Added Goals fields

This commit is contained in:
ivan-pelly
2026-03-15 09:35:58 -07:00
parent 242b1bce27
commit 53d0539d28
66 changed files with 1322 additions and 329 deletions
+66 -4
View File
@@ -294,8 +294,10 @@ public class StudentController : BaseController
});
}
var created = await _studentRepository.AddProgressEventAsync(userId, dto);
if (!created)
var newId = Guid.NewGuid();
var created = await _studentRepository.SaveProgressEventAsync(
newId, dto.GoalId, userId, dto.Content, isNew: true, dto.BenchmarkIds);
if (created is null)
{
return BadRequest(new ResponseResult
{
@@ -304,10 +306,70 @@ public class StudentController : BaseController
});
}
return StatusCode(StatusCodes.Status201Created, new ResponseResult
return StatusCode(StatusCodes.Status201Created, new ResponseResult<object>
{
Success = true,
Message = "Progress event added successfully."
Message = "Progress event added successfully.",
Data = new { progressEventId = created.Value }
});
}
[HttpPut("{idStudent:guid}/progress-events/{idProgressEvent:guid}")]
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")]
[ProducesResponseType(typeof(ResponseResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResponseResult), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ResponseResult>> UpdateProgressEvent(
Guid idStudent, Guid idProgressEvent, [FromBody] UpdateProgressEventDto dto)
{
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
if (error is not null)
{
return error;
}
var students = await _studentRepository.GetMyStudentsAsync(userId, programId, role);
if (!students.Select(s => s.StudentId).Contains(idStudent))
{
return NotFound(new ResponseResult
{
Success = false,
Message = "Student not found."
});
}
try
{
await _studentRepository.SaveProgressEventAsync(
idProgressEvent, Guid.Empty, userId, dto.Content, isNew: false, dto.BenchmarkIds);
return Ok(new ResponseResult
{
Success = true,
Message = "Progress event updated successfully."
});
}
catch (Exception ex)
{
return StatusCode(StatusCodes.Status500InternalServerError, new ResponseResult
{
Success = false,
Message = $"[DIAG] {ex.GetType().Name}: {ex.Message} | Inner: {ex.InnerException?.Message}"
});
}
}
[HttpGet("progress-events/{idProgressEvent:guid}/benchmarks")]
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")]
[ProducesResponseType(typeof(ResponseResult<List<Guid>>), StatusCodes.Status200OK)]
public async Task<ActionResult<ResponseResult<List<Guid>>>> GetProgressEventBenchmarks(Guid idProgressEvent)
{
var benchmarkIds = await _studentRepository.GetBenchmarkIdsForEventAsync(idProgressEvent);
return Ok(new ResponseResult<List<Guid>>
{
Success = true,
Message = "Benchmark associations retrieved.",
Data = benchmarkIds
});
}
@@ -5,4 +5,5 @@ public class AddProgressEventDto
public Guid GoalId { get; set; }
public string? Content { get; set; }
public bool IsSensitive { get; set; }
public List<Guid>? BenchmarkIds { get; set; }
}
@@ -6,4 +6,5 @@ public class CreateGoalDto
public string? Category { get; set; }
public string? Baseline { get; set; }
public Guid? GoalParentId { get; set; }
public DateTime? TargetCompletionDate { get; set; }
}
@@ -5,4 +5,8 @@ public class UpdateGoalDto
public string? Description { get; set; }
public string? Category { get; set; }
public string? Baseline { get; set; }
public DateTime? TargetCompletionDate { get; set; }
public DateTime? CloseDate { get; set; }
public bool? Achieved { get; set; }
public string? CloseNotes { get; set; }
}
@@ -0,0 +1,7 @@
namespace WinStudentGoalTracker.DataAccess;
public class UpdateProgressEventDto
{
public string? Content { get; set; }
public List<Guid>? BenchmarkIds { get; set; }
}
@@ -8,6 +8,10 @@ public class dbStudentGoalRow
public string? Description { get; set; }
public string? Category { get; set; }
public string? Baseline { get; set; }
public DateTime? TargetCompletionDate { get; set; }
public DateTime? CloseDate { get; set; }
public bool? Achieved { get; set; }
public string? CloseNotes { get; set; }
public int ProgressEventCount { get; set; }
public int BenchmarkCount { get; set; }
}
@@ -84,21 +84,49 @@ public class StudentRepository
return rowsAffected > 0;
}
public async Task<bool> AddProgressEventAsync(Guid userId, AddProgressEventDto dto)
// *****************************************************************
// Saves a progress event (insert or update) and syncs benchmark
// associations in a single stored procedure call.
// *****************************************************************
public async Task<Guid?> SaveProgressEventAsync(
Guid progressEventId, Guid goalId, Guid userId,
string? content, bool isNew, List<Guid>? benchmarkIds)
{
var idsCsv = benchmarkIds is { Count: > 0 }
? string.Join(",", benchmarkIds.Select(id => id.ToString()))
: null;
using var db = Connection;
var row = await db.QuerySingleOrDefaultAsync(
"sp_ProgressEvent_Insert",
var row = await db.QuerySingleOrDefaultAsync<dynamic>(
"sp_ProgressEvent_Save",
new
{
p_id_progress_event = Guid.NewGuid().ToString(),
p_id_goal = dto.GoalId.ToString(),
p_id_progress_event = progressEventId.ToString(),
p_id_goal = goalId.ToString(),
p_id_user_created = userId.ToString(),
p_content = dto.Content,
p_is_sensitive = dto.IsSensitive ? 1 : 0
p_content = content,
p_is_sensitive = 0,
p_is_new = isNew ? 1 : 0,
p_benchmark_ids = idsCsv
},
commandType: CommandType.StoredProcedure);
return row is not null;
if (row is null) return null;
return row.progressEventId is Guid g ? g : Guid.Parse((string)row.progressEventId);
}
// *****************************************************************
// Returns the benchmark IDs associated with a progress event.
// *****************************************************************
public async Task<List<Guid>> GetBenchmarkIdsForEventAsync(Guid progressEventId)
{
using var db = Connection;
var rows = await db.QueryAsync<dynamic>(
"sp_ProgressEventBenchmark_GetByEventId",
new { p_id_progress_event = progressEventId.ToString() },
commandType: CommandType.StoredProcedure);
return rows.Select(r => r.benchmarkId is Guid g ? g : Guid.Parse((string)r.benchmarkId)).ToList();
}
public async Task<Guid?> GetStudentIdForGoalAsync(Guid idGoal)
@@ -146,7 +174,8 @@ public class StudentRepository
p_id_user_created = userId.ToString(),
p_description = dto.Description,
p_category = dto.Category,
p_baseline = dto.Baseline
p_baseline = dto.Baseline,
p_target_completion_date = dto.TargetCompletionDate
},
commandType: CommandType.StoredProcedure);
@@ -159,6 +188,7 @@ public class StudentRepository
Description = dto.Description,
Category = dto.Category,
Baseline = dto.Baseline,
TargetCompletionDate = dto.TargetCompletionDate,
ProgressEventCount = 0
};
}
@@ -194,6 +224,10 @@ public class StudentRepository
Description = r.Description,
Category = r.Category,
Baseline = r.Baseline,
TargetCompletionDate = r.TargetCompletionDate,
CloseDate = r.CloseDate,
Achieved = r.Achieved,
CloseNotes = r.CloseNotes,
ProgressEventCount = r.ProgressEventCount,
BenchmarkCount = r.BenchmarkCount
}).ToList()
@@ -216,7 +250,11 @@ public class StudentRepository
p_id_user_created = (string?)null,
p_description = dto.Description,
p_category = dto.Category,
p_baseline = dto.Baseline
p_baseline = dto.Baseline,
p_target_completion_date = dto.TargetCompletionDate,
p_close_date = dto.CloseDate,
p_achieved = dto.Achieved,
p_close_notes = dto.CloseNotes
},
commandType: CommandType.StoredProcedure);
return rowsAffected > 0;
@@ -7,6 +7,10 @@ public class StudentGoalItem
public string? Description { get; set; }
public string? Category { get; set; }
public string? Baseline { get; set; }
public DateTime? TargetCompletionDate { get; set; }
public DateTime? CloseDate { get; set; }
public bool? Achieved { get; set; }
public string? CloseNotes { get; set; }
public int ProgressEventCount { get; set; }
public int BenchmarkCount { get; set; }
}
@@ -8,4 +8,5 @@ public class StudentResponse
public DateTime? LastEntryDate { get; set; }
public int GoalCount { get; set; }
public int ProgressEventCount { get; set; }
public int BenchmarkCount { get; set; }
}
-8
View File
@@ -1,8 +0,0 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Goal_Delete`(IN p_id_goal CHAR(36))
BEGIN
DELETE FROM goal
WHERE id_goal = p_id_goal;
SELECT ROW_COUNT() AS rows_affected;
END;;
DELIMITER ;
-15
View File
@@ -1,15 +0,0 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Goal_GetAll`()
BEGIN
SELECT
goalId,
goalParentId,
studentId,
description,
category,
baseline,
progressEventCount
FROM v_goal_card
ORDER BY goalId;
END;;
DELIMITER ;
@@ -8,6 +8,10 @@ BEGIN
description,
category,
baseline,
targetCompletionDate,
closeDate,
achieved,
closeNotes,
progressEventCount
FROM v_goal_card
WHERE goalId = p_id_goal
@@ -8,6 +8,10 @@ BEGIN
vc.`description`,
vc.`category`,
vc.`baseline`,
vc.`targetCompletionDate`,
vc.`closeDate`,
vc.`achieved`,
vc.`closeNotes`,
vc.`progressEventCount`,
vc.`benchmarkCount`
FROM `v_goal_card` vc
+5 -1
View File
@@ -6,7 +6,8 @@ CREATE DEFINER=`root`@`%` PROCEDURE `sp_Goal_Insert`(
IN p_id_user_created CHAR(36),
IN p_description TEXT,
IN p_category VARCHAR(255),
IN p_baseline TEXT
IN p_baseline TEXT,
IN p_target_completion_date DATE
)
BEGIN
INSERT INTO goal
@@ -18,6 +19,7 @@ BEGIN
description,
category,
baseline,
target_completion_date,
created_at,
updated_at
)
@@ -30,6 +32,7 @@ BEGIN
p_description,
p_category,
p_baseline,
p_target_completion_date,
UTC_TIMESTAMP(),
UTC_TIMESTAMP()
);
@@ -41,6 +44,7 @@ BEGIN
description,
category,
baseline,
target_completion_date,
created_at,
updated_at
FROM goal
+9 -1
View File
@@ -6,7 +6,11 @@ CREATE DEFINER=`root`@`%` PROCEDURE `sp_Goal_Update`(
IN p_id_user_created CHAR(36),
IN p_description TEXT,
IN p_category VARCHAR(255),
IN p_baseline TEXT
IN p_baseline TEXT,
IN p_target_completion_date DATE,
IN p_close_date DATE,
IN p_achieved TINYINT(1),
IN p_close_notes TEXT
)
BEGIN
UPDATE goal
@@ -17,6 +21,10 @@ BEGIN
description = COALESCE(p_description, description),
category = COALESCE(p_category, category),
baseline = COALESCE(p_baseline, baseline),
target_completion_date = COALESCE(p_target_completion_date, target_completion_date),
close_date = COALESCE(p_close_date, close_date),
achieved = COALESCE(p_achieved, achieved),
close_notes = COALESCE(p_close_notes, close_notes),
updated_at = UTC_TIMESTAMP()
WHERE id_goal = p_id_goal;
SELECT ROW_COUNT() AS rows_affected;
@@ -0,0 +1,11 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_ProgressEventBenchmark_GetByEventId`(
IN p_id_progress_event CHAR(36)
)
BEGIN
SELECT
peb.id_benchmark AS benchmarkId
FROM progress_event_benchmark peb
WHERE peb.id_progress_event = p_id_progress_event;
END;;
DELIMITER ;
@@ -1,8 +0,0 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_ProgressEvent_Delete`(IN p_id_progress_event CHAR(36))
BEGIN
DELETE FROM progress_event
WHERE id_progress_event = p_id_progress_event;
SELECT ROW_COUNT() AS rows_affected;
END;;
DELIMITER ;
@@ -1,16 +0,0 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_ProgressEvent_GetAll`()
BEGIN
SELECT
id_progress_event,
id_student,
id_goal,
id_user_created,
content,
is_sensitive,
created_at,
updated_at
FROM progress_event
ORDER BY id_progress_event;
END;;
DELIMITER ;
@@ -1,17 +0,0 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_ProgressEvent_GetById`(IN p_id_progress_event CHAR(36))
BEGIN
SELECT
id_progress_event,
id_student,
id_goal,
id_user_created,
content,
is_sensitive,
created_at,
updated_at
FROM progress_event
WHERE id_progress_event = p_id_progress_event
LIMIT 1;
END;;
DELIMITER ;
@@ -1,42 +0,0 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_ProgressEvent_Insert`(
IN p_id_progress_event CHAR(36),
IN p_id_goal CHAR(36),
IN p_id_user_created CHAR(36),
IN p_content TEXT,
IN p_is_sensitive TINYINT(1)
)
BEGIN
INSERT INTO progress_event
(
id_progress_event,
id_goal,
id_user_created,
content,
is_sensitive,
created_at,
updated_at
)
VALUES
(
p_id_progress_event,
p_id_goal,
p_id_user_created,
p_content,
p_is_sensitive,
UTC_TIMESTAMP(),
UTC_TIMESTAMP()
);
SELECT
id_progress_event,
id_goal,
id_user_created,
content,
is_sensitive,
created_at,
updated_at
FROM progress_event
WHERE id_progress_event = p_id_progress_event
LIMIT 1;
END;;
DELIMITER ;
@@ -0,0 +1,62 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_ProgressEvent_Save`(
IN p_id_progress_event CHAR(36),
IN p_id_goal CHAR(36),
IN p_id_user_created CHAR(36),
IN p_content TEXT,
IN p_is_sensitive TINYINT(1),
IN p_is_new TINYINT(1),
IN p_benchmark_ids TEXT
)
BEGIN
-- Insert or update the progress event
IF p_is_new = 1 THEN
INSERT INTO progress_event
(
id_progress_event,
id_goal,
id_user_created,
content,
is_sensitive,
created_at,
updated_at
)
VALUES
(
p_id_progress_event,
p_id_goal,
p_id_user_created,
p_content,
p_is_sensitive,
UTC_TIMESTAMP(),
UTC_TIMESTAMP()
);
ELSE
UPDATE progress_event
SET
content = COALESCE(p_content, content),
updated_at = UTC_TIMESTAMP()
WHERE id_progress_event = p_id_progress_event;
END IF;
-- Sync benchmark associations: remove those not in the list
DELETE FROM progress_event_benchmark
WHERE id_progress_event = p_id_progress_event
AND (p_benchmark_ids IS NULL
OR LENGTH(TRIM(p_benchmark_ids)) = 0
OR FIND_IN_SET(id_benchmark, p_benchmark_ids) = 0);
-- Add associations that are in the list but not yet in the table
IF p_benchmark_ids IS NOT NULL AND LENGTH(TRIM(p_benchmark_ids)) > 0 THEN
INSERT INTO progress_event_benchmark (id_progress_event_benchmark, id_progress_event, id_benchmark, created_at)
SELECT UUID(), p_id_progress_event, b.id_benchmark, UTC_TIMESTAMP()
FROM benchmark b
WHERE FIND_IN_SET(b.id_benchmark, p_benchmark_ids) > 0
AND b.id_benchmark NOT IN (
SELECT peb.id_benchmark
FROM progress_event_benchmark peb
WHERE peb.id_progress_event = p_id_progress_event
);
END IF;
-- Return the progress event ID for the caller
SELECT p_id_progress_event AS progressEventId;
END;;
DELIMITER ;
@@ -1,22 +0,0 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_ProgressEvent_Update`(
IN p_id_progress_event CHAR(36),
IN p_id_student CHAR(36),
IN p_id_goal CHAR(36),
IN p_id_user_created CHAR(36),
IN p_content TEXT,
IN p_is_sensitive TINYINT(1)
)
BEGIN
UPDATE progress_event
SET
id_student = COALESCE(p_id_student, id_student),
id_goal = COALESCE(p_id_goal, id_goal),
id_user_created = COALESCE(p_id_user_created, id_user_created),
content = COALESCE(p_content, content),
is_sensitive = COALESCE(p_is_sensitive, is_sensitive),
updated_at = UTC_TIMESTAMP()
WHERE id_progress_event = p_id_progress_event;
SELECT ROW_COUNT() AS rows_affected;
END;;
DELIMITER ;
@@ -1,14 +0,0 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_GetAll`()
BEGIN
SELECT
studentId,
identifier,
nextIepDate,
lastEntryDate,
goalCount,
progressEventCount
FROM v_student_card
ORDER BY studentId;
END;;
DELIMITER ;
+2 -1
View File
@@ -7,7 +7,8 @@ BEGIN
nextIepDate,
lastEntryDate,
goalCount,
progressEventCount
progressEventCount,
benchmarkCount
FROM v_student_card
WHERE studentId = p_id_student
LIMIT 1;
@@ -4,19 +4,18 @@ CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_GetWithAssignments`(
IN p_id_user CHAR(36)
)
BEGIN
-- Result set 1: All students in the program (card shape)
SELECT
vc.studentId,
vc.identifier,
vc.nextIepDate,
vc.lastEntryDate,
vc.goalCount,
vc.progressEventCount
vc.progressEventCount,
vc.benchmarkCount
FROM v_student_card vc
INNER JOIN student s ON s.id_student = vc.studentId
WHERE s.id_program = p_id_program
ORDER BY vc.studentId;
-- Result set 2: user_student assignments for the requesting user in this program
SELECT
us.id_user_student,
us.id_user,
+4
View File
@@ -6,6 +6,10 @@ CREATE TABLE `goal` (
`description` text,
`category` varchar(255) DEFAULT NULL,
`baseline` text,
`target_completion_date` date DEFAULT NULL,
`close_date` date DEFAULT NULL,
`achieved` tinyint(1) DEFAULT NULL,
`close_notes` text,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id_goal`),
+2 -2
View File
@@ -1,7 +1,7 @@
CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`%` SQL SECURITY DEFINER VIEW `winstudentgoaltracker`.`v_goal_card` AS
select `g`.`id_goal` AS `goalId`,`g`.`id_goal_parent` AS `goalParentId`,`g`.`id_student` AS `studentId`,`g`.`description` AS `description`,`g`.`category` AS `category`,`g`.`baseline` AS `baseline`,count(distinct `pe`.`id_progress_event`) AS `progressEventCount`,count(distinct `b`.`id_benchmark`) AS `benchmarkCount`
select `g`.`id_goal` AS `goalId`,`g`.`id_goal_parent` AS `goalParentId`,`g`.`id_student` AS `studentId`,`g`.`description` AS `description`,`g`.`category` AS `category`,`g`.`baseline` AS `baseline`,`g`.`target_completion_date` AS `targetCompletionDate`,`g`.`close_date` AS `closeDate`,`g`.`achieved` AS `achieved`,`g`.`close_notes` AS `closeNotes`,count(distinct `pe`.`id_progress_event`) AS `progressEventCount`,count(distinct `b`.`id_benchmark`) AS `benchmarkCount`
from ((`winstudentgoaltracker`.`goal` `g`
left
join `winstudentgoaltracker`.`progress_event` `pe` on((`pe`.`id_goal` = `g`.`id_goal`)))
left
join `winstudentgoaltracker`.`benchmark` `b` on((`b`.`id_goal` = `g`.`id_goal`))) group by `g`.`id_goal`,`g`.`id_goal_parent`,`g`.`id_student`,`g`.`description`,`g`.`category`,`g`.`baseline`;
join `winstudentgoaltracker`.`benchmark` `b` on((`b`.`id_goal` = `g`.`id_goal`))) group by `g`.`id_goal`,`g`.`id_goal_parent`,`g`.`id_student`,`g`.`description`,`g`.`category`,`g`.`baseline`,`g`.`target_completion_date`,`g`.`close_date`,`g`.`achieved`,`g`.`close_notes`;
@@ -42,6 +42,17 @@
></textarea>
</div>
<div class="field">
<label for="targetCompletionDate">Target Completion Date</label>
<input
id="targetCompletionDate"
type="date"
[(ngModel)]="form.targetCompletionDate"
name="targetCompletionDate"
/>
</div>
<!-- Parent goal dropdown hidden — may not be needed
@if (parentGoalOptions().length > 0) {
<div class="field">
<label for="goalParentId">Parent Goal <span class="optional">(optional)</span></label>
@@ -53,6 +64,7 @@
</select>
</div>
}
-->
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
@@ -20,6 +20,7 @@ export class AddGoalModal {
readonly studentId = input.required<string>();
readonly existingGoals = input.required<StudentGoalItem[]>();
readonly nextIepDate = input<string | null>();
readonly goalCreated = output<StudentGoalItem>();
readonly cancelled = output<void>();
@@ -35,8 +36,19 @@ export class AddGoalModal {
category: '',
baseline: '',
goalParentId: null,
targetCompletionDate: null,
};
// *****************************************************************
// Pre-fills targetCompletionDate from the student's nextIepDate.
// *****************************************************************
ngOnInit() {
const iepDate = this.nextIepDate?.();
if (iepDate) {
this.form.targetCompletionDate = iepDate;
}
}
// ************************** Properties ***************************
// ************************ Public Methods *************************
@@ -8,10 +8,6 @@
<p class="error">{{ errorMessage() }}</p>
}
@if (successMessage()) {
<p class="success">{{ successMessage() }}</p>
}
@if (loaded()) {
<div class="detail-card">
<div class="field">
@@ -46,3 +42,7 @@
</div>
</div>
}
@if (successMessage()) {
<p class="success">{{ successMessage() }}</p>
}
@@ -4,16 +4,18 @@
<span class="spacer"></span>
</div>
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}
@if (successMessage()) {
<p class="success">{{ successMessage() }}</p>
}
@if (loaded()) {
<div class="detail-card">
<div class="card-header">
<span class="card-title">Goal: {{ category }}</span>
@if (targetCompletionDate) {
<span class="card-title">Target: {{ targetCompletionDate | date:'mediumDate' }}</span>
} @else {
<span class="card-title">No target date</span>
}
</div>
<div class="card-body">
<div class="field">
<label class="field-label" for="category">Category</label>
<input id="category" class="field-input" type="text" [(ngModel)]="category" />
@@ -28,16 +30,50 @@
<textarea id="baseline" class="field-input field-textarea" [(ngModel)]="baseline" rows="3"
placeholder="Enter baseline..."></textarea>
</div>
<div class="field">
<label class="field-label" for="targetCompletionDate">Target Completion Date</label>
<input id="targetCompletionDate" class="field-input" type="date" [(ngModel)]="targetCompletionDate" />
</div>
@if (closeDate !== null) {
<div class="close-section">
<div class="field">
<label class="field-label" for="closeDate">Close Date</label>
<input id="closeDate" class="field-input" type="date" [(ngModel)]="closeDate" />
</div>
<div class="field field-row">
<label class="field-label" for="achieved">Achieved</label>
<input id="achieved" type="checkbox" [ngModel]="achieved ?? false" (ngModelChange)="achieved = $event" />
</div>
@if (achieved === false) {
<div class="field">
<label class="field-label" for="closeNotes">Close Notes <span class="required">*</span></label>
<textarea id="closeNotes" class="field-input field-textarea" [(ngModel)]="closeNotes" rows="3"
placeholder="Explain why the goal was not achieved..." required></textarea>
</div>
}
</div>
}
<div class="actions">
<button class="toolbar-btn" (click)="onCancel()" [disabled]="!hasChanges()">Cancel</button>
<button class="toolbar-btn save-btn" (click)="onSave()" [disabled]="!hasChanges() || saving()">
{{ saving() ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
<div class="card-footer">
<a class="detail-link" (click)="onProgressEvents()">Progress Events</a>
<a class="detail-link" (click)="onBenchmarks()">Benchmarks</a>
<button class="footer-btn btn-green" (click)="onProgressEvents()">Progress Events</button>
<button class="footer-btn btn-blue" (click)="onBenchmarks()">Benchmarks</button>
</div>
</div>
}
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}
@if (successMessage()) {
<p class="success">{{ successMessage() }}</p>
}
@@ -58,40 +58,77 @@
.error {
font-size: 0.875rem;
color: #dc2626;
margin: 0 0 1rem;
margin: 1rem 0 0;
}
.success {
font-size: 0.875rem;
color: #16a34a;
margin: 0 0 1rem;
margin: 1rem 0 0;
}
.detail-card {
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
max-width: 600px;
overflow: hidden;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 1.25rem;
background: #f8f9fa;
border-bottom: 1px solid #ddd;
}
.card-title {
font-size: 0.875rem;
font-weight: 600;
color: #333;
}
.card-body {
padding: 1.5rem;
}
.card-footer {
display: flex;
align-items: center;
gap: 1.5rem;
margin-top: 1rem;
padding-top: 1rem;
padding: 0.5rem 1.5rem;
border-top: 1px solid #eee;
}
.detail-link {
font-size: 0.875rem;
color: #4f46e5;
.footer-btn {
padding: 0.375rem 0.75rem;
background: transparent;
border: 1px solid;
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
text-decoration: underline;
}
.detail-link:hover {
color: #4338ca;
.btn-green {
color: #16a34a;
border-color: #16a34a;
}
.btn-green:hover {
background: #f0fdf4;
}
.btn-blue {
color: #2563eb;
border-color: #2563eb;
margin-left: auto;
}
.btn-blue:hover {
background: #eff6ff;
}
.field {
@@ -113,6 +150,7 @@
padding: 0.375rem 0.5rem;
border: 1px solid #ccc;
border-radius: 6px;
font-family: inherit;
font-size: 0.9375rem;
outline: none;
}
@@ -147,3 +185,24 @@
.save-btn:hover {
background: #4338ca;
}
.close-section {
border-top: 1px solid #eee;
padding-top: 1rem;
margin-top: 0.5rem;
}
.field-row {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.field-row input[type="checkbox"] {
width: 1.125rem;
height: 1.125rem;
}
.required {
color: #dc2626;
}
@@ -1,13 +1,14 @@
import { Component, inject, signal, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { DatePipe } from '@angular/common';
import { Subscription } from 'rxjs';
import { StudentService } from '../../../shared/services/student.service';
import { StudentGoalItem } from '../../../shared/classes/student-goal';
@Component({
selector: 'app-goal-card-full',
imports: [FormsModule],
imports: [FormsModule, DatePipe],
templateUrl: './goal-card-full.html',
styleUrl: './goal-card-full.scss',
})
@@ -42,6 +43,10 @@ export class GoalCardFull implements OnDestroy {
protected description = '';
protected category = '';
protected baseline = '';
protected targetCompletionDate: string | null = null;
protected closeDate: string | null = null;
protected achieved: boolean | null = null;
protected closeNotes: string | null = null;
// Read-only metadata
protected progressEventCount = 0;
@@ -51,6 +56,10 @@ export class GoalCardFull implements OnDestroy {
private savedDescription = '';
private savedCategory = '';
private savedBaseline = '';
private savedTargetCompletionDate: string | null = null;
private savedCloseDate: string | null = null;
private savedAchieved: boolean | null = null;
private savedCloseNotes: string | null = null;
// ************************** Properties ***************************
@@ -60,7 +69,11 @@ export class GoalCardFull implements OnDestroy {
hasChanges(): boolean {
return this.description !== this.savedDescription
|| this.category !== this.savedCategory
|| this.baseline !== this.savedBaseline;
|| this.baseline !== this.savedBaseline
|| this.targetCompletionDate !== this.savedTargetCompletionDate
|| this.closeDate !== this.savedCloseDate
|| this.achieved !== this.savedAchieved
|| this.closeNotes !== this.savedCloseNotes;
}
// ************************ Public Methods *************************
@@ -79,6 +92,10 @@ export class GoalCardFull implements OnDestroy {
description: this.description,
category: this.category,
baseline: this.baseline,
targetCompletionDate: this.targetCompletionDate,
closeDate: this.closeDate,
achieved: this.achieved,
closeNotes: this.closeNotes,
});
this.saving.set(false);
@@ -87,7 +104,12 @@ export class GoalCardFull implements OnDestroy {
this.savedDescription = this.description;
this.savedCategory = this.category;
this.savedBaseline = this.baseline;
this.savedTargetCompletionDate = this.targetCompletionDate;
this.savedCloseDate = this.closeDate;
this.savedAchieved = this.achieved;
this.savedCloseNotes = this.closeNotes;
this.successMessage.set('Changes saved.');
this.studentService.notifyDataChanged();
} else {
this.errorMessage.set(result.message);
}
@@ -100,6 +122,10 @@ export class GoalCardFull implements OnDestroy {
this.description = this.savedDescription;
this.category = this.savedCategory;
this.baseline = this.savedBaseline;
this.targetCompletionDate = this.savedTargetCompletionDate;
this.closeDate = this.savedCloseDate;
this.achieved = this.savedAchieved;
this.closeNotes = this.savedCloseNotes;
this.errorMessage.set(null);
this.successMessage.set(null);
}
@@ -122,6 +148,14 @@ export class GoalCardFull implements OnDestroy {
// ********************** Support Procedures ***********************
// *****************************************************************
// Normalizes an API date string to YYYY-MM-DD for <input type="date">.
// *****************************************************************
private toDateInput(value: string | null): string | null {
if (!value) return null;
return value.substring(0, 10);
}
// *****************************************************************
// Loads the goal by finding it in the student's goal list.
// *****************************************************************
@@ -142,12 +176,20 @@ export class GoalCardFull implements OnDestroy {
this.description = goal.description;
this.category = goal.category;
this.baseline = goal.baseline;
this.targetCompletionDate = this.toDateInput(goal.targetCompletionDate);
this.closeDate = this.toDateInput(goal.closeDate);
this.achieved = goal.achieved;
this.closeNotes = goal.closeNotes;
this.progressEventCount = goal.progressEventCount;
this.benchmarkCount = goal.benchmarkCount;
this.savedDescription = goal.description;
this.savedCategory = goal.category;
this.savedBaseline = goal.baseline;
this.savedTargetCompletionDate = this.toDateInput(goal.targetCompletionDate);
this.savedCloseDate = this.toDateInput(goal.closeDate);
this.savedAchieved = goal.achieved;
this.savedCloseNotes = goal.closeNotes;
this.loaded.set(true);
});
}
@@ -1,14 +1,24 @@
<div class="card" (click)="onCardClick()">
<div class="card-header">
<span class="category-badge">{{ goal().category }}</span>
<span class="event-count">{{ goal().progressEventCount }} events</span>
<span class="card-title">Goal: {{ goal().category }}</span>
@if (goal().closeDate !== null) {
<span class="closed-badge" [class.achieved]="goal().achieved === true">
{{ goal().achieved === true ? 'Closed ✓' : 'Closed ✗' }}
</span>
}
@if (goal().targetCompletionDate) {
<span class="event-count">Target: {{ goal().targetCompletionDate | date:'mediumDate' }}</span>
} @else {
<span class="event-count">No target date</span>
}
</div>
<h3 class="title">{{ goal().category }}</h3>
<div class="card-body">
<p class="description">{{ goal().description }}</p>
</div>
<div class="card-footer">
<a class="footer-link" (click)="$event.stopPropagation(); onBenchmarksClick()">Benchmarks</a>
<a class="footer-link" (click)="$event.stopPropagation(); onProgressEventsClick()">Progress Events</a>
<button class="footer-btn btn-green" (click)="$event.stopPropagation(); onProgressEventsClick()">Progress Events</button>
<button class="footer-btn btn-blue" (click)="$event.stopPropagation(); onBenchmarksClick()">Benchmarks</button>
</div>
</div>
@@ -6,64 +6,37 @@
.card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 1.25rem 1.5rem;
border: 1px solid #ddd;
display: flex;
flex-direction: column;
gap: 0.625rem;
cursor: pointer;
min-width: 0;
}
.card-footer {
display: flex;
align-items: center;
gap: 1.5rem;
margin: 0 -1.5rem -1rem;
padding: 0.5rem 1.5rem 0.5rem;
border-top: 1px solid #eee;
}
.footer-link {
font-size: 0.875rem;
color: #4f46e5;
cursor: pointer;
text-decoration: underline;
}
.footer-link:hover {
color: #4338ca;
overflow: hidden;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 1.25rem;
background: #f8f9fa;
border-bottom: 1px solid #ddd;
}
.category-badge {
padding: 0.2rem 0.6rem;
background: #eef2ff;
color: #4f46e5;
border-radius: 999px;
font-size: 0.75rem;
.card-title {
font-size: 0.875rem;
font-weight: 600;
color: #333;
}
.event-count {
font-size: 0.8125rem;
color: #888;
font-size: 0.875rem;
font-weight: 600;
color: #333;
}
.title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #111;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.card-body {
padding: 1.25rem 1.5rem;
}
.description {
@@ -72,7 +45,61 @@
color: #555;
line-height: 1.5;
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-footer {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.5rem 1.5rem;
border-top: 1px solid #eee;
}
.footer-btn {
padding: 0.375rem 0.75rem;
background: transparent;
border: 1px solid;
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.btn-green {
color: #16a34a;
border-color: #16a34a;
}
.btn-green:hover {
background: #f0fdf4;
}
.btn-blue {
color: #2563eb;
border-color: #2563eb;
margin-left: auto;
}
.btn-blue:hover {
background: #eff6ff;
}
.closed-badge {
font-size: 0.75rem;
font-weight: 600;
padding: 0.125rem 0.5rem;
border-radius: 4px;
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
.closed-badge.achieved {
background: #f0fdf4;
color: #16a34a;
border-color: #bbf7d0;
}
@@ -1,10 +1,11 @@
import { Component, inject, input } from '@angular/core';
import { DatePipe } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { StudentGoalItem } from '../../../shared/classes/student-goal';
@Component({
selector: 'app-goal-card',
imports: [],
imports: [DatePipe],
templateUrl: './goal-card.html',
styleUrl: './goal-card.scss',
})
@@ -13,7 +13,7 @@
}
@if (showAddModal()) {
<app-add-goal-modal [studentId]="studentId" [existingGoals]="goals()" (goalCreated)="onGoalCreated($event)"
<app-add-goal-modal [studentId]="studentId" [existingGoals]="goals()" [nextIepDate]="nextIepDate()" (goalCreated)="onGoalCreated($event)"
(cancelled)="onModalCancelled()" />
}
@@ -52,7 +52,7 @@
font-size: 1.125rem;
font-weight: 600;
color: #333;
margin: 0 0 0.5rem;
margin: 0 0 1.25rem;
}
.spacer {
@@ -78,6 +78,7 @@
.card-grid {
display: flex;
flex-wrap: wrap;
align-content: start;
gap: 1rem;
overflow-y: auto;
flex: 1;
@@ -32,6 +32,7 @@ export class GoalList implements OnDestroy {
protected studentId!: string;
protected readonly studentIdentifier = signal<string | null>(null);
protected readonly nextIepDate = signal<string | null>(null);
protected readonly goals = signal<StudentGoalItem[]>([]);
protected readonly showAddModal = signal(false);
protected readonly errorMessage = signal<string | null>(null);
@@ -71,6 +72,13 @@ export class GoalList implements OnDestroy {
// Loads goals for the student from the service.
// *****************************************************************
private loadGoals() {
this.studentService.getStudentById(this.studentId).then(studentResult => {
if (studentResult.success && studentResult.payload) {
const iep = studentResult.payload.nextIepDate;
this.nextIepDate.set(iep ? String(iep).substring(0, 10) : null);
}
});
this.studentService.getGoalsForStudent(this.studentId).then(data => {
if (!data.success) {
this.errorMessage.set(data.message);
@@ -1 +1,57 @@
<p>progress-edit works!</p>
<div class="toolbar">
<button class="toolbar-btn back-btn" (click)="onBack()">&#8593; Progress Events</button>
<span class="toolbar-title">{{ isNew() ? 'New Progress Event' : 'Edit Progress Event' }}</span>
<span class="spacer"></span>
</div>
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}
@if (loaded()) {
<div class="detail-card">
<div class="field">
<span class="field-label">Goal: {{ goalCategory }}</span>
</div>
<div class="field">
<label class="field-label" for="content">Notes</label>
<textarea id="content" class="field-input field-textarea" [(ngModel)]="content" rows="5"
placeholder="Enter progress event notes..."></textarea>
</div>
@if (benchmarkItems().length > 0) {
<div class="field">
<span class="field-label">Associated Benchmarks</span>
<div class="benchmark-checks">
@for (bm of benchmarkItems(); track bm.benchmarkId) {
<label class="check-item">
<input type="checkbox" [checked]="bm.checked" (change)="onToggleBenchmark(bm.benchmarkId)" />
{{ bm.label }}
</label>
}
</div>
</div>
}
@if (!isNew()) {
<div class="metadata">
@if (createdByName) {
<span class="meta-item">Created by: {{ createdByName }}</span>
}
@if (createdAt) {
<span class="meta-item">Created: {{ createdAt | date:'medium' }}</span>
}
</div>
}
<div class="actions">
<button class="toolbar-btn" (click)="onCancel()" [disabled]="!hasChanges()">Cancel</button>
<button class="toolbar-btn save-btn" (click)="onSave()" [disabled]="!hasChanges() || saving()">
{{ saving() ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
}
@if (successMessage()) {
<p class="success">{{ successMessage() }}</p>
}
@@ -0,0 +1,163 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
}
.toolbar {
display: flex;
align-items: center;
position: relative;
gap: 0.75rem;
height: 40px;
padding-right: 0.5rem;
border-radius: 8px;
background: #fff;
border-bottom: 1px solid #ddd;
margin-bottom: 1rem;
flex-shrink: 0;
}
.toolbar-btn {
padding: 0.375rem 0.75rem;
background: transparent;
color: #4f46e5;
border: 1px solid #4f46e5;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.toolbar-btn:hover {
background: #eef2ff;
}
.toolbar-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.back-btn {
margin-left: 0.5rem;
}
.toolbar-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-weight: 600;
font-size: 1.25rem;
color: #333;
}
.spacer {
flex: 1;
}
.error {
font-size: 0.875rem;
color: #dc2626;
margin: 0 0 1rem;
}
.success {
font-size: 0.875rem;
color: #16a34a;
margin: 0 0 1rem;
}
.detail-card {
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
max-width: 600px;
}
.field {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.field-label {
font-size: 0.75rem;
font-weight: 600;
color: #666;
text-transform: uppercase;
letter-spacing: 0.025em;
margin-bottom: 0.25rem;
}
.field-input {
padding: 0.375rem 0.5rem;
border: 1px solid #ccc;
border-radius: 6px;
font-size: 0.9375rem;
outline: none;
}
.field-input:focus {
border-color: #4f46e5;
}
.field-textarea {
font-family: inherit;
resize: vertical;
min-height: 5rem;
}
.benchmark-checks {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.25rem;
}
.check-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
color: #333;
cursor: pointer;
}
.check-item input[type="checkbox"] {
width: 1rem;
height: 1rem;
accent-color: #4f46e5;
}
.metadata {
display: flex;
gap: 1.5rem;
margin-bottom: 1rem;
}
.meta-item {
font-size: 0.8125rem;
color: #888;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.actions .toolbar-btn {
min-width: 6rem;
}
.save-btn {
background: #4f46e5;
color: #fff;
border-color: #4f46e5;
}
.save-btn:hover {
background: #4338ca;
}
@@ -1,11 +1,228 @@
import { Component } from '@angular/core';
import { Component, inject, signal, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { DatePipe } from '@angular/common';
import { Subscription } from 'rxjs';
import { StudentService } from '../../../shared/services/student.service';
import { BenchmarkDto } from '../../../shared/classes/benchmark.dto';
interface BenchmarkCheckItem {
benchmarkId: string;
label: string;
checked: boolean;
}
@Component({
selector: 'app-progress-edit',
imports: [],
imports: [FormsModule, DatePipe],
templateUrl: './progress-edit.html',
styleUrl: './progress-edit.scss',
})
export class ProgressEdit {
export class ProgressEdit implements OnDestroy {
// ************************** Constructor **************************
constructor() {
this.paramSub = this.route.paramMap.subscribe(params => {
this.studentId = params.get('studentId')!;
this.goalId = params.get('goalId')!;
this.progressEventId = params.get('progressEventId') ?? null;
this.loadData();
});
}
// ************************** Declarations *************************
private readonly studentService = inject(StudentService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly paramSub: Subscription;
private studentId!: string;
private goalId!: string;
private progressEventId: string | null = null;
protected readonly loaded = signal(false);
protected readonly isNew = signal(false);
protected readonly errorMessage = signal<string | null>(null);
protected readonly successMessage = signal<string | null>(null);
protected readonly saving = signal(false);
// Form fields
protected content = '';
private savedContent = '';
// Benchmark checkboxes
protected benchmarkItems = signal<BenchmarkCheckItem[]>([]);
private savedBenchmarkSelections: Set<string> = new Set();
// Read-only metadata
protected goalCategory = '';
protected createdByName = '';
protected createdAt: Date | null = null;
// ************************** Properties ***************************
// *****************************************************************
// Returns true if the form has unsaved changes.
// *****************************************************************
hasChanges(): boolean {
if (this.content !== this.savedContent) return true;
const current = new Set(this.benchmarkItems().filter(b => b.checked).map(b => b.benchmarkId));
if (current.size !== this.savedBenchmarkSelections.size) return true;
for (const id of current) {
if (!this.savedBenchmarkSelections.has(id)) return true;
}
return false;
}
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
// *****************************************************************
// Saves the progress event (create or update) and any benchmark
// associations based on the checked checkboxes.
// *****************************************************************
async onSave() {
this.saving.set(true);
this.errorMessage.set(null);
this.successMessage.set(null);
const checkedIds = this.benchmarkItems()
.filter(b => b.checked)
.map(b => b.benchmarkId);
if (this.isNew()) {
const result = await this.studentService.addProgressEvent(
this.studentId, this.goalId, this.content.trim(),
checkedIds.length > 0 ? checkedIds : undefined
);
this.saving.set(false);
if (result.success) {
this.successMessage.set('Progress event created.');
this.savedContent = this.content;
this.savedBenchmarkSelections = new Set(checkedIds);
this.studentService.notifyDataChanged();
if (result.payload?.progressEventId) {
this.router.navigate(['/students', this.studentId, 'goals', this.goalId, 'progress', result.payload.progressEventId]);
}
} else {
this.errorMessage.set(result.message);
}
} else {
const result = await this.studentService.updateProgressEvent(
this.studentId, this.progressEventId!, this.content.trim(), checkedIds
);
this.saving.set(false);
if (result.success) {
this.savedContent = this.content;
this.savedBenchmarkSelections = new Set(checkedIds);
this.successMessage.set('Changes saved.');
} else {
this.errorMessage.set(result.message);
}
}
}
// *****************************************************************
// Reverts the form to the last-saved state.
// *****************************************************************
onCancel() {
this.content = this.savedContent;
this.benchmarkItems.update(items =>
items.map(b => ({ ...b, checked: this.savedBenchmarkSelections.has(b.benchmarkId) }))
);
this.errorMessage.set(null);
this.successMessage.set(null);
}
onBack() {
this.router.navigate(['/students', this.studentId, 'goals', this.goalId, 'progress']);
}
// *****************************************************************
// Toggles a benchmark checkbox.
// *****************************************************************
onToggleBenchmark(benchmarkId: string) {
this.benchmarkItems.update(items =>
items.map(b => b.benchmarkId === benchmarkId ? { ...b, checked: !b.checked } : b)
);
}
ngOnDestroy() {
this.paramSub.unsubscribe();
}
// ********************** Support Procedures ***********************
// *****************************************************************
// Loads all data needed for the form: goal category, benchmarks,
// and (for edit mode) the existing event content + associations.
// *****************************************************************
private async loadData() {
this.loaded.set(false);
// Load goal category
const goalsResult = await this.studentService.getGoalsForStudent(this.studentId);
if (goalsResult.success && goalsResult.payload) {
const goal = goalsResult.payload.goals.find(g => g.goalId === this.goalId);
this.goalCategory = goal?.category ?? '';
}
// Load benchmarks for this goal
const bmResult = await this.studentService.getBenchmarksForStudent(this.studentId);
const goalBenchmarks = (bmResult.success && bmResult.payload)
? bmResult.payload.benchmarks.filter(b => b.goalId === this.goalId)
: [];
if (!this.progressEventId) {
// New event mode
this.isNew.set(true);
this.content = '';
this.savedContent = '';
this.savedBenchmarkSelections = new Set();
this.benchmarkItems.set(goalBenchmarks.map(b => ({
benchmarkId: b.benchmarkId,
label: b.shortName || b.benchmark,
checked: false,
})));
this.loaded.set(true);
return;
}
// Edit mode — load existing event
this.isNew.set(false);
const eventsResult = await this.studentService.getProgressEventsForGoal(this.goalId);
if (!eventsResult.success || !eventsResult.payload) {
this.errorMessage.set(eventsResult.message);
this.loaded.set(true);
return;
}
const event = eventsResult.payload.find(e => e.progressEventId === this.progressEventId);
if (!event) {
this.errorMessage.set('Progress event not found.');
this.loaded.set(true);
return;
}
this.content = event.content;
this.savedContent = event.content;
this.createdByName = event.createdByName;
this.createdAt = event.createdAt;
// Load existing benchmark associations
const assocResult = await this.studentService.getProgressEventBenchmarks(this.progressEventId!);
const associatedIds = new Set(assocResult.success && assocResult.payload ? assocResult.payload : []);
this.savedBenchmarkSelections = new Set(associatedIds);
this.benchmarkItems.set(goalBenchmarks.map(b => ({
benchmarkId: b.benchmarkId,
label: b.shortName || b.benchmark,
checked: associatedIds.has(b.benchmarkId),
})));
this.loaded.set(true);
}
}
@@ -1,7 +1,7 @@
<div class="card">
<p class="content">{{ event().content }}</p>
<div class="action-icons">
<button class="icon-btn" title="Edit">&#9998;</button>
<button class="edit-btn" (click)="onEdit()">Edit</button>
<!-- <button class="icon-btn" title="Delete">&#128465;</button> -->
</div>
<span class="author">{{ event().createdByName }}</span>
@@ -47,15 +47,17 @@
text-align: right;
}
// .icon-btn {
// background: none;
// border: none;
// cursor: pointer;
// font-size: 1rem;
// color: #888;
// padding: 0.125rem;
// }
.icon-btn:hover {
.edit-btn {
padding: 0.25rem 0.625rem;
background: transparent;
color: #4f46e5;
border: 1px solid #4f46e5;
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.edit-btn:hover {
background: #eef2ff;
}
@@ -1,5 +1,6 @@
import { Component, input } from '@angular/core';
import { Component, inject, input } from '@angular/core';
import { DatePipe } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { ProgressEventDto } from '../../../shared/classes/progress-event.dto';
@Component({
@@ -14,6 +15,8 @@ export class ProgressItem {
// ************************** Declarations *************************
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
readonly event = input.required<ProgressEventDto>();
// ************************** Properties ***************************
@@ -22,5 +25,9 @@ export class ProgressItem {
// ************************ Event Handlers *************************
onEdit() {
this.router.navigate([this.event().progressEventId], { relativeTo: this.route });
}
// ********************** Support Procedures ***********************
}
@@ -67,8 +67,7 @@ export class ProgressList implements OnDestroy {
// ************************ Event Handlers *************************
onAddProgressEvent() {
this.showAddModal.set(true);
// TODO: Wire up add-progress-event modal component
this.router.navigate(['/students', this.studentId, 'goals', this.goalId, 'progress', 'new']);
}
// *****************************************************************
@@ -2,6 +2,8 @@
<div class="node-row" [style.padding-left]="indent()">
@if (hasToggle(node)) {
<span class="toggle-indicator" (click)="onToggle(node, $event)">{{ node.expanded ? '' : '+' }}</span>
} @else {
<span class="toggle-placeholder"></span>
}
@if (node.routerLink) {
<a class="node-label" [routerLink]="node.routerLink" routerLinkActive="active"
@@ -34,6 +34,11 @@
flex-shrink: 0;
}
.toggle-placeholder {
width: calc(1rem + 2px);
flex-shrink: 0;
}
.node-label {
flex: 1;
font-size: 0.8125rem;
@@ -12,6 +12,11 @@
@if (loaded()) {
<div class="detail-card">
<div class="detail-card-header">
<span class="detail-card-title">Student</span>
</div>
<div class="detail-card-body">
<div class="field">
<label class="field-label" for="identifier">Name</label>
<input id="identifier" class="field-input" type="text" [(ngModel)]="identifier" />
@@ -27,9 +32,10 @@
{{ saving() ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
<div class="card-footer">
<a class="detail-link" (click)="onGoals()">Goals</a>
<button class="footer-btn btn-dark-green" (click)="onGoals()">Goals</button>
<span class="spacer"></span>
@if (successMessage()) {
<span class="success-label" [class.fade-out]="fading()">{{ successMessage() }}</span>
@@ -67,8 +67,26 @@
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
max-width: 480px;
overflow: hidden;
}
.detail-card-header {
display: flex;
align-items: center;
padding: 0.625rem 1.25rem;
background: #f8f9fa;
border-bottom: 1px solid #ddd;
}
.detail-card-title {
font-size: 0.875rem;
font-weight: 600;
color: #333;
}
.detail-card-body {
padding: 1.5rem;
}
.field {
@@ -106,20 +124,27 @@
.card-footer {
display: flex;
align-items: center;
margin: 1rem -1.5rem -1rem;
padding: 0.5rem 1.5rem 0.5rem;
padding: 0.5rem 1.5rem;
border-top: 1px solid #eee;
}
.detail-link {
font-size: 0.875rem;
color: #4f46e5;
.footer-btn {
padding: 0.375rem 0.75rem;
background: transparent;
border: 1px solid;
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
text-decoration: underline;
}
.detail-link:hover {
color: #4338ca;
.btn-dark-green {
color: #15803d;
border-color: #15803d;
}
.btn-dark-green:hover {
background: #f0fdf4;
}
.success-label {
@@ -15,5 +15,6 @@
<div class="stats">
<span>Goals: {{ student().goalCount }}</span>
<span>Events: {{ student().progressEventCount }}</span>
<span>Benchmarks: {{ student().benchmarkCount }}</span>
</div>
</div>
@@ -7,6 +7,7 @@ import { GoalCardFull } from './components/goal-card-full/goal-card-full';
import { ProgressList } from './components/progress-list/progress-list';
import { BenchmarkList } from './components/benchmark-list/benchmark-list';
import { BenchmarkCardFull } from './components/benchmark-card-full/benchmark-card-full';
import { ProgressEdit } from './components/progress-edit/progress-edit';
export default [
{
@@ -19,6 +20,8 @@ export default [
{ path: 'students/:studentId/goals', component: GoalList },
{ path: 'students/:studentId/goals/:goalId', component: GoalCardFull },
{ path: 'students/:studentId/goals/:goalId/progress', component: ProgressList },
{ path: 'students/:studentId/goals/:goalId/progress/new', component: ProgressEdit },
{ path: 'students/:studentId/goals/:goalId/progress/:progressEventId', component: ProgressEdit },
{ path: 'students/:studentId/goals/:goalId/benchmarks', component: BenchmarkList },
{ path: 'students/:studentId/goals/:goalId/benchmarks/new', component: BenchmarkCardFull },
{ path: 'students/:studentId/goals/:goalId/benchmarks/:benchmarkId', component: BenchmarkCardFull },
@@ -0,0 +1,4 @@
<div class="tile" [class.checked]="checked()" (click)="onTap()">
<span class="check-indicator">{{ checked() ? '✓' : '' }}</span>
<span class="tile-label">{{ label() }}</span>
</div>
@@ -0,0 +1,46 @@
.tile {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.tile:active {
background: #f5f5f5;
}
.tile.checked {
border-color: #4f46e5;
background: #eef2ff;
}
.check-indicator {
display: flex;
align-items: center;
justify-content: center;
width: 1.375rem;
height: 1.375rem;
border: 2px solid #ccc;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.tile.checked .check-indicator {
background: #4f46e5;
border-color: #4f46e5;
}
.tile-label {
font-size: 0.9375rem;
color: #333;
line-height: 1.3;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ToggleBenchmark } from './toggle-benchmark';
describe('ToggleBenchmark', () => {
let component: ToggleBenchmark;
let fixture: ComponentFixture<ToggleBenchmark>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ToggleBenchmark]
})
.compileComponents();
fixture = TestBed.createComponent(ToggleBenchmark);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,22 @@
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-toggle-benchmark',
imports: [],
templateUrl: './toggle-benchmark.html',
styleUrl: './toggle-benchmark.scss',
})
export class ToggleBenchmark {
// ************************** Declarations *************************
readonly label = input.required<string>();
readonly checked = input.required<boolean>();
readonly toggled = output<void>();
// ************************ Event Handlers *************************
onTap() {
this.toggled.emit();
}
}
@@ -15,11 +15,20 @@
<div class="form-card">
<label class="field-label">Progress event notes</label>
<textarea class="notes-input" placeholder="Type your message here." rows="5" [(ngModel)]="notes"></textarea>
<label class="field-label">Voice note</label>
<button class="voice-btn">🎙 Record voice note</button>
</div>
@if (benchmarkItems().length > 0) {
<label class="field-label">Associated Benchmarks</label>
<div class="benchmark-scroll">
@for (bm of benchmarkItems(); track bm.benchmarkId) {
<app-toggle-benchmark
[label]="bm.label"
[checked]="bm.checked"
(toggled)="onToggleBenchmark(bm.benchmarkId)" />
}
</div>
}
<!-- Save button -->
<button class="save-btn" [disabled]="!canSave()" (click)="onSave()">
{{ saving() ? 'Saving...' : 'Save' }}
@@ -130,3 +130,14 @@
border-radius: 8px;
font-size: 0.875rem;
}
/* Scrollable benchmark region */
.benchmark-scroll {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
overflow-y: auto;
min-height: 0;
margin-bottom: 1rem;
}
@@ -3,12 +3,19 @@ import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { describeHttpError } from '../../../shared/classes/http-errors';
import { DummyStudentService } from '../../../shared/services/dummy-student.service';
import { StudentService } from '../../../shared/services/student.service';
interface BenchmarkCheckItem {
benchmarkId: string;
label: string;
checked: boolean;
}
import { ToggleBenchmark } from '../../components/toggle-benchmark/toggle-benchmark';
@Component({
selector: 'app-add-progress-event',
imports: [FormsModule],
imports: [FormsModule, ToggleBenchmark],
templateUrl: './add-progress-event.html',
styleUrl: './add-progress-event.scss',
})
@@ -21,6 +28,7 @@ export class AddProgressEvent {
this.studentIdentifier.set(this.route.snapshot.queryParamMap.get('studentIdentifier') ?? '');
this.studentId = this.route.snapshot.paramMap.get('studentId') ?? '';
this.goalId = this.route.snapshot.paramMap.get('goalId') ?? '';
this.loadBenchmarks();
}
// ************************** Declarations *************************
@@ -37,6 +45,7 @@ export class AddProgressEvent {
protected readonly notes = signal('');
protected readonly error = signal<string | null>(null);
protected readonly saving = signal(false);
protected readonly benchmarkItems = signal<BenchmarkCheckItem[]>([]);
// ************************** Properties ***************************
@@ -59,15 +68,31 @@ export class AddProgressEvent {
}
// *****************************************************************
// Saves the progress event. On success, returns to the goal list.
// On failure, displays the error message from the API.
// Toggles a benchmark checkbox.
// *****************************************************************
onToggleBenchmark(benchmarkId: string) {
this.benchmarkItems.update(items =>
items.map(b => b.benchmarkId === benchmarkId ? { ...b, checked: !b.checked } : b)
);
}
// *****************************************************************
// Saves the progress event with optional benchmark associations.
// On success, returns to the goal list.
// *****************************************************************
async onSave() {
this.error.set(null);
this.saving.set(true);
const checkedIds = this.benchmarkItems()
.filter(b => b.checked)
.map(b => b.benchmarkId);
try {
const result = await this.studentService.addProgressEvent(this.studentId, this.goalId, this.notes().trim());
const result = await this.studentService.addProgressEvent(
this.studentId, this.goalId, this.notes().trim(),
checkedIds.length > 0 ? checkedIds : undefined
);
this.saving.set(false);
if (result.success) {
this.router.navigate(['students', this.studentId, 'goals']);
@@ -81,4 +106,19 @@ export class AddProgressEvent {
}
// ********************** Support Procedures ***********************
// *****************************************************************
// Loads benchmarks for the current goal to populate checkboxes.
// *****************************************************************
private async loadBenchmarks() {
const result = await this.studentService.getBenchmarksForStudent(this.studentId);
if (result.success && result.payload) {
const goalBenchmarks = result.payload.benchmarks.filter(b => b.goalId === this.goalId);
this.benchmarkItems.set(goalBenchmarks.map(b => ({
benchmarkId: b.benchmarkId,
label: b.shortName || b.benchmark,
checked: false,
})));
}
}
}
@@ -3,4 +3,5 @@ export interface CreateGoalDto {
category: string;
baseline: string;
goalParentId: string | null;
targetCompletionDate: string | null;
}
@@ -5,4 +5,5 @@ export interface StudentCardDto {
lastEntryDate: Date | null;
goalCount: number;
progressEventCount: number;
benchmarkCount: number;
}
@@ -9,6 +9,10 @@ export interface StudentGoalItem {
description: string; // goal.description — text
category: string; // goal.category — varchar(100)
baseline: string; // goal.baseline — text
targetCompletionDate: string | null; // goal.target_completion_date — date
closeDate: string | null; // goal.close_date — date
achieved: boolean | null; // goal.achieved — tinyint(1), null until closed
closeNotes: string | null; // goal.close_notes — text
progressEventCount: number; // count of progress_event rows for this goal
benchmarkCount: number; // count of benchmark rows for this goal
}
@@ -15,6 +15,8 @@ const STORAGE_KEYS = {
JWT: 'auth_jwt',
REFRESH_TOKEN: 'auth_refresh_token',
SESSION_TOKEN: 'auth_session_token',
PROGRAM_NAME: 'auth_program_name',
SCHOOL_DISTRICT_NAME: 'auth_school_district_name',
} as const;
// Refresh the JWT this many seconds before it actually expires.
@@ -34,8 +36,8 @@ export class Auth {
private readonly _sessionToken = signal<string | null>(this.loadSessionToken());
private readonly _programs = signal<UserProgramSummary[]>([]);
private readonly _isRefreshing = signal(false);
private readonly _programName = signal<string>('');
private readonly _schoolDistrictName = signal<string>('');
private readonly _programName = signal<string>(localStorage.getItem(STORAGE_KEYS.PROGRAM_NAME) ?? '');
private readonly _schoolDistrictName = signal<string>(localStorage.getItem(STORAGE_KEYS.SCHOOL_DISTRICT_NAME) ?? '');
/** The currently authenticated user, parsed from the JWT. Null when logged out. */
readonly user = computed<AuthUser | null>(() => {
@@ -198,6 +200,8 @@ export class Auth {
// Store program context for header display
this._programName.set(data.programName);
this._schoolDistrictName.set(data.schoolDistrictName);
localStorage.setItem(STORAGE_KEYS.PROGRAM_NAME, data.programName);
localStorage.setItem(STORAGE_KEYS.SCHOOL_DISTRICT_NAME, data.schoolDistrictName);
// Clear phase-1 artefacts
localStorage.removeItem(STORAGE_KEYS.SESSION_TOKEN);
@@ -243,6 +247,8 @@ export class Auth {
this._isRefreshing.set(false);
this._programName.set('');
this._schoolDistrictName.set('');
localStorage.removeItem(STORAGE_KEYS.PROGRAM_NAME);
localStorage.removeItem(STORAGE_KEYS.SCHOOL_DISTRICT_NAME);
}
private loadSessionToken(): string | null {
@@ -19,41 +19,41 @@ export class DummyStudentService {
'1': {
studentIdentifier: 'J.B',
goals: [
{ goalId: 'g1', goalParentId: null, description: 'Work on main-idea identification and inference skills across fiction and nonfiction texts.', category: 'Academics', baseline: '', progressEventCount: 5, benchmarkCount: 2 },
{ goalId: 'g2', goalParentId: null, description: 'Finish all units in the algebra course including linear equations and graphing.', category: 'Academics', baseline: '', progressEventCount: 2, benchmarkCount: 0 },
{ goalId: 'g3', goalParentId: null, description: 'Write a reflective journal entry each week to build writing fluency.', category: 'Communication', baseline: '', progressEventCount: 8, benchmarkCount: 1 },
{ goalId: 'g1', goalParentId: null, description: 'Work on main-idea identification and inference skills across fiction and nonfiction texts.', category: 'Academics', baseline: '', targetCompletionDate: null, closeDate: null, achieved: null, closeNotes: null, progressEventCount: 5, benchmarkCount: 2 },
{ goalId: 'g2', goalParentId: null, description: 'Finish all units in the algebra course including linear equations and graphing.', category: 'Academics', baseline: '', targetCompletionDate: null, closeDate: null, achieved: null, closeNotes: null, progressEventCount: 2, benchmarkCount: 0 },
{ goalId: 'g3', goalParentId: null, description: 'Write a reflective journal entry each week to build writing fluency.', category: 'Communication', baseline: '', targetCompletionDate: null, closeDate: null, achieved: null, closeNotes: null, progressEventCount: 8, benchmarkCount: 1 },
],
},
'2': {
studentIdentifier: 'M.K',
goals: [
{ goalId: 'g4', goalParentId: null, description: 'Prepare for and pass the industry certification exam by end of quarter.', category: 'Career Readiness', baseline: '', progressEventCount: 3, benchmarkCount: 0 },
{ goalId: 'g5', goalParentId: null, description: 'Maintain consistent attendance throughout the term.', category: 'Behavior', baseline: '', progressEventCount: 0, benchmarkCount: 0 },
{ goalId: 'g6', goalParentId: null, description: 'Log the required 40 hours at the assigned internship site.', category: 'Career Readiness', baseline: '', progressEventCount: 12, benchmarkCount: 0 },
{ goalId: 'g7', goalParentId: null, description: 'Build a personal portfolio showcasing completed coursework and projects.', category: 'Career Readiness', baseline: '', progressEventCount: 1, benchmarkCount: 0 },
{ goalId: 'g4', goalParentId: null, description: 'Prepare for and pass the industry certification exam by end of quarter.', category: 'Career Readiness', baseline: '', targetCompletionDate: null, closeDate: null, achieved: null, closeNotes: null, progressEventCount: 3, benchmarkCount: 0 },
{ goalId: 'g5', goalParentId: null, description: 'Maintain consistent attendance throughout the term.', category: 'Behavior', baseline: '', targetCompletionDate: null, closeDate: null, achieved: null, closeNotes: null, progressEventCount: 0, benchmarkCount: 0 },
{ goalId: 'g6', goalParentId: null, description: 'Log the required 40 hours at the assigned internship site.', category: 'Career Readiness', baseline: '', targetCompletionDate: null, closeDate: null, achieved: null, closeNotes: null, progressEventCount: 12, benchmarkCount: 0 },
{ goalId: 'g7', goalParentId: null, description: 'Build a personal portfolio showcasing completed coursework and projects.', category: 'Career Readiness', baseline: '', targetCompletionDate: null, closeDate: null, achieved: null, closeNotes: null, progressEventCount: 1, benchmarkCount: 0 },
],
},
'3': {
studentIdentifier: 'A.R',
goals: [
{ goalId: 'g8', goalParentId: null, description: 'Complete practice tests and study modules for GED math and reading sections.', category: 'Academics', baseline: '', progressEventCount: 6, benchmarkCount: 0 },
{ goalId: 'g9', goalParentId: null, description: 'Attend the resume writing workshop and produce a final draft.', category: 'Career Readiness', baseline: '', progressEventCount: 0, benchmarkCount: 0 },
{ goalId: 'g8', goalParentId: null, description: 'Complete practice tests and study modules for GED math and reading sections.', category: 'Academics', baseline: '', targetCompletionDate: null, closeDate: null, achieved: null, closeNotes: null, progressEventCount: 6, benchmarkCount: 0 },
{ goalId: 'g9', goalParentId: null, description: 'Attend the resume writing workshop and produce a final draft.', category: 'Career Readiness', baseline: '', targetCompletionDate: null, closeDate: null, achieved: null, closeNotes: null, progressEventCount: 0, benchmarkCount: 0 },
],
},
'4': {
studentIdentifier: 'T.W',
goals: [
{ goalId: 'g10', goalParentId: null, description: 'Present in front of the class at least once per month.', category: 'Communication', baseline: '', progressEventCount: 4, benchmarkCount: 0 },
{ goalId: 'g11', goalParentId: null, description: 'Move up one placement level in math by the end of the semester.', category: 'Academics', baseline: '', progressEventCount: 7, benchmarkCount: 0 },
{ goalId: 'g12', goalParentId: null, description: 'Learn and apply at least three de-escalation techniques.', category: 'Behavior', baseline: '', progressEventCount: 2, benchmarkCount: 0 },
{ goalId: 'g13', goalParentId: null, description: 'Achieve a 30-day unbroken attendance streak.', category: 'Behavior', baseline: '', progressEventCount: 0, benchmarkCount: 0 },
{ goalId: 'g14', goalParentId: null, description: 'Complete a job shadow day in a field of interest.', category: 'Career Readiness', baseline: '', progressEventCount: 1, benchmarkCount: 0 },
{ goalId: 'g10', goalParentId: null, description: 'Present in front of the class at least once per month.', category: 'Communication', baseline: '', targetCompletionDate: null, closeDate: null, achieved: null, closeNotes: null, progressEventCount: 4, benchmarkCount: 0 },
{ goalId: 'g11', goalParentId: null, description: 'Move up one placement level in math by the end of the semester.', category: 'Academics', baseline: '', targetCompletionDate: null, closeDate: null, achieved: null, closeNotes: null, progressEventCount: 7, benchmarkCount: 0 },
{ goalId: 'g12', goalParentId: null, description: 'Learn and apply at least three de-escalation techniques.', category: 'Behavior', baseline: '', targetCompletionDate: null, closeDate: null, achieved: null, closeNotes: null, progressEventCount: 2, benchmarkCount: 0 },
{ goalId: 'g13', goalParentId: null, description: 'Achieve a 30-day unbroken attendance streak.', category: 'Behavior', baseline: '', targetCompletionDate: null, closeDate: null, achieved: null, closeNotes: null, progressEventCount: 0, benchmarkCount: 0 },
{ goalId: 'g14', goalParentId: null, description: 'Complete a job shadow day in a field of interest.', category: 'Career Readiness', baseline: '', targetCompletionDate: null, closeDate: null, achieved: null, closeNotes: null, progressEventCount: 1, benchmarkCount: 0 },
],
},
'5': {
studentIdentifier: 'L.C',
goals: [
{ goalId: 'g15', goalParentId: null, description: 'Reach 40 WPM with 95% accuracy on typing assessments.', category: 'Career Readiness', baseline: '', progressEventCount: 3, benchmarkCount: 0 },
{ goalId: 'g15', goalParentId: null, description: 'Reach 40 WPM with 95% accuracy on typing assessments.', category: 'Career Readiness', baseline: '', targetCompletionDate: null, closeDate: null, achieved: null, closeNotes: null, progressEventCount: 3, benchmarkCount: 0 },
],
},
};
@@ -75,6 +75,7 @@ export class DummyStudentService {
lastEntryDate: new Date('2026-02-21'),
goalCount: 3,
progressEventCount: 5,
benchmarkCount: 3,
},
{
studentId: '2',
@@ -83,6 +84,7 @@ export class DummyStudentService {
lastEntryDate: new Date('2026-02-25'),
goalCount: 4,
progressEventCount: 8,
benchmarkCount: 0,
},
{
studentId: '3',
@@ -91,6 +93,7 @@ export class DummyStudentService {
lastEntryDate: null,
goalCount: 2,
progressEventCount: 0,
benchmarkCount: 0,
},
];
@@ -119,6 +122,10 @@ export class DummyStudentService {
description: data.description,
category: data.category,
baseline: data.baseline,
targetCompletionDate: data.targetCompletionDate,
closeDate: null,
achieved: null,
closeNotes: null,
progressEventCount: 0,
benchmarkCount: 0,
};
@@ -114,10 +114,30 @@ export class StudentService {
}
}
async addProgressEvent(studentId: string, goalId: string, content: string): Promise<ApiResult> {
// *****************************************************************
// Creates a new progress event, optionally with benchmark
// associations. Returns the new progress event ID on success.
// *****************************************************************
async addProgressEvent(studentId: string, goalId: string, content: string, benchmarkIds?: string[]): Promise<ApiResult<any>> {
try {
const result = await firstValueFrom(
this.http.post<ResponseResult<void>>(`${this.base}/api/Student/${studentId}/progress-event`, { goalId, content })
this.http.post<ResponseResult<any>>(`${this.base}/api/Student/${studentId}/progress-event`, { goalId, content, benchmarkIds })
);
return result.success
? ApiResult.ok(result.data)
: ApiResult.fail(result.message);
} catch (error) {
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
}
}
// *****************************************************************
// Updates a progress event's content and benchmark associations.
// *****************************************************************
async updateProgressEvent(studentId: string, progressEventId: string, content: string, benchmarkIds?: string[]): Promise<ApiResult> {
try {
const result = await firstValueFrom(
this.http.put<ResponseResult<void>>(`${this.base}/api/Student/${studentId}/progress-events/${progressEventId}`, { content, benchmarkIds })
);
return result.success
? ApiResult.empty()
@@ -127,6 +147,22 @@ export class StudentService {
}
}
// *****************************************************************
// Returns benchmark IDs associated with a progress event.
// *****************************************************************
async getProgressEventBenchmarks(progressEventId: string): Promise<ApiResult<string[]>> {
try {
const result = await firstValueFrom(
this.http.get<ResponseResult<string[]>>(`${this.base}/api/Student/progress-events/${progressEventId}/benchmarks`)
);
return result.success
? ApiResult.ok(result.data ?? [])
: ApiResult.fail(result.message);
} catch (error) {
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
}
}
// *****************************************************************
// Returns progress events for a given student goal.
// *****************************************************************
@@ -230,7 +266,15 @@ export class StudentService {
// *****************************************************************
// Updates a goal's description, category, and baseline.
// *****************************************************************
async updateGoal(studentId: string, goalId: string, data: { description?: string; category?: string; baseline?: string }): Promise<ApiResult<any>> {
async updateGoal(studentId: string, goalId: string, data: {
description?: string;
category?: string;
baseline?: string;
targetCompletionDate?: string | null;
closeDate?: string | null;
achieved?: boolean | null;
closeNotes?: string | null;
}): Promise<ApiResult<any>> {
try {
const result = await firstValueFrom(
this.http.put<ResponseResult<any>>(`${this.base}/api/Student/${studentId}/goals/${goalId}`, data)
+4
View File
@@ -7,3 +7,7 @@ body {
height: 100%;
overflow: hidden;
}
input[type="date"] {
font-family: inherit;
}