diff --git a/api/src/Controllers/StudentController.cs b/api/src/Controllers/StudentController.cs index 99677e7..0bfd9ca 100644 --- a/api/src/Controllers/StudentController.cs +++ b/api/src/Controllers/StudentController.cs @@ -130,6 +130,39 @@ public class StudentController : BaseController }); } + [HttpGet("{idStudent:guid}/benchmarks")] + [Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status404NotFound)] + public async Task>> 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 + { + Success = false, + Message = "Student not found." + }); + } + + var summary = await _studentRepository.GetBenchmarkSummaryAsync(idStudent); + + return Ok(new ResponseResult + { + Success = true, + Message = "Benchmarks retrieved successfully.", + Data = summary + }); + } + [HttpPost("{idStudent:guid}/goals")] [Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.ProgramAdmin}")] [ProducesResponseType(typeof(ResponseResult), 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), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status404NotFound)] + public async Task>> 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 + { + Success = false, + Message = "Student not found." + }); + } + + var updated = await _studentRepository.UpdateGoalAsync(idGoal, dto); + + return Ok(new ResponseResult + { + Success = true, + Message = updated ? "Goal updated successfully." : "No changes were applied." + }); + } + [HttpPost("{idStudent:guid}/progress-event")] [Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")] [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status201Created)] @@ -411,4 +476,87 @@ public class StudentController : BaseController Message = "Student deleted." }); } + + [HttpPost("{idStudent:guid}/benchmarks")] + [Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.ProgramAdmin}")] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status400BadRequest)] + public async Task>> 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 + { + Success = false, + Message = "Student not found." + }); + } + + if (!PermissionService.IsAllowed(role, EntityType.Benchmark, PermissionAction.Create, isMine: true)) + { + return BadRequest(new ResponseResult + { + 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 + { + Success = false, + Message = "Unable to create benchmark." + }); + } + + return StatusCode(StatusCodes.Status201Created, new ResponseResult + { + Success = true, + Message = "Benchmark created successfully.", + Data = created + }); + } + + [HttpPut("{idStudent:guid}/benchmarks/{idBenchmark:guid}")] + [Authorize(Roles = $"{UserRoles.Teacher}")] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status404NotFound)] + public async Task>> 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 + { + Success = false, + Message = "Student not found." + }); + } + + var updated = await _studentRepository.UpdateBenchmarkAsync(idBenchmark, dto.Benchmark); + + return Ok(new ResponseResult + { + Success = true, + Message = updated ? "Changes applied successfully." : "No changes were applied." + }); + } } diff --git a/api/src/DataAccess/Models/DataTransferObjects/CreateBenchmarkDto.cs b/api/src/DataAccess/Models/DataTransferObjects/CreateBenchmarkDto.cs new file mode 100644 index 0000000..236f035 --- /dev/null +++ b/api/src/DataAccess/Models/DataTransferObjects/CreateBenchmarkDto.cs @@ -0,0 +1,7 @@ +namespace WinStudentGoalTracker.DataAccess; + +public class CreateBenchmarkDto +{ + public Guid GoalId { get; set; } + public string Benchmark { get; set; } = string.Empty; +} diff --git a/api/src/DataAccess/Models/DataTransferObjects/UpdateBenchmarkDto.cs b/api/src/DataAccess/Models/DataTransferObjects/UpdateBenchmarkDto.cs new file mode 100644 index 0000000..ed3543a --- /dev/null +++ b/api/src/DataAccess/Models/DataTransferObjects/UpdateBenchmarkDto.cs @@ -0,0 +1,6 @@ +namespace WinStudentGoalTracker.DataAccess; + +public class UpdateBenchmarkDto +{ + public string Benchmark { get; set; } = string.Empty; +} diff --git a/api/src/DataAccess/Models/DataTransferObjects/UpdateGoalDto.cs b/api/src/DataAccess/Models/DataTransferObjects/UpdateGoalDto.cs new file mode 100644 index 0000000..8310302 --- /dev/null +++ b/api/src/DataAccess/Models/DataTransferObjects/UpdateGoalDto.cs @@ -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; } +} diff --git a/api/src/DataAccess/Models/DatabaseObjects/dbStudentBenchmarkRow.cs b/api/src/DataAccess/Models/DatabaseObjects/dbStudentBenchmarkRow.cs new file mode 100644 index 0000000..fc95bf8 --- /dev/null +++ b/api/src/DataAccess/Models/DatabaseObjects/dbStudentBenchmarkRow.cs @@ -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; } +} diff --git a/api/src/DataAccess/Models/DatabaseObjects/dbStudentGoalRow.cs b/api/src/DataAccess/Models/DatabaseObjects/dbStudentGoalRow.cs index 50e645f..d660976 100644 --- a/api/src/DataAccess/Models/DatabaseObjects/dbStudentGoalRow.cs +++ b/api/src/DataAccess/Models/DatabaseObjects/dbStudentGoalRow.cs @@ -9,4 +9,5 @@ public class dbStudentGoalRow public string? Description { get; set; } public string? Category { get; set; } public int ProgressEventCount { get; set; } + public int BenchmarkCount { get; set; } } diff --git a/api/src/DataAccess/Repositories/StudentRepository.cs b/api/src/DataAccess/Repositories/StudentRepository.cs index 9346902..f4f42d6 100644 --- a/api/src/DataAccess/Repositories/StudentRepository.cs +++ b/api/src/DataAccess/Repositories/StudentRepository.cs @@ -194,9 +194,119 @@ public class StudentRepository Title = r.Title, Description = r.Description, Category = r.Category, - ProgressEventCount = r.ProgressEventCount + ProgressEventCount = r.ProgressEventCount, + BenchmarkCount = r.BenchmarkCount }).ToList() }; } + // ***************************************************************** + // Updates a goal's title, description, and category. + // ***************************************************************** + public async Task UpdateGoalAsync(Guid goalId, UpdateGoalDto dto) + { + using var db = Connection; + var rowsAffected = await db.ExecuteScalarAsync( + "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 GetBenchmarkSummaryAsync(Guid idStudent) + { + using var db = Connection; + var rows = await db.QueryAsync( + "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 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 UpdateBenchmarkAsync(Guid benchmarkId, string benchmarkText) + { + using var db = Connection; + var rowsAffected = await db.ExecuteScalarAsync( + "sp_Benchmark_Update", + new + { + p_id_benchmark = benchmarkId.ToString(), + p_benchmark = benchmarkText + }, + commandType: CommandType.StoredProcedure); + return rowsAffected > 0; + } + } diff --git a/api/src/Models/ResponseTypes/StudentBenchmarkItem.cs b/api/src/Models/ResponseTypes/StudentBenchmarkItem.cs new file mode 100644 index 0000000..b1ab69e --- /dev/null +++ b/api/src/Models/ResponseTypes/StudentBenchmarkItem.cs @@ -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; } +} diff --git a/api/src/Models/ResponseTypes/StudentBenchmarkSummary.cs b/api/src/Models/ResponseTypes/StudentBenchmarkSummary.cs new file mode 100644 index 0000000..d9c385d --- /dev/null +++ b/api/src/Models/ResponseTypes/StudentBenchmarkSummary.cs @@ -0,0 +1,7 @@ +namespace WinStudentGoalTracker.Models; + +public class StudentBenchmarkSummary +{ + public string? StudentIdentifier { get; set; } + public List Benchmarks { get; set; } = []; +} diff --git a/api/src/Models/ResponseTypes/StudentGoalItem.cs b/api/src/Models/ResponseTypes/StudentGoalItem.cs index 5d6b7c1..6e39936 100644 --- a/api/src/Models/ResponseTypes/StudentGoalItem.cs +++ b/api/src/Models/ResponseTypes/StudentGoalItem.cs @@ -8,4 +8,5 @@ public class StudentGoalItem public string? Description { get; set; } public string? Category { get; set; } public int ProgressEventCount { get; set; } + public int BenchmarkCount { get; set; } } diff --git a/api/src/Models/Security/EntityType.cs b/api/src/Models/Security/EntityType.cs index 332d1b7..95e94a9 100644 --- a/api/src/Models/Security/EntityType.cs +++ b/api/src/Models/Security/EntityType.cs @@ -8,10 +8,11 @@ public static class EntityType public const string Student = "student"; public const string Goal = "goal"; public const string ProgressEvent = "progress_event"; + public const string Benchmark = "benchmark"; public static string? TryParse(string value) => All.Contains(value) ? value : null; public static readonly IReadOnlyList All = - [SchoolDistrict, Program, User, Student, Goal, ProgressEvent]; + [SchoolDistrict, Program, User, Student, Goal, ProgressEvent, Benchmark]; } diff --git a/api/src/Services/PermissionMatrix.cs b/api/src/Services/PermissionMatrix.cs index 1d61c6a..4ddf0de 100644 --- a/api/src/Services/PermissionMatrix.cs +++ b/api/src/Services/PermissionMatrix.cs @@ -60,6 +60,13 @@ public static class PermissionMatrix [PermissionAction.Update] = 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.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.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.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.Delete] = Deny, }, + [EntityType.Benchmark] = new() + { + [PermissionAction.Create] = Deny, + [PermissionAction.Read] = MineOnly, + [PermissionAction.Update] = Deny, + [PermissionAction.Delete] = Deny, + }, }, }; diff --git a/db/Objects/procedures/sp_Benchmark_GetByStudentId.sql b/db/Objects/procedures/sp_Benchmark_GetByStudentId.sql new file mode 100644 index 0000000..0ff1ce2 --- /dev/null +++ b/db/Objects/procedures/sp_Benchmark_GetByStudentId.sql @@ -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 ; diff --git a/db/Objects/procedures/sp_Benchmark_Insert.sql b/db/Objects/procedures/sp_Benchmark_Insert.sql new file mode 100644 index 0000000..ad77528 --- /dev/null +++ b/db/Objects/procedures/sp_Benchmark_Insert.sql @@ -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 ; diff --git a/db/Objects/procedures/sp_Benchmark_Update.sql b/db/Objects/procedures/sp_Benchmark_Update.sql new file mode 100644 index 0000000..d4e6807 --- /dev/null +++ b/db/Objects/procedures/sp_Benchmark_Update.sql @@ -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 ; diff --git a/db/Objects/procedures/sp_Goal_GetByStudentId.sql b/db/Objects/procedures/sp_Goal_GetByStudentId.sql index 9c968a7..13b9132 100644 --- a/db/Objects/procedures/sp_Goal_GetByStudentId.sql +++ b/db/Objects/procedures/sp_Goal_GetByStudentId.sql @@ -8,7 +8,8 @@ BEGIN vc.`title`, vc.`description`, vc.`category`, - vc.`progressEventCount` + vc.`progressEventCount`, + vc.`benchmarkCount` FROM `v_goal_card` vc INNER JOIN `student` s ON s.`id_student` = vc.`studentId` WHERE vc.`studentId` = p_id_student diff --git a/db/Objects/tables/benchmark.sql b/db/Objects/tables/benchmark.sql new file mode 100644 index 0000000..9c01e9e --- /dev/null +++ b/db/Objects/tables/benchmark.sql @@ -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; diff --git a/db/Objects/tables/progress_event_benchmark.sql b/db/Objects/tables/progress_event_benchmark.sql new file mode 100644 index 0000000..2a255da --- /dev/null +++ b/db/Objects/tables/progress_event_benchmark.sql @@ -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; diff --git a/db/Objects/views/v_goal_card.sql b/db/Objects/views/v_goal_card.sql index 91a48f3..a935665 100644 --- a/db/Objects/views/v_goal_card.sql +++ b/db/Objects/views/v_goal_card.sql @@ -1,5 +1,7 @@ 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` -from (`winstudentgoaltracker`.`goal` +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` `g` 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`; diff --git a/db/Objects/views/v_progress_event_card.sql b/db/Objects/views/v_progress_event_card.sql index f28d57a..a557da7 100644 --- a/db/Objects/views/v_progress_event_card.sql +++ b/db/Objects/views/v_progress_event_card.sql @@ -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` from ((`winstudentgoaltracker`.`progress_event` `pe` 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`))); diff --git a/ui/winstudentgoaltracker/public/hurdles.png b/ui/winstudentgoaltracker/public/hurdles.png new file mode 100644 index 0000000..dda2ca8 Binary files /dev/null and b/ui/winstudentgoaltracker/public/hurdles.png differ diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card-full/benchmark-card-full.html b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card-full/benchmark-card-full.html new file mode 100644 index 0000000..3cd0d3f --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card-full/benchmark-card-full.html @@ -0,0 +1,44 @@ +
+ + {{ isNew() ? 'New Benchmark' : 'Benchmark Detail' }} + +
+ +@if (errorMessage()) { +

{{ errorMessage() }}

+} + +@if (successMessage()) { +

{{ successMessage() }}

+} + +@if (loaded()) { +
+
+ Goal + {{ goalTitle }} +
+
+ + +
+ @if (!isNew()) { + + } +
+ + +
+
+} \ No newline at end of file diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card-full/benchmark-card-full.scss b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card-full/benchmark-card-full.scss new file mode 100644 index 0000000..a799a7b --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card-full/benchmark-card-full.scss @@ -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; +} \ No newline at end of file diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card-full/benchmark-card-full.spec.ts b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card-full/benchmark-card-full.spec.ts new file mode 100644 index 0000000..30710b6 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card-full/benchmark-card-full.spec.ts @@ -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; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BenchmarkCardFull] + }) + .compileComponents(); + + fixture = TestBed.createComponent(BenchmarkCardFull); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card-full/benchmark-card-full.ts b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card-full/benchmark-card-full.ts new file mode 100644 index 0000000..5328476 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card-full/benchmark-card-full.ts @@ -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(null); + protected readonly successMessage = signal(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 ?? ''; + } + }); + } +} diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card/benchmark-card.html b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card/benchmark-card.html new file mode 100644 index 0000000..fa8a4f6 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card/benchmark-card.html @@ -0,0 +1,12 @@ +
+
+ {{ benchmark().goalTitle }} + @if (benchmark().updatedAt) { + Updated: {{ benchmark().updatedAt | date:'M/d/yy' }} + } @else { + {{ benchmark().createdAt | date:'M/d/yy' }} + } +
+ +

{{ benchmark().benchmark }}

+
\ No newline at end of file diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card/benchmark-card.scss b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card/benchmark-card.scss new file mode 100644 index 0000000..e7744af --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card/benchmark-card.scss @@ -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; +} \ No newline at end of file diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card/benchmark-card.spec.ts b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card/benchmark-card.spec.ts new file mode 100644 index 0000000..1881594 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card/benchmark-card.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BenchmarkCard } from './benchmark-card'; + +describe('BenchmarkCard', () => { + let component: BenchmarkCard; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BenchmarkCard] + }) + .compileComponents(); + + fixture = TestBed.createComponent(BenchmarkCard); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card/benchmark-card.ts b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card/benchmark-card.ts new file mode 100644 index 0000000..4f924a3 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-card/benchmark-card.ts @@ -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(); + + // ************************** Properties *************************** + + // ************************ Public Methods ************************* + + // ************************ Event Handlers ************************* + + // ********************** Support Procedures *********************** +} diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-list/benchmark-list.html b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-list/benchmark-list.html new file mode 100644 index 0000000..dae1868 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-list/benchmark-list.html @@ -0,0 +1,24 @@ +
+ + + +
+ +@if (studentIdentifier()) { +

Benchmarks for {{ studentIdentifier() }}

+} + +@if (errorMessage()) { +

{{ errorMessage() }}

+} + +@if (benchmarks().length === 0 && !errorMessage()) { +

No benchmarks yet. Click Add a Benchmark to + get started.

+} @else { +
+ @for (bm of benchmarks(); track bm.benchmarkId) { + + } +
+} \ No newline at end of file diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-list/benchmark-list.scss b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-list/benchmark-list.scss new file mode 100644 index 0000000..c5a6379 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-list/benchmark-list.scss @@ -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; +} \ No newline at end of file diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-list/benchmark-list.spec.ts b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-list/benchmark-list.spec.ts new file mode 100644 index 0000000..964283c --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-list/benchmark-list.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BenchmarkList } from './benchmark-list'; + +describe('BenchmarkList', () => { + let component: BenchmarkList; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BenchmarkList] + }) + .compileComponents(); + + fixture = TestBed.createComponent(BenchmarkList); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-list/benchmark-list.ts b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-list/benchmark-list.ts new file mode 100644 index 0000000..8dfcb29 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/benchmark-list/benchmark-list.ts @@ -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(null); + protected readonly benchmarks = signal([]); + protected readonly errorMessage = signal(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 ?? []); + } + }); + } +} diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/goal-card-full/goal-card-full.html b/ui/winstudentgoaltracker/src/app/desktop/components/goal-card-full/goal-card-full.html new file mode 100644 index 0000000..6f24319 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/goal-card-full/goal-card-full.html @@ -0,0 +1,42 @@ +
+ + Goal Detail + +
+ +@if (errorMessage()) { +

{{ errorMessage() }}

+} + +@if (successMessage()) { +

{{ successMessage() }}

+} + +@if (loaded()) { +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+} \ No newline at end of file diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/goal-card-full/goal-card-full.scss b/ui/winstudentgoaltracker/src/app/desktop/components/goal-card-full/goal-card-full.scss new file mode 100644 index 0000000..2ab516b --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/goal-card-full/goal-card-full.scss @@ -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; +} \ No newline at end of file diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/goal-card-full/goal-card-full.spec.ts b/ui/winstudentgoaltracker/src/app/desktop/components/goal-card-full/goal-card-full.spec.ts new file mode 100644 index 0000000..d69bbd6 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/goal-card-full/goal-card-full.spec.ts @@ -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; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GoalCardFull] + }) + .compileComponents(); + + fixture = TestBed.createComponent(GoalCardFull); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/goal-card-full/goal-card-full.ts b/ui/winstudentgoaltracker/src/app/desktop/components/goal-card-full/goal-card-full.ts new file mode 100644 index 0000000..8bb14bd --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/goal-card-full/goal-card-full.ts @@ -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(null); + protected readonly successMessage = signal(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); + }); + } +} diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/goal-card/goal-card.html b/ui/winstudentgoaltracker/src/app/desktop/components/goal-card/goal-card.html index 329c66c..3a1ddf6 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/goal-card/goal-card.html +++ b/ui/winstudentgoaltracker/src/app/desktop/components/goal-card/goal-card.html @@ -1,4 +1,4 @@ -
+
{{ goal().category }} {{ goal().progressEventCount }} events @@ -6,4 +6,9 @@

{{ goal().title }}

{{ goal().description }}

+ +
\ No newline at end of file diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/goal-card/goal-card.scss b/ui/winstudentgoaltracker/src/app/desktop/components/goal-card/goal-card.scss index c3a0d8d..42b66ef 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/goal-card/goal-card.scss +++ b/ui/winstudentgoaltracker/src/app/desktop/components/goal-card/goal-card.scss @@ -11,16 +11,29 @@ display: flex; flex-direction: column; gap: 0.625rem; - height: 130px; + cursor: pointer; + min-width: 0; } -.card.clickable { - cursor: pointer; +.card-footer { + display: flex; + align-items: center; + gap: 1.5rem; + margin: 0 -1.5rem -1rem; + padding: 0.5rem 1.5rem 0.5rem; + border-top: 1px solid #eee; } -.card.clickable:hover { - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); +.footer-link { + font-size: 0.875rem; + color: #4f46e5; + cursor: pointer; + text-decoration: underline; +} + +.footer-link:hover { + color: #4338ca; } .card-header { diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/goal-card/goal-card.ts b/ui/winstudentgoaltracker/src/app/desktop/components/goal-card/goal-card.ts index abd6797..7fd7314 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/goal-card/goal-card.ts +++ b/ui/winstudentgoaltracker/src/app/desktop/components/goal-card/goal-card.ts @@ -26,9 +26,25 @@ export class GoalCard { // ************************ Event Handlers ************************* // ***************************************************************** - // Navigates to the progress events page for this goal. + // Navigates to the goal detail page. // ***************************************************************** 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')!; this.router.navigate(['/students', studentId, 'goals', this.goal().goalId, 'progress']); } diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/goal-list/goal-list.html b/ui/winstudentgoaltracker/src/app/desktop/components/goal-list/goal-list.html index 0b63021..45d5c09 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/goal-list/goal-list.html +++ b/ui/winstudentgoaltracker/src/app/desktop/components/goal-list/goal-list.html @@ -1,12 +1,15 @@
- + + Goals -
+ + + @if (studentIdentifier()) { -

Goals for {{ studentIdentifier() }}

+

Student: {{ studentIdentifier() }}

} @if (showAddModal()) { @@ -26,4 +29,9 @@ }
-} \ No newline at end of file +} + +
+ + +
\ No newline at end of file diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/goal-list/goal-list.scss b/ui/winstudentgoaltracker/src/app/desktop/components/goal-list/goal-list.scss index a13b731..d23df06 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/goal-list/goal-list.scss +++ b/ui/winstudentgoaltracker/src/app/desktop/components/goal-list/goal-list.scss @@ -4,27 +4,11 @@ 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 { display: flex; align-items: center; + position: relative; gap: 0.75rem; height: 40px; padding-right: 0.5rem; @@ -55,6 +39,15 @@ margin-left: 0.5rem; } +.toolbar-title { + position: absolute; + left: 50%; + transform: translateX(-50%); + font-weight: 600; + font-size: 1.25rem; + color: #333; +} + .section-header { font-size: 1.125rem; font-weight: 600; @@ -85,4 +78,14 @@ gap: 1rem; overflow-y: auto; flex: 1; +} + +.goal-footer { + display: flex; + align-items: center; + padding: 0 1rem; + height: 48px; + background: #fff; + border-top: 1px solid #ddd; + flex-shrink: 0; } \ No newline at end of file diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/goal-list/goal-list.ts b/ui/winstudentgoaltracker/src/app/desktop/components/goal-list/goal-list.ts index d683d7b..8ffd288 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/goal-list/goal-list.ts +++ b/ui/winstudentgoaltracker/src/app/desktop/components/goal-list/goal-list.ts @@ -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 { Subscription } from 'rxjs'; import { StudentGoalItem } from '../../../shared/classes/student-goal'; import { StudentService } from '../../../shared/services/student.service'; import { GoalCard } from '../goal-card/goal-card'; @@ -11,13 +12,15 @@ import { AddGoalModal } from '../add-goal-modal/add-goal-modal'; templateUrl: './goal-list.html', styleUrl: './goal-list.scss', }) -export class GoalList { +export class GoalList implements OnDestroy { // ************************** Constructor ************************** constructor() { - this.studentId = this.route.snapshot.paramMap.get('studentId')!; - this.loadGoals(); + this.paramSub = this.route.paramMap.subscribe(params => { + this.studentId = params.get('studentId')!; + this.loadGoals(); + }); } // ************************** Declarations ************************* @@ -25,8 +28,9 @@ export class GoalList { private readonly studentService = inject(StudentService); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); + private readonly paramSub: Subscription; - protected readonly studentId: string; + protected studentId!: string; protected readonly studentIdentifier = signal(null); protected readonly goals = signal([]); protected readonly showAddModal = signal(false); @@ -52,7 +56,11 @@ export class GoalList { } onBack() { - this.router.navigate(['/students']); + this.router.navigate(['/students', this.studentId]); + } + + ngOnDestroy() { + this.paramSub.unsubscribe(); } // ********************** Support Procedures *********************** diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/progress-list/progress-list.html b/ui/winstudentgoaltracker/src/app/desktop/components/progress-list/progress-list.html index ffb1510..b89f1e9 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/progress-list/progress-list.html +++ b/ui/winstudentgoaltracker/src/app/desktop/components/progress-list/progress-list.html @@ -1,13 +1,18 @@
- + + Progress Events + +
+ + @if (studentIdentifier() && goalTitle()) {

- {{ events().length }} Progress Events for {{ studentIdentifier() }} for goal: {{ goalTitle() }} + Student: {{ studentIdentifier() }}    Goal: {{ goalTitle() }} @if (isFiltered()) { - (showing {{ filteredEvents().length }}) + (showing {{ filteredEvents().length }} of {{ events().length }}) }