Updates to encompass benchmarks

This commit is contained in:
ivan-pelly
2026-03-07 16:10:55 -08:00
parent 69e96403f4
commit 3d531298e2
65 changed files with 2505 additions and 86 deletions
+148
View File
@@ -130,6 +130,39 @@ public class StudentController : BaseController
}); });
} }
[HttpGet("{idStudent:guid}/benchmarks")]
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")]
[ProducesResponseType(typeof(ResponseResult<StudentBenchmarkSummary>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResponseResult<StudentBenchmarkSummary>), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ResponseResult<StudentBenchmarkSummary>>> GetBenchmarks(Guid idStudent)
{
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<StudentBenchmarkSummary>
{
Success = false,
Message = "Student not found."
});
}
var summary = await _studentRepository.GetBenchmarkSummaryAsync(idStudent);
return Ok(new ResponseResult<StudentBenchmarkSummary>
{
Success = true,
Message = "Benchmarks retrieved successfully.",
Data = summary
});
}
[HttpPost("{idStudent:guid}/goals")] [HttpPost("{idStudent:guid}/goals")]
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.ProgramAdmin}")] [Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.ProgramAdmin}")]
[ProducesResponseType(typeof(ResponseResult<StudentGoalItem>), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ResponseResult<StudentGoalItem>), StatusCodes.Status201Created)]
@@ -205,6 +238,38 @@ public class StudentController : BaseController
}); });
} }
[HttpPut("{idStudent:guid}/goals/{idGoal:guid}")]
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.ProgramAdmin}")]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ResponseResult<object>>> UpdateGoal(Guid idStudent, Guid idGoal, [FromBody] UpdateGoalDto 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<object>
{
Success = false,
Message = "Student not found."
});
}
var updated = await _studentRepository.UpdateGoalAsync(idGoal, dto);
return Ok(new ResponseResult<object>
{
Success = true,
Message = updated ? "Goal updated successfully." : "No changes were applied."
});
}
[HttpPost("{idStudent:guid}/progress-event")] [HttpPost("{idStudent:guid}/progress-event")]
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")] [Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")]
[ProducesResponseType(typeof(ResponseResult), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status201Created)]
@@ -411,4 +476,87 @@ public class StudentController : BaseController
Message = "Student deleted." Message = "Student deleted."
}); });
} }
[HttpPost("{idStudent:guid}/benchmarks")]
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.ProgramAdmin}")]
[ProducesResponseType(typeof(ResponseResult<StudentBenchmarkItem>), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ResponseResult<StudentBenchmarkItem>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ResponseResult<StudentBenchmarkItem>), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<ResponseResult<StudentBenchmarkItem>>> CreateBenchmark(Guid idStudent, [FromBody] CreateBenchmarkDto 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<StudentBenchmarkItem>
{
Success = false,
Message = "Student not found."
});
}
if (!PermissionService.IsAllowed(role, EntityType.Benchmark, PermissionAction.Create, isMine: true))
{
return BadRequest(new ResponseResult<StudentBenchmarkItem>
{
Success = false,
Message = "Unable to create benchmark. - Permission Matrix"
});
}
var created = await _studentRepository.InsertBenchmarkAsync(dto.GoalId, userId, dto);
if (created is null)
{
return BadRequest(new ResponseResult<StudentBenchmarkItem>
{
Success = false,
Message = "Unable to create benchmark."
});
}
return StatusCode(StatusCodes.Status201Created, new ResponseResult<StudentBenchmarkItem>
{
Success = true,
Message = "Benchmark created successfully.",
Data = created
});
}
[HttpPut("{idStudent:guid}/benchmarks/{idBenchmark:guid}")]
[Authorize(Roles = $"{UserRoles.Teacher}")]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ResponseResult<object>>> UpdateBenchmark(Guid idStudent, Guid idBenchmark, [FromBody] UpdateBenchmarkDto 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<object>
{
Success = false,
Message = "Student not found."
});
}
var updated = await _studentRepository.UpdateBenchmarkAsync(idBenchmark, dto.Benchmark);
return Ok(new ResponseResult<object>
{
Success = true,
Message = updated ? "Changes applied successfully." : "No changes were applied."
});
}
} }
@@ -0,0 +1,7 @@
namespace WinStudentGoalTracker.DataAccess;
public class CreateBenchmarkDto
{
public Guid GoalId { get; set; }
public string Benchmark { get; set; } = string.Empty;
}
@@ -0,0 +1,6 @@
namespace WinStudentGoalTracker.DataAccess;
public class UpdateBenchmarkDto
{
public string Benchmark { get; set; } = string.Empty;
}
@@ -0,0 +1,8 @@
namespace WinStudentGoalTracker.DataAccess;
public class UpdateGoalDto
{
public string? Title { get; set; }
public string? Description { get; set; }
public string? Category { get; set; }
}
@@ -0,0 +1,13 @@
namespace WinStudentGoalTracker.DataAccess;
public class dbStudentBenchmarkRow
{
public string? StudentIdentifier { get; set; }
public required Guid BenchmarkId { get; set; }
public required Guid GoalId { get; set; }
public string? GoalTitle { get; set; }
public string? Benchmark { get; set; }
public string? CreatedByName { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
@@ -9,4 +9,5 @@ public class dbStudentGoalRow
public string? Description { get; set; } public string? Description { get; set; }
public string? Category { get; set; } public string? Category { get; set; }
public int ProgressEventCount { get; set; } public int ProgressEventCount { get; set; }
public int BenchmarkCount { get; set; }
} }
@@ -194,9 +194,119 @@ public class StudentRepository
Title = r.Title, Title = r.Title,
Description = r.Description, Description = r.Description,
Category = r.Category, Category = r.Category,
ProgressEventCount = r.ProgressEventCount ProgressEventCount = r.ProgressEventCount,
BenchmarkCount = r.BenchmarkCount
}).ToList() }).ToList()
}; };
} }
// *****************************************************************
// Updates a goal's title, description, and category.
// *****************************************************************
public async Task<bool> UpdateGoalAsync(Guid goalId, UpdateGoalDto dto)
{
using var db = Connection;
var rowsAffected = await db.ExecuteScalarAsync<int>(
"sp_Goal_Update",
new
{
p_id_goal = goalId.ToString(),
p_id_goal_parent = (string?)null,
p_id_student = (string?)null,
p_id_user_created = (string?)null,
p_title = dto.Title,
p_description = dto.Description,
p_category = dto.Category
},
commandType: CommandType.StoredProcedure);
return rowsAffected > 0;
}
// *****************************************************************
// Returns all benchmarks for a student, grouped under a summary
// with the student identifier. Returns null if student not found.
// *****************************************************************
public async Task<StudentBenchmarkSummary?> GetBenchmarkSummaryAsync(Guid idStudent)
{
using var db = Connection;
var rows = await db.QueryAsync<dbStudentBenchmarkRow>(
"sp_Benchmark_GetByStudentId",
new { p_id_student = idStudent.ToString() },
commandType: CommandType.StoredProcedure);
var list = rows.ToList();
if (list.Count == 0)
{
var student = await GetByIdAsync(idStudent);
if (student is null) return null;
return new StudentBenchmarkSummary
{
StudentIdentifier = student.Identifier,
Benchmarks = []
};
}
return new StudentBenchmarkSummary
{
StudentIdentifier = list[0].StudentIdentifier,
Benchmarks = list.Select(r => new StudentBenchmarkItem
{
BenchmarkId = r.BenchmarkId,
GoalId = r.GoalId,
GoalTitle = r.GoalTitle,
Benchmark = r.Benchmark,
CreatedByName = r.CreatedByName,
CreatedAt = r.CreatedAt,
UpdatedAt = r.UpdatedAt
}).ToList()
};
}
// *****************************************************************
// Inserts a new benchmark and returns the created benchmark item.
// *****************************************************************
public async Task<StudentBenchmarkItem?> InsertBenchmarkAsync(Guid goalId, Guid userId, CreateBenchmarkDto dto)
{
var newId = Guid.NewGuid();
using var db = Connection;
var row = await db.QuerySingleOrDefaultAsync(
"sp_Benchmark_Insert",
new
{
p_id_benchmark = newId.ToString(),
p_id_goal = goalId.ToString(),
p_id_user_created = userId.ToString(),
p_benchmark = dto.Benchmark
},
commandType: CommandType.StoredProcedure);
if (row is null) return null;
return new StudentBenchmarkItem
{
BenchmarkId = newId,
GoalId = goalId,
Benchmark = dto.Benchmark,
CreatedAt = DateTime.UtcNow
};
}
// *****************************************************************
// Updates a benchmark's text and returns whether rows were affected.
// *****************************************************************
public async Task<bool> UpdateBenchmarkAsync(Guid benchmarkId, string benchmarkText)
{
using var db = Connection;
var rowsAffected = await db.ExecuteScalarAsync<int>(
"sp_Benchmark_Update",
new
{
p_id_benchmark = benchmarkId.ToString(),
p_benchmark = benchmarkText
},
commandType: CommandType.StoredProcedure);
return rowsAffected > 0;
}
} }
@@ -0,0 +1,12 @@
namespace WinStudentGoalTracker.Models;
public class StudentBenchmarkItem
{
public Guid BenchmarkId { get; set; }
public Guid GoalId { get; set; }
public string? GoalTitle { get; set; }
public string? Benchmark { get; set; }
public string? CreatedByName { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
@@ -0,0 +1,7 @@
namespace WinStudentGoalTracker.Models;
public class StudentBenchmarkSummary
{
public string? StudentIdentifier { get; set; }
public List<StudentBenchmarkItem> Benchmarks { get; set; } = [];
}
@@ -8,4 +8,5 @@ public class StudentGoalItem
public string? Description { get; set; } public string? Description { get; set; }
public string? Category { get; set; } public string? Category { get; set; }
public int ProgressEventCount { get; set; } public int ProgressEventCount { get; set; }
public int BenchmarkCount { get; set; }
} }
+2 -1
View File
@@ -8,10 +8,11 @@ public static class EntityType
public const string Student = "student"; public const string Student = "student";
public const string Goal = "goal"; public const string Goal = "goal";
public const string ProgressEvent = "progress_event"; public const string ProgressEvent = "progress_event";
public const string Benchmark = "benchmark";
public static string? TryParse(string value) => public static string? TryParse(string value) =>
All.Contains(value) ? value : null; All.Contains(value) ? value : null;
public static readonly IReadOnlyList<string> All = public static readonly IReadOnlyList<string> All =
[SchoolDistrict, Program, User, Student, Goal, ProgressEvent]; [SchoolDistrict, Program, User, Student, Goal, ProgressEvent, Benchmark];
} }
+35
View File
@@ -60,6 +60,13 @@ public static class PermissionMatrix
[PermissionAction.Update] = Allow, [PermissionAction.Update] = Allow,
[PermissionAction.Delete] = Allow, [PermissionAction.Delete] = Allow,
}, },
[EntityType.Benchmark] = new()
{
[PermissionAction.Create] = Allow,
[PermissionAction.Read] = Allow,
[PermissionAction.Update] = Allow,
[PermissionAction.Delete] = Allow,
},
}, },
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
@@ -109,6 +116,13 @@ public static class PermissionMatrix
[PermissionAction.Update] = Allow, [PermissionAction.Update] = Allow,
[PermissionAction.Delete] = Allow, [PermissionAction.Delete] = Allow,
}, },
[EntityType.Benchmark] = new()
{
[PermissionAction.Create] = Allow,
[PermissionAction.Read] = Allow,
[PermissionAction.Update] = Allow,
[PermissionAction.Delete] = Allow,
},
}, },
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
@@ -158,6 +172,13 @@ public static class PermissionMatrix
[PermissionAction.Update] = Allow, [PermissionAction.Update] = Allow,
[PermissionAction.Delete] = MineOnly, [PermissionAction.Delete] = MineOnly,
}, },
[EntityType.Benchmark] = new()
{
[PermissionAction.Create] = Allow,
[PermissionAction.Read] = Allow,
[PermissionAction.Update] = Allow,
[PermissionAction.Delete] = MineOnly,
},
}, },
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
@@ -207,6 +228,13 @@ public static class PermissionMatrix
[PermissionAction.Update] = MineOnly, [PermissionAction.Update] = MineOnly,
[PermissionAction.Delete] = MineOnly, [PermissionAction.Delete] = MineOnly,
}, },
[EntityType.Benchmark] = new()
{
[PermissionAction.Create] = MineOnly,
[PermissionAction.Read] = MineOnly,
[PermissionAction.Update] = MineOnly,
[PermissionAction.Delete] = MineOnly,
},
}, },
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
@@ -256,6 +284,13 @@ public static class PermissionMatrix
[PermissionAction.Update] = MineOnly, [PermissionAction.Update] = MineOnly,
[PermissionAction.Delete] = Deny, [PermissionAction.Delete] = Deny,
}, },
[EntityType.Benchmark] = new()
{
[PermissionAction.Create] = Deny,
[PermissionAction.Read] = MineOnly,
[PermissionAction.Update] = Deny,
[PermissionAction.Delete] = Deny,
},
}, },
}; };
@@ -0,0 +1,20 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Benchmark_GetByStudentId`(IN p_id_student CHAR(36))
BEGIN
SELECT
s.`identifier` AS `studentIdentifier`,
b.`id_benchmark` AS `benchmarkId`,
b.`id_goal` AS `goalId`,
g.`title` AS `goalTitle`,
b.`benchmark` AS `benchmark`,
u.`name` AS `createdByName`,
b.`created_at` AS `createdAt`,
b.`updated_at` AS `updatedAt`
FROM `benchmark` b
INNER JOIN `goal` g ON g.`id_goal` = b.`id_goal`
INNER JOIN `student` s ON s.`id_student` = g.`id_student`
LEFT JOIN `user` u ON u.`id_user` = b.`id_user_created`
WHERE g.`id_student` = p_id_student
ORDER BY b.`created_at` DESC;
END;;
DELIMITER ;
@@ -0,0 +1,38 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Benchmark_Insert`(
IN p_id_benchmark CHAR(36),
IN p_id_goal CHAR(36),
IN p_id_user_created CHAR(36),
IN p_benchmark TEXT
)
BEGIN
INSERT INTO benchmark
(
id_benchmark,
id_goal,
id_user_created,
benchmark,
created_at,
updated_at
)
VALUES
(
p_id_benchmark,
p_id_goal,
p_id_user_created,
p_benchmark,
UTC_TIMESTAMP(),
NULL
);
SELECT
id_benchmark,
id_goal,
id_user_created,
benchmark,
created_at,
updated_at
FROM benchmark
WHERE id_benchmark = p_id_benchmark
LIMIT 1;
END;;
DELIMITER ;
@@ -0,0 +1,14 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Benchmark_Update`(
IN p_id_benchmark CHAR(36),
IN p_benchmark TEXT
)
BEGIN
UPDATE benchmark
SET
benchmark = p_benchmark,
updated_at = UTC_TIMESTAMP()
WHERE id_benchmark = p_id_benchmark;
SELECT ROW_COUNT() AS rowsAffected;
END;;
DELIMITER ;
@@ -8,7 +8,8 @@ BEGIN
vc.`title`, vc.`title`,
vc.`description`, vc.`description`,
vc.`category`, vc.`category`,
vc.`progressEventCount` vc.`progressEventCount`,
vc.`benchmarkCount`
FROM `v_goal_card` vc FROM `v_goal_card` vc
INNER JOIN `student` s ON s.`id_student` = vc.`studentId` INNER JOIN `student` s ON s.`id_student` = vc.`studentId`
WHERE vc.`studentId` = p_id_student WHERE vc.`studentId` = p_id_student
+9
View File
@@ -0,0 +1,9 @@
CREATE TABLE `benchmark` (
`id_benchmark` char(36) NOT NULL DEFAULT (uuid()),
`id_goal` char(36) NOT NULL,
`id_user_created` char(36) NOT NULL,
`benchmark` text NOT NULL,
`created_at` datetime NOT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id_benchmark`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -0,0 +1,7 @@
CREATE TABLE `progress_event_benchmark` (
`id_progress_event_benchmark` char(36) NOT NULL DEFAULT (uuid()),
`id_progress_event` char(36) NOT NULL,
`id_benchmark` char(36) NOT NULL,
`created_at` datetime NOT NULL,
PRIMARY KEY (`id_progress_event_benchmark`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+5 -3
View File
@@ -1,5 +1,7 @@
CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`%` SQL SECURITY DEFINER VIEW `winstudentgoaltracker`.`v_goal_card` AS CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`%` SQL SECURITY DEFINER VIEW `winstudentgoaltracker`.`v_goal_card` AS
select `winstudentgoaltracker`.`goal`.`id_goal` AS `goalId`,`winstudentgoaltracker`.`goal`.`id_goal_parent` AS `goalParentId`,`winstudentgoaltracker`.`goal`.`id_student` AS `studentId`,`winstudentgoaltracker`.`goal`.`title` AS `title`,`winstudentgoaltracker`.`goal`.`description` AS `description`,`winstudentgoaltracker`.`goal`.`category` AS `category`,count(`pe`.`id_progress_event`) AS `progressEventCount` select `g`.`id_goal` AS `goalId`,`g`.`id_goal_parent` AS `goalParentId`,`g`.`id_student` AS `studentId`,`g`.`title` AS `title`,`g`.`description` AS `description`,`g`.`category` AS `category`,count(distinct `pe`.`id_progress_event`) AS `progressEventCount`,count(distinct `b`.`id_benchmark`) AS `benchmarkCount`
from (`winstudentgoaltracker`.`goal` from ((`winstudentgoaltracker`.`goal` `g`
left left
join `winstudentgoaltracker`.`progress_event` `pe` on((`pe`.`id_goal` = `winstudentgoaltracker`.`goal`.`id_goal`))) group by `winstudentgoaltracker`.`goal`.`id_goal`,`winstudentgoaltracker`.`goal`.`id_goal_parent`,`winstudentgoaltracker`.`goal`.`id_student`,`winstudentgoaltracker`.`goal`.`title`,`winstudentgoaltracker`.`goal`.`description`,`winstudentgoaltracker`.`goal`.`category`; 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`.`title`,`g`.`description`,`g`.`category`;
+2 -1
View File
@@ -2,4 +2,5 @@ CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`%` SQL SECURITY DEFINER VIEW `winstud
select `pe`.`id_progress_event` AS `progressEventId`,`pe`.`id_goal` AS `goalId`,`g`.`id_student` AS `studentId`,`pe`.`content` AS `content`,`pe`.`created_at` AS `createdAt`,`u`.`name` AS `createdByName` select `pe`.`id_progress_event` AS `progressEventId`,`pe`.`id_goal` AS `goalId`,`g`.`id_student` AS `studentId`,`pe`.`content` AS `content`,`pe`.`created_at` AS `createdAt`,`u`.`name` AS `createdByName`
from ((`winstudentgoaltracker`.`progress_event` `pe` from ((`winstudentgoaltracker`.`progress_event` `pe`
join `winstudentgoaltracker`.`goal` `g` on((`g`.`id_goal` = `pe`.`id_goal`))) join `winstudentgoaltracker`.`goal` `g` on((`g`.`id_goal` = `pe`.`id_goal`)))
left join `winstudentgoaltracker`.`user` `u` on((`u`.`id_user` = `pe`.`id_user_created`))); left
join `winstudentgoaltracker`.`user` `u` on((`u`.`id_user` = `pe`.`id_user_created`)));
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

@@ -0,0 +1,44 @@
<div class="toolbar">
<button class="toolbar-btn back-btn" (click)="onBack()">&#8593; Benchmarks</button>
<span class="toolbar-title">{{ isNew() ? 'New Benchmark' : 'Benchmark Detail' }}</span>
<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="field">
<span class="field-label">Goal</span>
<span class="field-value">{{ goalTitle }}</span>
</div>
<div class="field">
<label class="field-label" for="benchmarkText">Benchmark</label>
<textarea id="benchmarkText" class="field-input field-textarea" [(ngModel)]="benchmarkText" rows="4"
placeholder="Enter benchmark text..."></textarea>
</div>
@if (!isNew()) {
<div class="metadata">
@if (createdByName) {
<span class="meta-item">Created by: {{ createdByName }}</span>
}
<span class="meta-item">Created: {{ createdAt | date:'medium' }}</span>
@if (updatedAt) {
<span class="meta-item">Updated: {{ updatedAt | 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>
}
@@ -0,0 +1,146 @@
: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-value {
font-size: 0.9375rem;
color: #333;
}
.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;
}
.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;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BenchmarkCardFull } from './benchmark-card-full';
describe('BenchmarkCardFull', () => {
let component: BenchmarkCardFull;
let fixture: ComponentFixture<BenchmarkCardFull>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BenchmarkCardFull]
})
.compileComponents();
fixture = TestBed.createComponent(BenchmarkCardFull);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,169 @@
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';
@Component({
selector: 'app-benchmark-card-full',
imports: [FormsModule, DatePipe],
templateUrl: './benchmark-card-full.html',
styleUrl: './benchmark-card-full.scss',
})
export class BenchmarkCardFull implements OnDestroy {
// ************************** Constructor **************************
constructor() {
this.paramSub = this.route.paramMap.subscribe(params => {
this.studentId = params.get('studentId')!;
this.goalId = params.get('goalId')!;
this.benchmarkId = params.get('benchmarkId') ?? null;
this.loadBenchmark();
});
}
// ************************** 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 benchmarkId: 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 field
protected benchmarkText = '';
private savedBenchmarkText = '';
// Read-only metadata
protected goalTitle = '';
protected createdByName = '';
protected createdAt: Date | null = null;
protected updatedAt: Date | null = null;
// ************************** Properties ***************************
// *****************************************************************
// Returns true if the benchmark text has unsaved changes.
// *****************************************************************
hasChanges(): boolean {
return this.benchmarkText !== this.savedBenchmarkText;
}
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
// *****************************************************************
// Saves changes or creates a new benchmark.
// *****************************************************************
async onSave() {
this.saving.set(true);
this.errorMessage.set(null);
this.successMessage.set(null);
if (this.isNew()) {
const result = await this.studentService.createBenchmark(this.studentId, {
goalId: this.goalId,
benchmark: this.benchmarkText,
});
this.saving.set(false);
if (result.success) {
this.successMessage.set('Benchmark created.');
this.savedBenchmarkText = this.benchmarkText;
if (result.payload?.benchmarkId) {
this.router.navigate(['/students', this.studentId, 'goals', this.goalId, 'benchmarks', result.payload.benchmarkId]);
}
} else {
this.errorMessage.set(result.message);
}
} else {
const result = await this.studentService.updateBenchmark(this.studentId, this.benchmarkId!, this.benchmarkText);
this.saving.set(false);
if (result.success) {
this.savedBenchmarkText = this.benchmarkText;
this.successMessage.set('Changes saved.');
} else {
this.errorMessage.set(result.message);
}
}
}
// *****************************************************************
// Reverts the benchmark text to the last-saved value.
// *****************************************************************
onCancel() {
this.benchmarkText = this.savedBenchmarkText;
this.errorMessage.set(null);
this.successMessage.set(null);
}
onBack() {
this.router.navigate(['/students', this.studentId, 'benchmarks']);
}
ngOnDestroy() {
this.paramSub.unsubscribe();
}
// ********************** Support Procedures ***********************
// *****************************************************************
// Loads existing benchmark data or sets up new-benchmark state.
// *****************************************************************
private loadBenchmark() {
if (!this.benchmarkId) {
this.isNew.set(true);
this.benchmarkText = '';
this.savedBenchmarkText = '';
this.loadGoalTitle();
this.loaded.set(true);
return;
}
this.isNew.set(false);
this.studentService.getBenchmarksForStudent(this.studentId).then(result => {
if (!result.success || !result.payload) {
this.errorMessage.set(result.message);
return;
}
const bm = result.payload.benchmarks.find(b => b.benchmarkId === this.benchmarkId);
if (!bm) {
this.errorMessage.set('Benchmark not found.');
return;
}
this.benchmarkText = bm.benchmark;
this.savedBenchmarkText = bm.benchmark;
this.goalTitle = bm.goalTitle;
this.createdByName = bm.createdByName;
this.createdAt = bm.createdAt;
this.updatedAt = bm.updatedAt;
this.loaded.set(true);
});
}
// *****************************************************************
// Loads the goal title for a new benchmark.
// *****************************************************************
private loadGoalTitle() {
this.studentService.getGoalsForStudent(this.studentId).then(result => {
if (result.success && result.payload) {
const goal = result.payload.goals.find(g => g.goalId === this.goalId);
this.goalTitle = goal?.title ?? '';
}
});
}
}
@@ -0,0 +1,12 @@
<div class="card">
<div class="card-header">
<span class="goal-badge">{{ benchmark().goalTitle }}</span>
@if (benchmark().updatedAt) {
<span class="date">Updated: {{ benchmark().updatedAt | date:'M/d/yy' }}</span>
} @else {
<span class="date">{{ benchmark().createdAt | date:'M/d/yy' }}</span>
}
</div>
<p class="benchmark-text">{{ benchmark().benchmark }}</p>
</div>
@@ -0,0 +1,51 @@
:host {
display: block;
width: 300px;
}
.card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
min-width: 0;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.goal-badge {
padding: 0.2rem 0.6rem;
background: #f0fdf4;
color: #16a34a;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 60%;
}
.date {
font-size: 0.8125rem;
color: #888;
}
.benchmark-text {
margin: 0;
font-size: 0.875rem;
color: #333;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BenchmarkCard } from './benchmark-card';
describe('BenchmarkCard', () => {
let component: BenchmarkCard;
let fixture: ComponentFixture<BenchmarkCard>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BenchmarkCard]
})
.compileComponents();
fixture = TestBed.createComponent(BenchmarkCard);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,26 @@
import { Component, input } from '@angular/core';
import { DatePipe } from '@angular/common';
import { BenchmarkDto } from '../../../shared/classes/benchmark.dto';
@Component({
selector: 'app-benchmark-card',
imports: [DatePipe],
templateUrl: './benchmark-card.html',
styleUrl: './benchmark-card.scss',
})
export class BenchmarkCard {
// ************************** Constructor **************************
// ************************** Declarations *************************
readonly benchmark = input.required<BenchmarkDto>();
// ************************** Properties ***************************
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
// ********************** Support Procedures ***********************
}
@@ -0,0 +1,24 @@
<div class="toolbar">
<button class="toolbar-btn back-btn" (click)="onBack()">&#8593; Goal</button>
<span class="spacer"></span>
<button class="toolbar-btn" (click)="onAddBenchmark()">+ Add a Benchmark</button>
</div>
@if (studentIdentifier()) {
<h2 class="section-header">Benchmarks for {{ studentIdentifier() }}</h2>
}
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}
@if (benchmarks().length === 0 && !errorMessage()) {
<p class="empty-state">No benchmarks yet. Click <a class="empty-link" (click)="onAddBenchmark()">Add a Benchmark</a> to
get started.</p>
} @else {
<div class="card-grid">
@for (bm of benchmarks(); track bm.benchmarkId) {
<app-benchmark-card [benchmark]="bm" />
}
</div>
}
@@ -0,0 +1,75 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
}
.toolbar {
display: flex;
align-items: center;
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;
}
.back-btn {
margin-left: 0.5rem;
}
.section-header {
font-size: 1.125rem;
font-weight: 600;
color: #333;
margin: 0 0 0.5rem;
}
.spacer {
flex: 1;
}
.error {
font-size: 0.875rem;
color: #dc2626;
margin: 0 0 1rem;
}
.empty-state {
color: #888;
font-size: 0.9375rem;
margin: 2rem auto;
text-align: center;
}
.empty-link {
color: #4f46e5;
text-decoration: underline;
cursor: pointer;
}
.card-grid {
display: flex;
flex-wrap: wrap;
gap: 1rem;
overflow-y: auto;
flex: 1;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BenchmarkList } from './benchmark-list';
describe('BenchmarkList', () => {
let component: BenchmarkList;
let fixture: ComponentFixture<BenchmarkList>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BenchmarkList]
})
.compileComponents();
fixture = TestBed.createComponent(BenchmarkList);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,64 @@
import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { BenchmarkDto } from '../../../shared/classes/benchmark.dto';
import { StudentService } from '../../../shared/services/student.service';
import { BenchmarkCard } from '../benchmark-card/benchmark-card';
@Component({
selector: 'app-benchmark-list',
imports: [BenchmarkCard],
templateUrl: './benchmark-list.html',
styleUrl: './benchmark-list.scss',
})
export class BenchmarkList {
// ************************** Constructor **************************
constructor() {
this.studentId = this.route.snapshot.paramMap.get('studentId')!;
this.goalId = this.route.snapshot.paramMap.get('goalId') || '';
this.loadBenchmarks();
}
// ************************** Declarations *************************
private readonly studentService = inject(StudentService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
protected readonly studentId: string;
protected readonly goalId: string;
protected readonly studentIdentifier = signal<string | null>(null);
protected readonly benchmarks = signal<BenchmarkDto[]>([]);
protected readonly errorMessage = signal<string | null>(null);
// ************************** Properties ***************************
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
onAddBenchmark() {
this.router.navigate(['/students', this.studentId, 'goals', this.goalId, 'benchmarks', 'new']);
}
onBack() {
this.router.navigate(['/students', this.studentId, 'goals', this.goalId]);
}
// ********************** Support Procedures ***********************
// *****************************************************************
// Loads benchmarks for the student from the service.
// *****************************************************************
private loadBenchmarks() {
this.studentService.getBenchmarksForStudent(this.studentId).then(data => {
if (!data.success) {
this.errorMessage.set(data.message);
} else {
this.studentIdentifier.set(data.payload?.studentIdentifier ?? null);
this.benchmarks.set(data.payload?.benchmarks ?? []);
}
});
}
}
@@ -0,0 +1,42 @@
<div class="toolbar">
<button class="toolbar-btn back-btn" (click)="onBack()">&#8593; Student</button>
<span class="toolbar-title">Goal Detail</span>
<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="field">
<label class="field-label" for="title">Title</label>
<input id="title" class="field-input" type="text" [(ngModel)]="title" />
</div>
<div class="field">
<label class="field-label" for="description">Description</label>
<textarea id="description" class="field-input field-textarea" [(ngModel)]="description" rows="4"
placeholder="Enter description..."></textarea>
</div>
<div class="field">
<label class="field-label" for="category">Category</label>
<input id="category" class="field-input" type="text" [(ngModel)]="category" />
</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 class="card-footer">
<a class="detail-link" (click)="onProgressEvents()">Progress Events</a>
<a class="detail-link" (click)="onBenchmarks()">Benchmarks</a>
</div>
</div>
}
@@ -0,0 +1,149 @@
: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;
}
.card-footer {
display: flex;
gap: 1.5rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.detail-link {
font-size: 0.875rem;
color: #4f46e5;
cursor: pointer;
text-decoration: underline;
}
.detail-link:hover {
color: #4338ca;
}
.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;
}
.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;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GoalCardFull } from './goal-card-full';
describe('GoalCardFull', () => {
let component: GoalCardFull;
let fixture: ComponentFixture<GoalCardFull>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GoalCardFull]
})
.compileComponents();
fixture = TestBed.createComponent(GoalCardFull);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,154 @@
import { Component, inject, signal, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
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],
templateUrl: './goal-card-full.html',
styleUrl: './goal-card-full.scss',
})
export class GoalCardFull implements OnDestroy {
// ************************** Constructor **************************
constructor() {
this.paramSub = this.route.paramMap.subscribe(params => {
this.studentId = params.get('studentId')!;
this.goalId = params.get('goalId')!;
this.loadGoal();
});
}
// ************************** 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;
protected readonly loaded = signal(false);
protected readonly errorMessage = signal<string | null>(null);
protected readonly successMessage = signal<string | null>(null);
protected readonly saving = signal(false);
// Form fields
protected title = '';
protected description = '';
protected category = '';
// Read-only metadata
protected progressEventCount = 0;
protected benchmarkCount = 0;
// Snapshot
private savedTitle = '';
private savedDescription = '';
private savedCategory = '';
// ************************** Properties ***************************
// *****************************************************************
// Returns true if form values differ from the saved snapshot.
// *****************************************************************
hasChanges(): boolean {
return this.title !== this.savedTitle
|| this.description !== this.savedDescription
|| this.category !== this.savedCategory;
}
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
// *****************************************************************
// Saves changes to the goal via the API.
// *****************************************************************
async onSave() {
this.saving.set(true);
this.errorMessage.set(null);
this.successMessage.set(null);
const result = await this.studentService.updateGoal(this.studentId, this.goalId, {
title: this.title,
description: this.description,
category: this.category,
});
this.saving.set(false);
if (result.success) {
this.savedTitle = this.title;
this.savedDescription = this.description;
this.savedCategory = this.category;
this.successMessage.set('Changes saved.');
} else {
this.errorMessage.set(result.message);
}
}
// *****************************************************************
// Reverts form fields to the last-saved snapshot.
// *****************************************************************
onCancel() {
this.title = this.savedTitle;
this.description = this.savedDescription;
this.category = this.savedCategory;
this.errorMessage.set(null);
this.successMessage.set(null);
}
onBack() {
this.router.navigate(['/students', this.studentId]);
}
onProgressEvents() {
this.router.navigate(['/students', this.studentId, 'goals', this.goalId, 'progress']);
}
onBenchmarks() {
this.router.navigate(['/students', this.studentId, 'benchmarks']);
}
ngOnDestroy() {
this.paramSub.unsubscribe();
}
// ********************** Support Procedures ***********************
// *****************************************************************
// Loads the goal by finding it in the student's goal list.
// *****************************************************************
private loadGoal() {
this.loaded.set(false);
this.studentService.getGoalsForStudent(this.studentId).then(result => {
if (!result.success || !result.payload) {
this.errorMessage.set(result.message);
return;
}
const goal = result.payload.goals.find(g => g.goalId === this.goalId);
if (!goal) {
this.errorMessage.set('Goal not found.');
return;
}
this.title = goal.title;
this.description = goal.description;
this.category = goal.category;
this.progressEventCount = goal.progressEventCount;
this.benchmarkCount = goal.benchmarkCount;
this.savedTitle = goal.title;
this.savedDescription = goal.description;
this.savedCategory = goal.category;
this.loaded.set(true);
});
}
}
@@ -1,4 +1,4 @@
<div class="card clickable" (click)="onCardClick()"> <div class="card" (click)="onCardClick()">
<div class="card-header"> <div class="card-header">
<span class="category-badge">{{ goal().category }}</span> <span class="category-badge">{{ goal().category }}</span>
<span class="event-count">{{ goal().progressEventCount }} events</span> <span class="event-count">{{ goal().progressEventCount }} events</span>
@@ -6,4 +6,9 @@
<h3 class="title">{{ goal().title }}</h3> <h3 class="title">{{ goal().title }}</h3>
<p class="description">{{ goal().description }}</p> <p class="description">{{ goal().description }}</p>
<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>
</div>
</div> </div>
@@ -11,16 +11,29 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.625rem; gap: 0.625rem;
height: 130px; cursor: pointer;
min-width: 0; min-width: 0;
} }
.card.clickable { .card-footer {
cursor: pointer; 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;
} }
.card.clickable:hover { .footer-link {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); font-size: 0.875rem;
color: #4f46e5;
cursor: pointer;
text-decoration: underline;
}
.footer-link:hover {
color: #4338ca;
} }
.card-header { .card-header {
@@ -26,9 +26,25 @@ export class GoalCard {
// ************************ Event Handlers ************************* // ************************ Event Handlers *************************
// ***************************************************************** // *****************************************************************
// Navigates to the progress events page for this goal. // Navigates to the goal detail page.
// ***************************************************************** // *****************************************************************
onCardClick() { onCardClick() {
const studentId = this.route.snapshot.paramMap.get('studentId')!;
this.router.navigate(['/students', studentId, 'goals', this.goal().goalId]);
}
// *****************************************************************
// Navigates to the benchmarks page for this goal.
// *****************************************************************
onBenchmarksClick() {
const studentId = this.route.snapshot.paramMap.get('studentId')!;
this.router.navigate(['/students', studentId, 'goals', this.goal().goalId, 'benchmarks']);
}
// *****************************************************************
// Navigates to the progress events page for this goal.
// *****************************************************************
onProgressEventsClick() {
const studentId = this.route.snapshot.paramMap.get('studentId')!; const studentId = this.route.snapshot.paramMap.get('studentId')!;
this.router.navigate(['/students', studentId, 'goals', this.goal().goalId, 'progress']); this.router.navigate(['/students', studentId, 'goals', this.goal().goalId, 'progress']);
} }
@@ -1,12 +1,15 @@
<div class="toolbar"> <div class="toolbar">
<button class="toolbar-btn back-btn" (click)="onBack()">&#8592; Students</button> <button class="toolbar-btn back-btn" (click)="onBack()">&#8593; Student</button>
<span class="toolbar-title">Goals</span>
<span class="spacer"></span> <span class="spacer"></span>
<button class="generate-report"> ⭐ Generate progress report</button>
<button class="toolbar-btn" (click)="onAddGoal()">+ Add a Goal</button> <button class="toolbar-btn" (click)="onAddGoal()">+ Add a Goal</button>
</div> </div>
<!-- <img class="hero-image" src="/hurdlescropped.png" alt="Hurdles" /> -->
@if (studentIdentifier()) { @if (studentIdentifier()) {
<h2 class="section-header">Goals for {{ studentIdentifier() }}</h2> <h2 class="section-header">Student: {{ studentIdentifier() }}</h2>
} }
@if (showAddModal()) { @if (showAddModal()) {
@@ -27,3 +30,8 @@
} }
</div> </div>
} }
<footer class="goal-footer">
<span class="spacer"></span>
<button class="toolbar-btn"> ⭐ Generate progress report</button>
</footer>
@@ -4,27 +4,11 @@
height: 100%; height: 100%;
} }
.generate-report {
display: inline-flex;
align-items: center;
background-color: #4000ee;
color: white;
border: none;
padding: 8px 14px;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.generate-report:hover {
background-color: #4f46e5;
}
.toolbar { .toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
position: relative;
gap: 0.75rem; gap: 0.75rem;
height: 40px; height: 40px;
padding-right: 0.5rem; padding-right: 0.5rem;
@@ -55,6 +39,15 @@
margin-left: 0.5rem; margin-left: 0.5rem;
} }
.toolbar-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-weight: 600;
font-size: 1.25rem;
color: #333;
}
.section-header { .section-header {
font-size: 1.125rem; font-size: 1.125rem;
font-weight: 600; font-weight: 600;
@@ -86,3 +79,13 @@
overflow-y: auto; overflow-y: auto;
flex: 1; flex: 1;
} }
.goal-footer {
display: flex;
align-items: center;
padding: 0 1rem;
height: 48px;
background: #fff;
border-top: 1px solid #ddd;
flex-shrink: 0;
}
@@ -1,5 +1,6 @@
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { StudentGoalItem } from '../../../shared/classes/student-goal'; import { StudentGoalItem } from '../../../shared/classes/student-goal';
import { StudentService } from '../../../shared/services/student.service'; import { StudentService } from '../../../shared/services/student.service';
import { GoalCard } from '../goal-card/goal-card'; import { GoalCard } from '../goal-card/goal-card';
@@ -11,13 +12,15 @@ import { AddGoalModal } from '../add-goal-modal/add-goal-modal';
templateUrl: './goal-list.html', templateUrl: './goal-list.html',
styleUrl: './goal-list.scss', styleUrl: './goal-list.scss',
}) })
export class GoalList { export class GoalList implements OnDestroy {
// ************************** Constructor ************************** // ************************** Constructor **************************
constructor() { constructor() {
this.studentId = this.route.snapshot.paramMap.get('studentId')!; this.paramSub = this.route.paramMap.subscribe(params => {
this.studentId = params.get('studentId')!;
this.loadGoals(); this.loadGoals();
});
} }
// ************************** Declarations ************************* // ************************** Declarations *************************
@@ -25,8 +28,9 @@ export class GoalList {
private readonly studentService = inject(StudentService); private readonly studentService = inject(StudentService);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly paramSub: Subscription;
protected readonly studentId: string; protected studentId!: string;
protected readonly studentIdentifier = signal<string | null>(null); protected readonly studentIdentifier = signal<string | null>(null);
protected readonly goals = signal<StudentGoalItem[]>([]); protected readonly goals = signal<StudentGoalItem[]>([]);
protected readonly showAddModal = signal(false); protected readonly showAddModal = signal(false);
@@ -52,7 +56,11 @@ export class GoalList {
} }
onBack() { onBack() {
this.router.navigate(['/students']); this.router.navigate(['/students', this.studentId]);
}
ngOnDestroy() {
this.paramSub.unsubscribe();
} }
// ********************** Support Procedures *********************** // ********************** Support Procedures ***********************
@@ -1,13 +1,18 @@
<div class="toolbar"> <div class="toolbar">
<button class="toolbar-btn back-btn" (click)="onBack()">&#8592; Goals</button> <button class="toolbar-btn back-btn" (click)="onBack()">&#8593; Goal</button>
<span class="toolbar-title">Progress Events</span>
<span class="spacer"></span>
<button class="toolbar-btn" (click)="onAddProgressEvent()">+ Add Progress Event</button>
</div> </div>
<!-- <img class="hero-image" src="/slalomcropped.png" alt="Slalom" /> -->
@if (studentIdentifier() && goalTitle()) { @if (studentIdentifier() && goalTitle()) {
<div class="header-row"> <div class="header-row">
<h2 class="section-header"> <h2 class="section-header">
{{ events().length }} Progress Events for {{ studentIdentifier() }} for goal: {{ goalTitle() }} Student: {{ studentIdentifier() }} &nbsp;&nbsp; Goal: {{ goalTitle() }}
@if (isFiltered()) { @if (isFiltered()) {
<span class="filter-count">(showing {{ filteredEvents().length }})</span> <span class="filter-count">(showing {{ filteredEvents().length }} of {{ events().length }})</span>
} }
</h2> </h2>
<div class="search-box"> <div class="search-box">
@@ -25,7 +30,7 @@
} }
@if (filteredEvents().length === 0 && !errorMessage()) { @if (filteredEvents().length === 0 && !errorMessage()) {
<p class="empty-state">No progress events recorded yet.</p> <p class="empty-state">No progress events recorded yet. Click <strong>+ Add Progress Event</strong> to get started.</p>
} @else { } @else {
<div class="event-list"> <div class="event-list">
@for (evt of filteredEvents(); track evt.progressEventId) { @for (evt of filteredEvents(); track evt.progressEventId) {
@@ -4,9 +4,19 @@
height: 100%; height: 100%;
} }
.hero-image {
max-width: 50%;
max-height: 100px;
object-fit: contain;
margin-bottom: 1rem;
flex-shrink: 0;
align-self: flex-start;
}
.toolbar { .toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
position: relative;
gap: 0.75rem; gap: 0.75rem;
height: 40px; height: 40px;
padding-right: 0.5rem; padding-right: 0.5rem;
@@ -17,6 +27,19 @@
flex-shrink: 0; flex-shrink: 0;
} }
.toolbar-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-weight: 600;
font-size: 1.25rem;
color: #333;
}
.spacer {
flex: 1;
}
.toolbar-btn { .toolbar-btn {
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
background: transparent; background: transparent;
@@ -43,6 +43,7 @@ export class ProgressList implements OnDestroy {
protected readonly errorMessage = signal<string | null>(null); protected readonly errorMessage = signal<string | null>(null);
protected readonly rawSearchText = signal(''); protected readonly rawSearchText = signal('');
protected readonly searchTerm = signal(''); protected readonly searchTerm = signal('');
protected readonly showAddModal = signal(false);
// ************************** Properties *************************** // ************************** Properties ***************************
@@ -65,11 +66,16 @@ export class ProgressList implements OnDestroy {
// ************************ Event Handlers ************************* // ************************ Event Handlers *************************
onAddProgressEvent() {
this.showAddModal.set(true);
// TODO: Wire up add-progress-event modal component
}
// ***************************************************************** // *****************************************************************
// Navigates back to the goals list for this student. // Navigates back to the parent goal detail.
// ***************************************************************** // *****************************************************************
onBack() { onBack() {
this.router.navigate(['/students', this.studentId, 'goals']); this.router.navigate(['/students', this.studentId, 'goals', this.goalId]);
} }
// ***************************************************************** // *****************************************************************
@@ -0,0 +1,18 @@
@for (node of nodes(); track node.label) {
<div class="node-row" [style.padding-left]="indent()">
@if (hasToggle(node)) {
<span class="toggle-indicator" (click)="onToggle(node, $event)">{{ node.expanded ? '' : '+' }}</span>
}
@if (node.routerLink) {
<a class="node-label" [routerLink]="node.routerLink" routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }">{{ node.label }}</a>
} @else if (hasToggle(node)) {
<span class="node-label clickable" (click)="onToggle(node, $event)">{{ node.label }}</span>
} @else {
<span class="node-label">{{ node.label }}</span>
}
</div>
@if (node.expanded && node.children) {
<app-sidebar-tree-node [nodes]="node.children" [depth]="depth() + 1" />
}
}
@@ -0,0 +1,55 @@
:host {
display: contents;
}
.node-row {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
min-height: 1.75rem;
}
.node-row:hover {
background: #f5f5f5;
}
.node-row:has(.node-label.active) {
background: #e5e5e5;
}
.toggle-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border: 1px solid #aaa;
border-radius: 2px;
font-size: 0.75rem;
line-height: 1;
color: #555;
cursor: pointer;
user-select: none;
flex-shrink: 0;
}
.node-label {
flex: 1;
font-size: 0.8125rem;
color: #333;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-label.clickable {
cursor: pointer;
user-select: none;
}
.node-label.active {
font-weight: 600;
color: #4f46e5;
}
@@ -0,0 +1,67 @@
import { Component, input } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { SidebarNode } from '../../../shared/classes/sidebar-node';
@Component({
selector: 'app-sidebar-tree-node',
imports: [RouterLink, RouterLinkActive, SidebarTreeNode],
templateUrl: './sidebar-tree-node.html',
styleUrl: './sidebar-tree-node.scss',
})
export class SidebarTreeNode {
// ************************** Constructor **************************
// ************************** Declarations *************************
readonly nodes = input.required<SidebarNode[]>();
readonly depth = input<number>(0);
// ************************** Properties ***************************
// *****************************************************************
// Computed indentation in rem based on depth.
// *****************************************************************
indent(): string {
return (1 + this.depth() * 1.5) + 'rem';
}
// ************************ Public Methods *************************
// *****************************************************************
// Returns true if a node should show the +/- toggle.
// A node is expandable if it has static children, or has a
// loadChildren function with a non-zero childCount.
// *****************************************************************
hasToggle(node: SidebarNode): boolean {
if (node.children && node.children.length > 0) return true;
if (node.loadChildren && node.childCount !== 0) return true;
return false;
}
// ************************ Event Handlers *************************
// *****************************************************************
// Toggles a node's expanded state. On first expand of a lazy node,
// calls loadChildren and caches the result in node.children.
// *****************************************************************
async onToggle(node: SidebarNode, event: Event) {
if (!this.hasToggle(node)) return;
event.preventDefault();
event.stopPropagation();
if (node.expanded) {
node.expanded = false;
return;
}
if (node.loadChildren && !node.children) {
node.children = await node.loadChildren();
}
node.expanded = true;
}
// ********************** Support Procedures ***********************
}
@@ -0,0 +1,39 @@
<div class="toolbar">
<button class="toolbar-btn back-btn" (click)="onBack()">&#8593; Students</button>
<span class="toolbar-title">Student Detail</span>
<span class="spacer"></span>
</div>
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}
@if (loaded()) {
<div class="detail-card">
<div class="field">
<label class="field-label" for="identifier">Name</label>
<input id="identifier" class="field-input" type="text" [(ngModel)]="identifier" />
</div>
<div class="field">
<label class="field-label" for="expectedGrad">Expected Graduation</label>
<input id="expectedGrad" class="field-input" type="date" [(ngModel)]="expectedGradDate" />
</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 class="card-footer">
<a class="detail-link" (click)="onGoals()">Goals</a>
<span class="spacer"></span>
@if (successMessage()) {
<span class="success-label" [class.fade-out]="fading()">{{ successMessage() }}</span>
}
</div>
</div>
}
@@ -0,0 +1,155 @@
: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;
}
.detail-card {
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
max-width: 480px;
}
.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-value {
font-size: 0.9375rem;
color: #333;
}
.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;
}
.card-footer {
display: flex;
align-items: center;
margin: 1rem -1.5rem -1rem;
padding: 0.5rem 1.5rem 0.5rem;
border-top: 1px solid #eee;
}
.detail-link {
font-size: 0.875rem;
color: #4f46e5;
cursor: pointer;
text-decoration: underline;
}
.detail-link:hover {
color: #4338ca;
}
.success-label {
font-size: 0.8125rem;
color: #16a34a;
opacity: 1;
transition: opacity 1s ease;
}
.success-label.fade-out {
opacity: 0;
}
.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;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { StudentCardFull } from './student-card-full';
describe('StudentCardFull', () => {
let component: StudentCardFull;
let fixture: ComponentFixture<StudentCardFull>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [StudentCardFull]
})
.compileComponents();
fixture = TestBed.createComponent(StudentCardFull);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,160 @@
import { Component, inject, signal, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { Subscription } from 'rxjs';
import { StudentService } from '../../../shared/services/student.service';
import { StudentCardDto } from '../../../shared/classes/student-card.dto';
@Component({
selector: 'app-student-card-full',
imports: [FormsModule],
templateUrl: './student-card-full.html',
styleUrl: './student-card-full.scss',
})
export class StudentCardFull implements OnDestroy {
// ************************** Constructor **************************
constructor() {
this.paramSub = this.route.paramMap.subscribe(params => {
this.studentId = params.get('studentId')!;
this.loadStudent();
});
}
// ************************** Declarations *************************
private readonly studentService = inject(StudentService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly paramSub: Subscription;
private studentId!: string;
protected readonly errorMessage = signal<string | null>(null);
protected readonly successMessage = signal<string | null>(null);
protected readonly saving = signal(false);
protected readonly loaded = signal(false);
protected readonly fading = signal(false);
private successTimer: any = null;
// Form fields — always editable
protected identifier = '';
protected expectedGradDate = '';
// Snapshot of last-saved values for cancel
private savedIdentifier = '';
private savedExpectedGradDate = '';
// ************************** Properties ***************************
// *****************************************************************
// Returns true if form values differ from the saved snapshot.
// *****************************************************************
hasChanges(): boolean {
return this.identifier !== this.savedIdentifier
|| this.expectedGradDate !== this.savedExpectedGradDate;
}
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
// *****************************************************************
// Saves changes to the student via the API.
// *****************************************************************
async onSave() {
this.saving.set(true);
this.errorMessage.set(null);
this.successMessage.set(null);
const result = await this.studentService.updateStudent(this.studentId, {
identifier: this.identifier,
expectedGrad: this.expectedGradDate || null,
});
this.saving.set(false);
if (result.success) {
this.savedIdentifier = this.identifier;
this.savedExpectedGradDate = this.expectedGradDate;
this.showSuccessTemporarily('Changes saved.');
this.studentService.notifyDataChanged();
} else {
this.errorMessage.set(result.message);
}
}
// *****************************************************************
// Reverts form fields to the last-saved snapshot.
// *****************************************************************
onCancel() {
this.identifier = this.savedIdentifier;
this.expectedGradDate = this.savedExpectedGradDate;
this.errorMessage.set(null);
this.successMessage.set(null);
}
onBack() {
this.router.navigate(['/students']);
}
onGoals() {
this.router.navigate(['/students', this.studentId, 'goals']);
}
ngOnDestroy() {
this.paramSub.unsubscribe();
if (this.successTimer) clearTimeout(this.successTimer);
}
// ********************** Support Procedures ***********************
// *****************************************************************
// Shows a success message for 4 seconds, then fades it out over 1s.
// *****************************************************************
private showSuccessTemporarily(message: string) {
if (this.successTimer) clearTimeout(this.successTimer);
this.fading.set(false);
this.successMessage.set(message);
this.successTimer = setTimeout(() => {
this.fading.set(true);
this.successTimer = setTimeout(() => {
this.successMessage.set(null);
this.fading.set(false);
}, 1000);
}, 4000);
}
// *****************************************************************
// Loads the student by ID and populates form fields.
// *****************************************************************
private loadStudent() {
if (!this.loaded()) {
this.loaded.set(false);
}
this.studentService.getStudentById(this.studentId).then(result => {
if (result.success && result.payload) {
const s = result.payload;
this.identifier = s.identifier;
this.expectedGradDate = this.toDateInput(s.expectedGradDate);
this.savedIdentifier = this.identifier;
this.savedExpectedGradDate = this.expectedGradDate;
this.loaded.set(true);
} else {
this.errorMessage.set(result.message);
}
});
}
// *****************************************************************
// Converts a Date to a YYYY-MM-DD string for date input binding.
// *****************************************************************
private toDateInput(date: Date | null): string {
if (!date) return '';
const d = new Date(date);
return d.toISOString().split('T')[0];
}
}
@@ -16,10 +16,11 @@
margin-bottom: 1rem; margin-bottom: 1rem;
flex-shrink: 0; flex-shrink: 0;
} }
.page-title { .page-title {
font-size: 24px; font-size: 24px;
font-weight: 600; font-weight: 600;
margin: 0; margin-left: 0.75rem;
} }
.toolbar-btn { .toolbar-btn {
@@ -40,8 +41,8 @@
.card-grid { .card-grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-content: flex-start;
gap: 1rem; gap: 1rem;
overflow-y: auto; overflow-y: auto;
flex: 1; flex: 1;
} }
@@ -41,7 +41,7 @@ export class StudentCardList {
} }
onStudentCreated(student: StudentCardDto) { onStudentCreated(student: StudentCardDto) {
this.students.update(list => [...list, student]); this.students.update(list => this.sortByIdentifier([...list, student]));
this.showAddModal.set(false); this.showAddModal.set(false);
} }
@@ -51,19 +51,26 @@ export class StudentCardList {
// ********************** Support Procedures *********************** // ********************** Support Procedures ***********************
// *****************************************************************
// Sorts an array of students alphabetically by identifier.
// *****************************************************************
private sortByIdentifier(students: StudentCardDto[]): StudentCardDto[] {
return students.sort((a, b) =>
a.identifier.localeCompare(b.identifier, undefined, { sensitivity: 'base' })
);
}
// ***************************************************************** // *****************************************************************
// Loads students from the service and populates the students signal. // Loads students from the service and populates the students signal.
// ***************************************************************** // *****************************************************************
private loadStudents() { private loadStudents() {
this.studentService.getMyStudents().then(data => { this.studentService.getMyStudents().then(data => {
if(!data.success) if (!data.success) {
{
this.errorMessage.set(data.message); this.errorMessage.set(data.message);
} }
else else {
{ this.students.set(this.sortByIdentifier(data.payload || []))
this.students.set(data.payload || [])
} }
}); });
@@ -1,4 +1,4 @@
<div class="card" [routerLink]="['/students', student().studentId, 'goals']"> <div class="card" [routerLink]="['/students', student().studentId]">
<h2 class="identifier">🎓 {{ student().identifier }}</h2> <h2 class="identifier">🎓 {{ student().identifier }}</h2>
<div class="meta"> <div class="meta">
@@ -1,8 +1,12 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { Home } from './pages/home/home'; import { Home } from './pages/home/home';
import { StudentCardList } from './components/student-card-list/student-card-list'; import { StudentCardList } from './components/student-card-list/student-card-list';
import { StudentCardFull } from './components/student-card-full/student-card-full';
import { GoalList } from './components/goal-list/goal-list'; import { GoalList } from './components/goal-list/goal-list';
import { GoalCardFull } from './components/goal-card-full/goal-card-full';
import { ProgressList } from './components/progress-list/progress-list'; 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';
export default [ export default [
{ {
@@ -11,8 +15,14 @@ export default [
children: [ children: [
{ path: '', redirectTo: 'students', pathMatch: 'full' }, { path: '', redirectTo: 'students', pathMatch: 'full' },
{ path: 'students', component: StudentCardList }, { path: 'students', component: StudentCardList },
{ path: 'students/:studentId', component: StudentCardFull },
{ path: 'students/:studentId/goals', component: GoalList }, { 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', component: ProgressList },
{ 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 },
{ path: 'students/:studentId/benchmarks', component: BenchmarkList },
], ],
}, },
] satisfies Routes; ] satisfies Routes;
@@ -11,7 +11,7 @@
<div class="body"> <div class="body">
<nav class="sidebar" [class.expanded]="sidebarExpanded()"> <nav class="sidebar" [class.expanded]="sidebarExpanded()">
<a class="nav-item" routerLink="/students">Home</a> <a class="nav-item" routerLink="/students">Home</a>
<a class="nav-item sub" routerLink="/students" routerLinkActive="active">My Students</a> <app-sidebar-tree-node [nodes]="sidebarTree()" [depth]="0" />
</nav> </nav>
<main class="content"> <main class="content">
@@ -79,7 +79,8 @@
} }
.sidebar.expanded { .sidebar.expanded {
width: 220px; width: 260px;
overflow-y: auto;
} }
.nav-item { .nav-item {
@@ -95,11 +96,6 @@
background: #f5f5f5; background: #f5f5f5;
} }
.nav-item.sub {
padding-left: 2rem;
font-size: 0.8125rem;
}
.nav-item.active { .nav-item.active {
font-weight: 600; font-weight: 600;
color: #4f46e5; color: #4f46e5;
@@ -1,21 +1,52 @@
import { Component, inject, signal } from '@angular/core'; import { Component, effect, inject, OnDestroy, signal } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { NavigationEnd, Router, RouterLink, RouterOutlet } from '@angular/router';
import { Subscription } from 'rxjs';
import { filter } from 'rxjs/operators';
import { Auth } from '../../../shared/services/auth'; import { Auth } from '../../../shared/services/auth';
import { StudentService } from '../../../shared/services/student.service';
import { StudentCardDto } from '../../../shared/classes/student-card.dto';
import { SidebarNode } from '../../../shared/classes/sidebar-node';
import { SidebarTreeNode } from '../../components/sidebar-tree-node/sidebar-tree-node';
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
imports: [RouterOutlet, RouterLink, RouterLinkActive], imports: [RouterOutlet, RouterLink, SidebarTreeNode],
templateUrl: './home.html', templateUrl: './home.html',
styleUrl: './home.scss', styleUrl: './home.scss',
}) })
export class Home { export class Home implements OnDestroy {
// ************************** Constructor ************************** // ************************** Constructor **************************
constructor() {
this.loadStudents();
// Reload the sidebar tree whenever data changes elsewhere.
let initialized = false;
effect(() => {
this.studentService.dataVersion();
if (initialized) {
this.loadStudents();
}
initialized = true;
});
// Auto-expand sidebar nodes to match the current route.
this.routeSub = this.router.events.pipe(
filter(e => e instanceof NavigationEnd)
).subscribe(() => {
this.expandToRoute(this.router.url);
});
}
// ************************** Declarations ************************* // ************************** Declarations *************************
private readonly auth = inject(Auth); private readonly auth = inject(Auth);
protected readonly sidebarExpanded = signal(false); private readonly router = inject(Router);
private readonly studentService = inject(StudentService);
private readonly routeSub: Subscription;
protected readonly sidebarExpanded = signal(true);
protected readonly sidebarTree = signal<SidebarNode[]>([]);
// ************************** Properties *************************** // ************************** Properties ***************************
@@ -35,5 +66,132 @@ export class Home {
this.auth.forceLogout(); this.auth.forceLogout();
} }
ngOnDestroy() {
this.routeSub.unsubscribe();
}
// ********************** Support Procedures *********************** // ********************** Support Procedures ***********************
// *****************************************************************
// Loads student list, sorts by identifier, and builds the sidebar
// tree with lazy-loading callbacks for goals and benchmarks.
// *****************************************************************
private loadStudents() {
this.studentService.getMyStudents().then(data => {
if (data.success) {
const sorted = (data.payload || []).sort((a, b) =>
a.identifier.localeCompare(b.identifier, undefined, { sensitivity: 'base' })
);
this.sidebarTree.set(this.buildTree(sorted));
this.expandToRoute(this.router.url);
}
});
}
// *****************************************************************
// Builds the sidebar node tree from a list of students.
// *****************************************************************
private buildTree(students: StudentCardDto[]): SidebarNode[] {
return [{
label: 'My Students',
routerLink: ['/students'],
expanded: true,
childCount: students.length,
children: students.map(s => ({
label: s.identifier,
routerLink: ['/students', s.studentId],
childCount: s.goalCount > 0 ? 1 : 0,
children: s.goalCount > 0 ? [{
label: 'Goals',
routerLink: ['/students', s.studentId, 'goals'],
childCount: s.goalCount,
loadChildren: () => this.loadGoalNodes(s.studentId),
}] : undefined,
})),
}];
}
// *****************************************************************
// Lazy-loads individual goal nodes for a student. Called when
// the "Goals" node is expanded for the first time.
// *****************************************************************
private async loadGoalNodes(studentId: string): Promise<SidebarNode[]> {
const result = await this.studentService.getGoalsForStudent(studentId);
if (!result.success || !result.payload) return [];
return result.payload.goals.map(goal => ({
label: goal.title,
routerLink: ['/students', studentId, 'goals', goal.goalId],
childCount: 2,
children: [
{
label: 'Progress Events',
routerLink: ['/students', studentId, 'goals', goal.goalId, 'progress'],
childCount: goal.progressEventCount,
},
{
label: 'Benchmarks',
routerLink: ['/students', studentId, 'goals', goal.goalId, 'benchmarks'],
childCount: goal.benchmarkCount,
loadChildren: goal.benchmarkCount > 0
? () => this.loadBenchmarkNodes(studentId, goal.goalId)
: undefined,
},
],
}));
}
// *****************************************************************
// Lazy-loads benchmark leaf nodes for a goal. Called when a
// "Benchmarks" node is expanded for the first time.
// *****************************************************************
private async loadBenchmarkNodes(studentId: string, goalId: string): Promise<SidebarNode[]> {
const result = await this.studentService.getBenchmarksForStudent(studentId);
if (!result.success || !result.payload) return [];
return result.payload.benchmarks
.filter(b => b.goalId === goalId)
.map(b => ({
label: b.benchmark,
}));
}
// *****************************************************************
// Walks the sidebar tree and expands any node whose routerLink is
// a prefix of the current URL. Triggers lazy loading if needed.
// Returns true if the current URL matches or is a descendant of
// any node in the given list.
// *****************************************************************
private async expandToRoute(url: string, nodes?: SidebarNode[]): Promise<boolean> {
const tree = nodes || this.sidebarTree();
let matched = false;
for (const node of tree) {
const nodePath = node.routerLink ? node.routerLink.join('/') : '';
// Check if this node is the target or an ancestor of the target.
const isMatch = nodePath !== '' && url === nodePath;
const isAncestor = nodePath !== '' && url.startsWith(nodePath + '/');
if (isMatch || isAncestor) {
matched = true;
if (isAncestor) {
// Expand this node to reveal children.
if (node.loadChildren && !node.children) {
node.children = await node.loadChildren();
}
node.expanded = true;
// Continue down the tree.
if (node.children) {
await this.expandToRoute(url, node.children);
}
}
}
}
return matched;
}
} }
@@ -0,0 +1,14 @@
export interface StudentBenchmarkSummary {
studentIdentifier: string;
benchmarks: BenchmarkDto[];
}
export interface BenchmarkDto {
benchmarkId: string;
goalId: string;
goalTitle: string;
benchmark: string;
createdByName: string;
createdAt: Date;
updatedAt: Date | null;
}
@@ -0,0 +1,8 @@
export interface SidebarNode {
label: string;
routerLink?: string[];
children?: SidebarNode[];
expanded?: boolean;
loadChildren?: () => Promise<SidebarNode[]>;
childCount?: number;
}
@@ -10,4 +10,5 @@ export interface StudentGoalItem {
description: string; // goal.description — text description: string; // goal.description — text
category: string; // goal.category — varchar(100) category: string; // goal.category — varchar(100)
progressEventCount: number; // count of progress_event rows for this goal progressEventCount: number; // count of progress_event rows for this goal
benchmarkCount: number; // count of benchmark rows for this goal
} }
@@ -4,6 +4,7 @@ import { ApiResult } from '../classes/api-result';
import { StudentGoalSummary, StudentGoalItem } from '../classes/student-goal'; import { StudentGoalSummary, StudentGoalItem } from '../classes/student-goal';
import { CreateGoalDto } from '../classes/create-goal.dto'; import { CreateGoalDto } from '../classes/create-goal.dto';
import { ProgressEventDto } from '../classes/progress-event.dto'; import { ProgressEventDto } from '../classes/progress-event.dto';
import { StudentBenchmarkSummary, BenchmarkDto } from '../classes/benchmark.dto';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -18,41 +19,41 @@ export class DummyStudentService {
'1': { '1': {
studentIdentifier: 'J.B', studentIdentifier: 'J.B',
goals: [ goals: [
{ goalId: 'g1', goalParentId: null, title: 'Improve reading comprehension', description: 'Work on main-idea identification and inference skills across fiction and nonfiction texts.', category: 'Academics', progressEventCount: 5 }, { goalId: 'g1', goalParentId: null, title: 'Improve reading comprehension', description: 'Work on main-idea identification and inference skills across fiction and nonfiction texts.', category: 'Academics', progressEventCount: 5, benchmarkCount: 2 },
{ goalId: 'g2', goalParentId: null, title: 'Complete algebra module', description: 'Finish all units in the algebra course including linear equations and graphing.', category: 'Academics', progressEventCount: 2 }, { goalId: 'g2', goalParentId: null, title: 'Complete algebra module', description: 'Finish all units in the algebra course including linear equations and graphing.', category: 'Academics', progressEventCount: 2, benchmarkCount: 0 },
{ goalId: 'g3', goalParentId: null, title: 'Weekly journal entries', description: 'Write a reflective journal entry each week to build writing fluency.', category: 'Communication', progressEventCount: 8 }, { goalId: 'g3', goalParentId: null, title: 'Weekly journal entries', description: 'Write a reflective journal entry each week to build writing fluency.', category: 'Communication', progressEventCount: 8, benchmarkCount: 1 },
], ],
}, },
'2': { '2': {
studentIdentifier: 'M.K', studentIdentifier: 'M.K',
goals: [ goals: [
{ goalId: 'g4', goalParentId: null, title: 'Pass certification exam', description: 'Prepare for and pass the industry certification exam by end of quarter.', category: 'Career Readiness', progressEventCount: 3 }, { goalId: 'g4', goalParentId: null, title: 'Pass certification exam', description: 'Prepare for and pass the industry certification exam by end of quarter.', category: 'Career Readiness', progressEventCount: 3, benchmarkCount: 0 },
{ goalId: 'g5', goalParentId: null, title: 'Attendance above 90%', description: 'Maintain consistent attendance throughout the term.', category: 'Behavior', progressEventCount: 0 }, { goalId: 'g5', goalParentId: null, title: 'Attendance above 90%', description: 'Maintain consistent attendance throughout the term.', category: 'Behavior', progressEventCount: 0, benchmarkCount: 0 },
{ goalId: 'g6', goalParentId: null, title: 'Complete internship hours', description: 'Log the required 40 hours at the assigned internship site.', category: 'Career Readiness', progressEventCount: 12 }, { goalId: 'g6', goalParentId: null, title: 'Complete internship hours', description: 'Log the required 40 hours at the assigned internship site.', category: 'Career Readiness', progressEventCount: 12, benchmarkCount: 0 },
{ goalId: 'g7', goalParentId: null, title: 'Portfolio project', description: 'Build a personal portfolio showcasing completed coursework and projects.', category: 'Career Readiness', progressEventCount: 1 }, { goalId: 'g7', goalParentId: null, title: 'Portfolio project', description: 'Build a personal portfolio showcasing completed coursework and projects.', category: 'Career Readiness', progressEventCount: 1, benchmarkCount: 0 },
], ],
}, },
'3': { '3': {
studentIdentifier: 'A.R', studentIdentifier: 'A.R',
goals: [ goals: [
{ goalId: 'g8', goalParentId: null, title: 'GED preparation', description: 'Complete practice tests and study modules for GED math and reading sections.', category: 'Academics', progressEventCount: 6 }, { goalId: 'g8', goalParentId: null, title: 'GED preparation', description: 'Complete practice tests and study modules for GED math and reading sections.', category: 'Academics', progressEventCount: 6, benchmarkCount: 0 },
{ goalId: 'g9', goalParentId: null, title: 'Resume workshop', description: 'Attend the resume writing workshop and produce a final draft.', category: 'Career Readiness', progressEventCount: 0 }, { goalId: 'g9', goalParentId: null, title: 'Resume workshop', description: 'Attend the resume writing workshop and produce a final draft.', category: 'Career Readiness', progressEventCount: 0, benchmarkCount: 0 },
], ],
}, },
'4': { '4': {
studentIdentifier: 'T.W', studentIdentifier: 'T.W',
goals: [ goals: [
{ goalId: 'g10', goalParentId: null, title: 'Public speaking practice', description: 'Present in front of the class at least once per month.', category: 'Communication', progressEventCount: 4 }, { goalId: 'g10', goalParentId: null, title: 'Public speaking practice', description: 'Present in front of the class at least once per month.', category: 'Communication', progressEventCount: 4, benchmarkCount: 0 },
{ goalId: 'g11', goalParentId: null, title: 'Math placement improvement', description: 'Move up one placement level in math by the end of the semester.', category: 'Academics', progressEventCount: 7 }, { goalId: 'g11', goalParentId: null, title: 'Math placement improvement', description: 'Move up one placement level in math by the end of the semester.', category: 'Academics', progressEventCount: 7, benchmarkCount: 0 },
{ goalId: 'g12', goalParentId: null, title: 'Conflict resolution strategies', description: 'Learn and apply at least three de-escalation techniques.', category: 'Behavior', progressEventCount: 2 }, { goalId: 'g12', goalParentId: null, title: 'Conflict resolution strategies', description: 'Learn and apply at least three de-escalation techniques.', category: 'Behavior', progressEventCount: 2, benchmarkCount: 0 },
{ goalId: 'g13', goalParentId: null, title: 'Daily attendance streak', description: 'Achieve a 30-day unbroken attendance streak.', category: 'Behavior', progressEventCount: 0 }, { goalId: 'g13', goalParentId: null, title: 'Daily attendance streak', description: 'Achieve a 30-day unbroken attendance streak.', category: 'Behavior', progressEventCount: 0, benchmarkCount: 0 },
{ goalId: 'g14', goalParentId: null, title: 'Job shadow experience', description: 'Complete a job shadow day in a field of interest.', category: 'Career Readiness', progressEventCount: 1 }, { goalId: 'g14', goalParentId: null, title: 'Job shadow experience', description: 'Complete a job shadow day in a field of interest.', category: 'Career Readiness', progressEventCount: 1, benchmarkCount: 0 },
], ],
}, },
'5': { '5': {
studentIdentifier: 'L.C', studentIdentifier: 'L.C',
goals: [ goals: [
{ goalId: 'g15', goalParentId: null, title: 'Improve typing speed', description: 'Reach 40 WPM with 95% accuracy on typing assessments.', category: 'Career Readiness', progressEventCount: 3 }, { goalId: 'g15', goalParentId: null, title: 'Improve typing speed', description: 'Reach 40 WPM with 95% accuracy on typing assessments.', category: 'Career Readiness', progressEventCount: 3, benchmarkCount: 0 },
], ],
}, },
}; };
@@ -119,6 +120,7 @@ export class DummyStudentService {
description: data.description, description: data.description,
category: data.category, category: data.category,
progressEventCount: 0, progressEventCount: 0,
benchmarkCount: 0,
}; };
student.goals.push(newGoal); student.goals.push(newGoal);
@@ -161,6 +163,28 @@ export class DummyStudentService {
return ApiResult.ok(events); return ApiResult.ok(events);
} }
// *****************************************************************
// Returns hardcoded benchmarks for a given student.
// TODO: Replace with actual API call
// *****************************************************************
async getBenchmarksForStudent(studentId: string): Promise<ApiResult<StudentBenchmarkSummary | null>> {
const studentGoals = this.data[studentId];
if (!studentGoals) {
return ApiResult.fail('Student not found');
}
const benchmarks: BenchmarkDto[] = [
{ benchmarkId: 'bm1', goalId: 'g1', goalTitle: 'Improve reading comprehension', benchmark: 'Student will identify the main idea of a grade-level nonfiction passage with 80% accuracy.', createdByName: 'Jane Smith', createdAt: new Date('2026-02-15'), updatedAt: null },
{ benchmarkId: 'bm2', goalId: 'g1', goalTitle: 'Improve reading comprehension', benchmark: 'Student will make at least two supported inferences per reading session.', createdByName: 'Jane Smith', createdAt: new Date('2026-02-16'), updatedAt: new Date('2026-02-20') },
{ benchmarkId: 'bm3', goalId: 'g3', goalTitle: 'Weekly journal entries', benchmark: 'Student will complete a minimum of one paragraph (5 sentences) per journal entry.', createdByName: 'John Doe', createdAt: new Date('2026-02-18'), updatedAt: null },
];
return ApiResult.ok({
studentIdentifier: studentGoals.studentIdentifier,
benchmarks: benchmarks
});
}
// ************************ Event Handlers ************************* // ************************ Event Handlers *************************
@@ -1,5 +1,5 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { inject, Injectable } from '@angular/core'; import { inject, Injectable, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { ApiResult } from '../classes/api-result'; import { ApiResult } from '../classes/api-result';
@@ -10,6 +10,7 @@ import { CreateGoalDto } from '../classes/create-goal.dto';
import { StudentCardDto } from '../classes/student-card.dto'; import { StudentCardDto } from '../classes/student-card.dto';
import { StudentGoalSummary, StudentGoalItem } from '../classes/student-goal'; import { StudentGoalSummary, StudentGoalItem } from '../classes/student-goal';
import { ProgressEventDto } from '../classes/progress-event.dto'; import { ProgressEventDto } from '../classes/progress-event.dto';
import { StudentBenchmarkSummary } from '../classes/benchmark.dto';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -23,10 +24,20 @@ export class StudentService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly base = environment.apiBaseUrl; private readonly base = environment.apiBaseUrl;
// Incremented after any data mutation so subscribers can refresh.
readonly dataVersion = signal(0);
// ************************** Properties *************************** // ************************** Properties ***************************
// ************************ Public Methods ************************* // ************************ Public Methods *************************
// *****************************************************************
// Increments the data version signal so subscribers can refresh.
// *****************************************************************
notifyDataChanged() {
this.dataVersion.update(v => v + 1);
}
// ***************************************************************** // *****************************************************************
// Returns student card summaries for the authenticated user. // Returns student card summaries for the authenticated user.
// ***************************************************************** // *****************************************************************
@@ -123,4 +134,100 @@ export class StudentService {
// ************************ Event Handlers ************************* // ************************ Event Handlers *************************
// ********************** Support Procedures *********************** // ********************** Support Procedures ***********************
// *****************************************************************
// Returns a single student by ID.
// *****************************************************************
async getStudentById(studentId: string): Promise<ApiResult<StudentCardDto>> {
try {
const result = await firstValueFrom(
this.http.get<ResponseResult<StudentCardDto>>(`${this.base}/api/Student/${studentId}`)
);
return result.success && result.data
? ApiResult.ok(result.data)
: ApiResult.fail(result.message);
} catch (error) {
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
}
}
// *****************************************************************
// Updates a student and returns the refreshed student data.
// *****************************************************************
async updateStudent(studentId: string, data: { identifier?: string; programYear?: number | null; enrollmentDate?: string | null; expectedGrad?: string | null }): Promise<ApiResult<StudentCardDto>> {
try {
const result = await firstValueFrom(
this.http.put<ResponseResult<StudentCardDto>>(`${this.base}/api/Student/${studentId}`, data)
);
return result.success && result.data
? ApiResult.ok(result.data)
: ApiResult.fail(result.message);
} catch (error) {
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
}
}
// *****************************************************************
// Returns benchmarks for a given student.
// *****************************************************************
async getBenchmarksForStudent(studentId: string): Promise<ApiResult<StudentBenchmarkSummary | null>> {
try {
const result = await firstValueFrom(
this.http.get<ResponseResult<StudentBenchmarkSummary>>(`${this.base}/api/Student/${studentId}/benchmarks`)
);
return result.success && result.data
? ApiResult.ok(result.data)
: ApiResult.fail(result.message);
} catch (error) {
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
}
}
// *****************************************************************
// Creates a new benchmark for a student.
// *****************************************************************
async createBenchmark(studentId: string, data: { goalId: string; benchmark: string }): Promise<ApiResult<any>> {
try {
const result = await firstValueFrom(
this.http.post<ResponseResult<any>>(`${this.base}/api/Student/${studentId}/benchmarks`, data)
);
return result.success
? ApiResult.ok(result.data)
: ApiResult.fail(result.message);
} catch (error) {
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
}
}
// *****************************************************************
// Updates a benchmark's text.
// *****************************************************************
async updateBenchmark(studentId: string, benchmarkId: string, benchmarkText: string): Promise<ApiResult<any>> {
try {
const result = await firstValueFrom(
this.http.put<ResponseResult<any>>(`${this.base}/api/Student/${studentId}/benchmarks/${benchmarkId}`, { benchmark: benchmarkText })
);
return result.success
? ApiResult.ok(result.data)
: ApiResult.fail(result.message);
} catch (error) {
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
}
}
// *****************************************************************
// Updates a goal's title, description, and category.
// *****************************************************************
async updateGoal(studentId: string, goalId: string, data: { title?: string; description?: string; category?: string }): Promise<ApiResult<any>> {
try {
const result = await firstValueFrom(
this.http.put<ResponseResult<any>>(`${this.base}/api/Student/${studentId}/goals/${goalId}`, data)
);
return result.success
? ApiResult.ok(result.data)
: ApiResult.fail(result.message);
} catch (error) {
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
}
}
} }