From bd360b42ff93de6c972d2cf3e2440941a84696f6 Mon Sep 17 00:00:00 2001 From: ivan-pelly Date: Sun, 29 Mar 2026 18:49:13 -0700 Subject: [PATCH] Initial report --- api/src/Controllers/StudentController.cs | 44 +++++ .../dbProgressReportGoalRow.cs | 8 + .../DatabaseObjects/dbProgressReportRow.cs | 10 ++ .../Repositories/StudentRepository.cs | 50 ++++++ .../StudentProgressReportResponse.cs | 23 +++ .../Models/ResponseTypes/StudentResponse.cs | 1 + api/src/Services/ProgressReportBuilder.cs | 65 +++++++ .../sp_ProgressReport_GetByStudentId.sql | 47 +++++ .../sp_Student_GetWithAssignments.sql | 1 + db/Objects/views/v_student_card.sql | 2 +- .../desktop/components/reports/reports.html | 10 ++ .../desktop/components/reports/reports.scss | 51 ++++++ .../components/reports/reports.spec.ts | 23 +++ .../app/desktop/components/reports/reports.ts | 12 ++ .../student-progress-report.html | 57 ++++++ .../student-progress-report.scss | 164 ++++++++++++++++++ .../student-progress-report.spec.ts | 23 +++ .../student-progress-report.ts | 159 +++++++++++++++++ .../src/app/desktop/desktop.routes.ts | 4 + .../src/app/desktop/pages/home/home.ts | 4 + .../app/shared/classes/student-card.dto.ts | 1 + .../classes/student-progress-report.dto.ts | 18 ++ .../shared/services/dummy-student.service.ts | 3 + .../app/shared/services/student.service.ts | 24 +++ 24 files changed, 803 insertions(+), 1 deletion(-) create mode 100644 api/src/DataAccess/Models/DatabaseObjects/dbProgressReportGoalRow.cs create mode 100644 api/src/DataAccess/Models/DatabaseObjects/dbProgressReportRow.cs create mode 100644 api/src/Models/ResponseTypes/StudentProgressReportResponse.cs create mode 100644 api/src/Services/ProgressReportBuilder.cs create mode 100644 db/Objects/procedures/sp_ProgressReport_GetByStudentId.sql create mode 100644 ui/winstudentgoaltracker/src/app/desktop/components/reports/reports.html create mode 100644 ui/winstudentgoaltracker/src/app/desktop/components/reports/reports.scss create mode 100644 ui/winstudentgoaltracker/src/app/desktop/components/reports/reports.spec.ts create mode 100644 ui/winstudentgoaltracker/src/app/desktop/components/reports/reports.ts create mode 100644 ui/winstudentgoaltracker/src/app/desktop/components/student-progress-report/student-progress-report.html create mode 100644 ui/winstudentgoaltracker/src/app/desktop/components/student-progress-report/student-progress-report.scss create mode 100644 ui/winstudentgoaltracker/src/app/desktop/components/student-progress-report/student-progress-report.spec.ts create mode 100644 ui/winstudentgoaltracker/src/app/desktop/components/student-progress-report/student-progress-report.ts create mode 100644 ui/winstudentgoaltracker/src/app/shared/classes/student-progress-report.dto.ts diff --git a/api/src/Controllers/StudentController.cs b/api/src/Controllers/StudentController.cs index 1a9680e..bf9b64e 100644 --- a/api/src/Controllers/StudentController.cs +++ b/api/src/Controllers/StudentController.cs @@ -621,4 +621,48 @@ public class StudentController : BaseController Message = updated ? "Changes applied successfully." : "No changes were applied." }); } + + [HttpGet("{idStudent:guid}/progress-report")] + [Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status404NotFound)] + public async Task>> GetProgressReport( + Guid idStudent, [FromQuery] DateTime fromDate, [FromQuery] DateTime toDate, [FromQuery] string? goalIds = null) + { + 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 report = await _studentRepository.GetProgressReportAsync(idStudent, fromDate, toDate, goalIds); + if (report is null) + { + return NotFound(new ResponseResult + { + Success = false, + Message = "Student not found." + }); + } + + var markdown = ProgressReportBuilder.BuildMarkdown(report, fromDate, toDate); + + return Ok(new ResponseResult + { + Success = true, + Message = "Progress report generated successfully.", + Data = markdown + }); + } } diff --git a/api/src/DataAccess/Models/DatabaseObjects/dbProgressReportGoalRow.cs b/api/src/DataAccess/Models/DatabaseObjects/dbProgressReportGoalRow.cs new file mode 100644 index 0000000..61b6706 --- /dev/null +++ b/api/src/DataAccess/Models/DatabaseObjects/dbProgressReportGoalRow.cs @@ -0,0 +1,8 @@ +namespace WinStudentGoalTracker.DataAccess; + +public class dbProgressReportGoalRow +{ + public required Guid GoalId { get; set; } + public string? Category { get; set; } + public string? Description { get; set; } +} diff --git a/api/src/DataAccess/Models/DatabaseObjects/dbProgressReportRow.cs b/api/src/DataAccess/Models/DatabaseObjects/dbProgressReportRow.cs new file mode 100644 index 0000000..6d9a577 --- /dev/null +++ b/api/src/DataAccess/Models/DatabaseObjects/dbProgressReportRow.cs @@ -0,0 +1,10 @@ +namespace WinStudentGoalTracker.DataAccess; + +public class dbProgressReportRow +{ + public required Guid GoalId { get; set; } + public required Guid ProgressEventId { get; set; } + public string? Content { get; set; } + public DateTime? CreatedAt { get; set; } + public string? BenchmarkNames { get; set; } +} diff --git a/api/src/DataAccess/Repositories/StudentRepository.cs b/api/src/DataAccess/Repositories/StudentRepository.cs index a88e7f7..fcb8469 100644 --- a/api/src/DataAccess/Repositories/StudentRepository.cs +++ b/api/src/DataAccess/Repositories/StudentRepository.cs @@ -351,4 +351,54 @@ public class StudentRepository return rowsAffected > 0; } + // ***************************************************************** + // Returns a full progress report for a student within the given + // date range. Calls sp_ProgressReport_GetByStudentId which returns + // two result sets: goals and progress events with benchmark names. + // ***************************************************************** + public async Task GetProgressReportAsync( + Guid studentId, DateTime fromDate, DateTime toDate, string? goalIds = null) + { + var student = await GetByIdAsync(studentId); + if (student is null) return null; + + using var db = Connection; + using var multi = await db.QueryMultipleAsync( + "sp_ProgressReport_GetByStudentId", + new + { + p_id_student = studentId.ToString(), + p_from_date = fromDate.ToString("yyyy-MM-dd"), + p_to_date = toDate.ToString("yyyy-MM-dd"), + p_goal_ids = goalIds + }, + commandType: CommandType.StoredProcedure); + + var goalRows = (await multi.ReadAsync()).ToList(); + var eventRows = (await multi.ReadAsync()).ToList(); + + var eventsByGoal = eventRows.GroupBy(e => e.GoalId) + .ToDictionary(g => g.Key, g => g.ToList()); + + return new StudentProgressReportResponse + { + StudentIdentifier = student.Identifier, + Goals = goalRows.Select(g => new ProgressReportGoal + { + GoalId = g.GoalId, + Category = g.Category, + Description = g.Description, + ProgressEvents = eventsByGoal.TryGetValue(g.GoalId, out var events) + ? events.Select(e => new ProgressReportEvent + { + ProgressEventId = e.ProgressEventId, + Content = e.Content, + CreatedAt = e.CreatedAt, + BenchmarkNames = e.BenchmarkNames + }).ToList() + : [] + }).ToList() + }; + } + } diff --git a/api/src/Models/ResponseTypes/StudentProgressReportResponse.cs b/api/src/Models/ResponseTypes/StudentProgressReportResponse.cs new file mode 100644 index 0000000..fb5f93a --- /dev/null +++ b/api/src/Models/ResponseTypes/StudentProgressReportResponse.cs @@ -0,0 +1,23 @@ +namespace WinStudentGoalTracker.Models; + +public class StudentProgressReportResponse +{ + public string? StudentIdentifier { get; set; } + public List Goals { get; set; } = []; +} + +public class ProgressReportGoal +{ + public Guid GoalId { get; set; } + public string? Category { get; set; } + public string? Description { get; set; } + public List ProgressEvents { get; set; } = []; +} + +public class ProgressReportEvent +{ + public Guid ProgressEventId { get; set; } + public string? Content { get; set; } + public DateTime? CreatedAt { get; set; } + public string? BenchmarkNames { get; set; } +} diff --git a/api/src/Models/ResponseTypes/StudentResponse.cs b/api/src/Models/ResponseTypes/StudentResponse.cs index bfa5508..19ba26f 100644 --- a/api/src/Models/ResponseTypes/StudentResponse.cs +++ b/api/src/Models/ResponseTypes/StudentResponse.cs @@ -5,6 +5,7 @@ public class StudentResponse public Guid StudentId { get; set; } public string? Identifier { get; set; } public DateTime? NextIepDate { get; set; } + public DateTime? FirstEntryDate { get; set; } public DateTime? LastEntryDate { get; set; } public int GoalCount { get; set; } public int ProgressEventCount { get; set; } diff --git a/api/src/Services/ProgressReportBuilder.cs b/api/src/Services/ProgressReportBuilder.cs new file mode 100644 index 0000000..41a007d --- /dev/null +++ b/api/src/Services/ProgressReportBuilder.cs @@ -0,0 +1,65 @@ +using WinStudentGoalTracker.Models; + +namespace WinStudentGoalTracker.Services; + +public static class ProgressReportBuilder +{ + // ***************************************************************** + // Builds a markdown document from a StudentProgressReportResponse. + // Returns the complete markdown string ready for download. + // ***************************************************************** + public static string BuildMarkdown(StudentProgressReportResponse report, DateTime fromDate, DateTime toDate) + { + var lines = new List(); + + var fromDisplay = fromDate.ToString("MMMM d, yyyy"); + var toDisplay = toDate.ToString("MMMM d, yyyy"); + + lines.Add("# Student Progress Report"); + lines.Add(""); + lines.Add($"**Student:** {report.StudentIdentifier}"); + lines.Add($"**Report Period:** {fromDisplay} – {toDisplay}"); + lines.Add(""); + lines.Add("---"); + + if (report.Goals.Count == 0) + { + lines.Add(""); + lines.Add("*No progress events found in the selected date range.*"); + return string.Join("\n", lines); + } + + var goalIndex = 0; + foreach (var goal in report.Goals) + { + goalIndex++; + lines.Add(""); + lines.Add($"## {goalIndex}. {goal.Category}"); + if (!string.IsNullOrWhiteSpace(goal.Description)) + { + lines.Add(""); + lines.Add(goal.Description); + } + lines.Add(""); + + foreach (var ev in goal.ProgressEvents) + { + var eventDate = ev.CreatedAt?.ToString("MMMM d, yyyy") ?? "Unknown date"; + lines.Add($"### {eventDate}"); + lines.Add(""); + lines.Add(ev.Content ?? ""); + + if (!string.IsNullOrWhiteSpace(ev.BenchmarkNames)) + { + lines.Add(""); + lines.Add($"**Benchmarks:** {ev.BenchmarkNames}"); + } + lines.Add(""); + } + + lines.Add("---"); + } + + return string.Join("\n", lines); + } +} diff --git a/db/Objects/procedures/sp_ProgressReport_GetByStudentId.sql b/db/Objects/procedures/sp_ProgressReport_GetByStudentId.sql new file mode 100644 index 0000000..cf060d9 --- /dev/null +++ b/db/Objects/procedures/sp_ProgressReport_GetByStudentId.sql @@ -0,0 +1,47 @@ +DELIMITER ;; +CREATE DEFINER=`root`@`%` PROCEDURE `sp_ProgressReport_GetByStudentId`( + IN p_id_student CHAR(36), + IN p_from_date DATE, + IN p_to_date DATE, + IN p_goal_ids TEXT +) +BEGIN + -- Result set 1: Goals that have at least one progress event in the date range + SELECT + g.`id_goal` AS `goalId`, + g.`category` AS `category`, + g.`description` AS `description` + FROM `goal` g + WHERE g.`id_student` = p_id_student + AND (p_goal_ids IS NULL OR FIND_IN_SET(g.`id_goal`, p_goal_ids)) + AND EXISTS ( + SELECT 1 FROM `progress_event` pe + WHERE pe.`id_goal` = g.`id_goal` + AND DATE(pe.`created_at`) >= p_from_date + AND DATE(pe.`created_at`) <= p_to_date + ) + ORDER BY g.`category`; + + -- Result set 2: Progress events within the date range, with benchmark names + SELECT + pe.`id_goal` AS `goalId`, + pe.`id_progress_event` AS `progressEventId`, + pe.`content` AS `content`, + pe.`created_at` AS `createdAt`, + GROUP_CONCAT( + COALESCE(b.`short_name`, b.`benchmark`) + ORDER BY b.`short_name`, b.`benchmark` + SEPARATOR ', ' + ) AS `benchmarkNames` + FROM `progress_event` pe + INNER JOIN `goal` g ON g.`id_goal` = pe.`id_goal` + LEFT JOIN `progress_event_benchmark` peb ON peb.`id_progress_event` = pe.`id_progress_event` + LEFT JOIN `benchmark` b ON b.`id_benchmark` = peb.`id_benchmark` + WHERE g.`id_student` = p_id_student + AND (p_goal_ids IS NULL OR FIND_IN_SET(g.`id_goal`, p_goal_ids)) + AND DATE(pe.`created_at`) >= p_from_date + AND DATE(pe.`created_at`) <= p_to_date + GROUP BY pe.`id_progress_event`, pe.`id_goal`, pe.`content`, pe.`created_at` + ORDER BY pe.`created_at` ASC; +END;; +DELIMITER ; diff --git a/db/Objects/procedures/sp_Student_GetWithAssignments.sql b/db/Objects/procedures/sp_Student_GetWithAssignments.sql index f65adbc..9d0f211 100644 --- a/db/Objects/procedures/sp_Student_GetWithAssignments.sql +++ b/db/Objects/procedures/sp_Student_GetWithAssignments.sql @@ -8,6 +8,7 @@ BEGIN vc.studentId, vc.identifier, vc.nextIepDate, + vc.firstEntryDate, vc.lastEntryDate, vc.goalCount, vc.progressEventCount, diff --git a/db/Objects/views/v_student_card.sql b/db/Objects/views/v_student_card.sql index be5cd81..1a672e2 100644 --- a/db/Objects/views/v_student_card.sql +++ b/db/Objects/views/v_student_card.sql @@ -1,5 +1,5 @@ CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`%` SQL SECURITY DEFINER VIEW `winstudentgoaltracker`.`v_student_card` AS -select `s`.`id_student` AS `studentId`,`s`.`identifier` AS `identifier`,`s`.`next_iep_date` AS `nextIepDate`,max(`pe`.`created_at`) AS `lastEntryDate`,count(distinct `g`.`id_goal`) AS `goalCount`,count(distinct `pe`.`id_progress_event`) AS `progressEventCount`,count(distinct `b`.`id_benchmark`) AS `benchmarkCount` +select `s`.`id_student` AS `studentId`,`s`.`identifier` AS `identifier`,`s`.`next_iep_date` AS `nextIepDate`,min(`pe`.`created_at`) AS `firstEntryDate`,max(`pe`.`created_at`) AS `lastEntryDate`,count(distinct `g`.`id_goal`) AS `goalCount`,count(distinct `pe`.`id_progress_event`) AS `progressEventCount`,count(distinct `b`.`id_benchmark`) AS `benchmarkCount` from (((`winstudentgoaltracker`.`student` `s` left join `winstudentgoaltracker`.`goal` `g` on((`g`.`id_student` = `s`.`id_student`))) diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/reports/reports.html b/ui/winstudentgoaltracker/src/app/desktop/components/reports/reports.html new file mode 100644 index 0000000..f069b5d --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/reports/reports.html @@ -0,0 +1,10 @@ +
+

Reports

+
+ +
+
+

Student Progress Report

+

Extract periodic student progress data for external reporting.

+
+
diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/reports/reports.scss b/ui/winstudentgoaltracker/src/app/desktop/components/reports/reports.scss new file mode 100644 index 0000000..66ff1ae --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/reports/reports.scss @@ -0,0 +1,51 @@ +:host { + display: flex; + flex-direction: column; + height: 100%; +} + +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + height: 40px; + padding-right: 0.5rem; + border-radius: 8px; + background: #fff; + border-bottom: 1px solid #ddd; + margin-bottom: 1rem; + flex-shrink: 0; +} + +.page-title { + font-size: 24px; + font-weight: 600; + margin-left: 0.75rem; +} + +.card-grid { + display: flex; + flex-wrap: wrap; + align-content: flex-start; + gap: 1rem; +} + +.card { + background: #fff; + border-radius: 8px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); + padding: 1.5rem; + cursor: pointer; + transition: box-shadow 0.15s ease, transform 0.15s ease; + + &:hover { + box-shadow: 0 4px 20px rgba(79, 70, 229, 0.15); + transform: translateY(-2px); + } + + h2 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + } +} diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/reports/reports.spec.ts b/ui/winstudentgoaltracker/src/app/desktop/components/reports/reports.spec.ts new file mode 100644 index 0000000..9cbf05a --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/reports/reports.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Reports } from './reports'; + +describe('Reports', () => { + let component: Reports; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Reports] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Reports); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/reports/reports.ts b/ui/winstudentgoaltracker/src/app/desktop/components/reports/reports.ts new file mode 100644 index 0000000..57432d9 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/reports/reports.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +@Component({ + selector: 'app-reports', + imports: [RouterLink], + templateUrl: './reports.html', + styleUrl: './reports.scss', +}) +export class Reports { + +} diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/student-progress-report/student-progress-report.html b/ui/winstudentgoaltracker/src/app/desktop/components/student-progress-report/student-progress-report.html new file mode 100644 index 0000000..9f0deea --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/student-progress-report/student-progress-report.html @@ -0,0 +1,57 @@ +
+ + Student Progress Report + +
+ +
+
+ Report Parameters +
+ +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + @if (goalItems().length > 0) { +
+ +
+ @for (goal of goalItems(); track goal.goalId) { + + } +
+
+ } + +
+ +
+
+
diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/student-progress-report/student-progress-report.scss b/ui/winstudentgoaltracker/src/app/desktop/components/student-progress-report/student-progress-report.scss new file mode 100644 index 0000000..0e59c0b --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/student-progress-report/student-progress-report.scss @@ -0,0 +1,164 @@ +: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; +} + +.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; +} + +.detail-card { + background: #fff; + border: 1px solid #ddd; + border-radius: 8px; + max-width: 600px; + overflow: hidden; +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.625rem 1.25rem; + background: #f8f9fa; + border-bottom: 1px solid #ddd; +} + +.card-title { + font-size: 0.875rem; + font-weight: 600; + color: #333; +} + +.card-body { + padding: 1.5rem; +} + +.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-family: inherit; + font-size: 0.9375rem; + outline: none; +} + +.field-input:focus { + border-color: #4f46e5; +} + +.date-row { + display: flex; + gap: 1.5rem; + + .field { + flex: 1; + } +} + +.goal-checklist { + border: 1px solid #ccc; + border-radius: 6px; + overflow: hidden; +} + +.goal-check-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + cursor: pointer; + font-size: 0.9375rem; + + &:hover { + background: #f5f5f5; + } + + & + & { + border-top: 1px solid #eee; + } + + input[type="checkbox"] { + pointer-events: none; + } +} + +.actions { + display: flex; + justify-content: flex-end; + margin-top: 0.5rem; +} + +.run-btn { + background: #4f46e5; + color: #fff; + border-color: #4f46e5; + min-width: 6rem; +} + +.run-btn:hover { + background: #4338ca; +} + +.toolbar-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/student-progress-report/student-progress-report.spec.ts b/ui/winstudentgoaltracker/src/app/desktop/components/student-progress-report/student-progress-report.spec.ts new file mode 100644 index 0000000..9fd0055 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/student-progress-report/student-progress-report.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { StudentProgressReport } from './student-progress-report'; + +describe('StudentProgressReport', () => { + let component: StudentProgressReport; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [StudentProgressReport] + }) + .compileComponents(); + + fixture = TestBed.createComponent(StudentProgressReport); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/student-progress-report/student-progress-report.ts b/ui/winstudentgoaltracker/src/app/desktop/components/student-progress-report/student-progress-report.ts new file mode 100644 index 0000000..6b09360 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/student-progress-report/student-progress-report.ts @@ -0,0 +1,159 @@ +import { Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { StudentService } from '../../../shared/services/student.service'; +import { StudentCardDto } from '../../../shared/classes/student-card.dto'; +import { StudentGoalItem } from '../../../shared/classes/student-goal'; + +interface GoalCheckItem { + goalId: string; + category: string; + checked: boolean; +} + +@Component({ + selector: 'app-student-progress-report', + imports: [FormsModule], + templateUrl: './student-progress-report.html', + styleUrl: './student-progress-report.scss', +}) +export class StudentProgressReport { + + // ************************** Constructor ************************** + + constructor() { + this.loadStudents(); + } + + // ************************** Declarations ************************* + + private readonly router = inject(Router); + private readonly studentService = inject(StudentService); + protected readonly students = signal([]); + protected readonly goalItems = signal([]); + protected readonly running = signal(false); + protected selectedStudentId = ''; + protected fromDate = ''; + protected toDate = ''; + + // ************************** Properties *************************** + + // ************************ Public Methods ************************* + + // ************************ Event Handlers ************************* + + onBack() { + this.router.navigate(['/reports']); + } + + // ***************************************************************** + // Handles student dropdown changes. Reads firstEntryDate and + // lastEntryDate from student data, and loads goals for the + // checklist with all items checked by default. + // ***************************************************************** + async onStudentChange() { + this.fromDate = ''; + this.toDate = ''; + this.goalItems.set([]); + + if (!this.selectedStudentId) return; + + const student = this.students().find(s => s.studentId === this.selectedStudentId); + if (!student) return; + + if (student.firstEntryDate) { + this.fromDate = this.toIsoDate(new Date(student.firstEntryDate)); + } + if (student.lastEntryDate) { + this.toDate = this.toIsoDate(new Date(student.lastEntryDate)); + } + + const goalsResult = await this.studentService.getGoalsForStudent(this.selectedStudentId); + if (goalsResult.success && goalsResult.payload) { + this.goalItems.set(goalsResult.payload.goals.map(g => ({ + goalId: g.goalId, + category: g.category ?? '', + checked: true, + }))); + } + } + + // ***************************************************************** + // Toggles a goal checkbox on or off. + // ***************************************************************** + onToggleGoal(goalId: string) { + this.goalItems.update(items => + items.map(g => g.goalId === goalId ? { ...g, checked: !g.checked } : g) + ); + } + + // ***************************************************************** + // Calls the API to generate the markdown report, passing only + // the checked goal IDs, and triggers a browser download. + // ***************************************************************** + async onRun() { + this.running.set(true); + try { + const checkedGoalIds = this.goalItems() + .filter(g => g.checked) + .map(g => g.goalId) + .join(','); + + const result = await this.studentService.getStudentProgressReport( + this.selectedStudentId, this.fromDate, this.toDate, checkedGoalIds || undefined + ); + + if (result.success && result.payload) { + this.downloadMarkdown(result.payload); + } + } finally { + this.running.set(false); + } + } + + // ********************** Support Procedures *********************** + + // ***************************************************************** + // Loads the list of students for the dropdown selector. + // ***************************************************************** + 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.students.set(sorted); + } + }); + } + + // ***************************************************************** + // Triggers a browser download of the given markdown content. + // ***************************************************************** + private downloadMarkdown(content: string) { + const student = this.students().find(s => s.studentId === this.selectedStudentId); + const name = student ? student.identifier.replace(/\s+/g, '_') : 'report'; + const filename = `${name}_progress_report_${this.fromDate}_to_${this.toDate}.md`; + + const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + // ***************************************************************** + // Formats a Date as an ISO date string (yyyy-MM-dd) for use with + // the native HTML date input. + // ***************************************************************** + private toIsoDate(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; + } +} diff --git a/ui/winstudentgoaltracker/src/app/desktop/desktop.routes.ts b/ui/winstudentgoaltracker/src/app/desktop/desktop.routes.ts index 45c5add..644b4e7 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/desktop.routes.ts +++ b/ui/winstudentgoaltracker/src/app/desktop/desktop.routes.ts @@ -8,6 +8,8 @@ import { ProgressList } from './components/progress-list/progress-list'; import { BenchmarkList } from './components/benchmark-list/benchmark-list'; import { BenchmarkCardFull } from './components/benchmark-card-full/benchmark-card-full'; import { ProgressEdit } from './components/progress-edit/progress-edit'; +import { Reports } from './components/reports/reports'; +import { StudentProgressReport } from './components/student-progress-report/student-progress-report'; export default [ { @@ -26,6 +28,8 @@ export default [ { path: 'students/:studentId/goals/:goalId/benchmarks/new', component: BenchmarkCardFull }, { path: 'students/:studentId/goals/:goalId/benchmarks/:benchmarkId', component: BenchmarkCardFull }, { path: 'students/:studentId/benchmarks', component: BenchmarkList }, + { path: 'reports', component: Reports }, + { path: 'reports/student-progress', component: StudentProgressReport }, ], }, ] satisfies Routes; diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.ts b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.ts index 129d137..c746988 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.ts +++ b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.ts @@ -132,6 +132,10 @@ export class Home implements OnDestroy { loadChildren: () => this.loadGoalNodes(s.studentId), }] : undefined, })), + }, + { + label: 'Reports', + routerLink: ['/reports'], }]; } diff --git a/ui/winstudentgoaltracker/src/app/shared/classes/student-card.dto.ts b/ui/winstudentgoaltracker/src/app/shared/classes/student-card.dto.ts index 6715c16..aece70b 100644 --- a/ui/winstudentgoaltracker/src/app/shared/classes/student-card.dto.ts +++ b/ui/winstudentgoaltracker/src/app/shared/classes/student-card.dto.ts @@ -2,6 +2,7 @@ export interface StudentCardDto { studentId: string; identifier: string; nextIepDate: Date; + firstEntryDate: Date | null; lastEntryDate: Date | null; goalCount: number; progressEventCount: number; diff --git a/ui/winstudentgoaltracker/src/app/shared/classes/student-progress-report.dto.ts b/ui/winstudentgoaltracker/src/app/shared/classes/student-progress-report.dto.ts new file mode 100644 index 0000000..4f16ecf --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/shared/classes/student-progress-report.dto.ts @@ -0,0 +1,18 @@ +export interface StudentProgressReportDto { + studentIdentifier: string; + goals: ProgressReportGoalDto[]; +} + +export interface ProgressReportGoalDto { + goalId: string; + category: string; + description: string; + progressEvents: ProgressReportEventDto[]; +} + +export interface ProgressReportEventDto { + progressEventId: string; + content: string; + createdAt: Date; + benchmarkNames: string | null; +} diff --git a/ui/winstudentgoaltracker/src/app/shared/services/dummy-student.service.ts b/ui/winstudentgoaltracker/src/app/shared/services/dummy-student.service.ts index 2123ade..23b7995 100644 --- a/ui/winstudentgoaltracker/src/app/shared/services/dummy-student.service.ts +++ b/ui/winstudentgoaltracker/src/app/shared/services/dummy-student.service.ts @@ -72,6 +72,7 @@ export class DummyStudentService { studentId: '1', identifier: 'J.B', nextIepDate: new Date('2027-02-27'), + firstEntryDate: new Date('2026-01-05'), lastEntryDate: new Date('2026-02-21'), goalCount: 3, progressEventCount: 5, @@ -81,6 +82,7 @@ export class DummyStudentService { studentId: '2', identifier: 'M.K', nextIepDate: new Date('2027-02-27'), + firstEntryDate: new Date('2026-01-10'), lastEntryDate: new Date('2026-02-25'), goalCount: 4, progressEventCount: 8, @@ -90,6 +92,7 @@ export class DummyStudentService { studentId: '3', identifier: 'A.R', nextIepDate: new Date('2027-02-27'), + firstEntryDate: null, lastEntryDate: null, goalCount: 2, progressEventCount: 0, diff --git a/ui/winstudentgoaltracker/src/app/shared/services/student.service.ts b/ui/winstudentgoaltracker/src/app/shared/services/student.service.ts index a9d264a..b524d74 100644 --- a/ui/winstudentgoaltracker/src/app/shared/services/student.service.ts +++ b/ui/winstudentgoaltracker/src/app/shared/services/student.service.ts @@ -11,6 +11,7 @@ import { StudentCardDto } from '../classes/student-card.dto'; import { StudentGoalSummary, StudentGoalItem } from '../classes/student-goal'; import { ProgressEventDto } from '../classes/progress-event.dto'; import { StudentBenchmarkSummary } from '../classes/benchmark.dto'; +import { StudentProgressReportDto } from '../classes/student-progress-report.dto'; @Injectable({ providedIn: 'root', @@ -179,6 +180,29 @@ export class StudentService { } } + // ***************************************************************** + // Returns a full progress report for a student within a date + // range, including goals, events, and benchmark associations. + // ***************************************************************** + async getStudentProgressReport(studentId: string, fromDate: string, toDate: string, goalIds?: string): Promise> { + try { + const params: any = { fromDate, toDate }; + if (goalIds) params.goalIds = goalIds; + + const result = await firstValueFrom( + this.http.get>( + `${this.base}/api/Student/${studentId}/progress-report`, + { params } + ) + ); + return result.success && result.data + ? ApiResult.ok(result.data) + : ApiResult.fail(result.message); + } catch (error) { + return ApiResult.fail(describeHttpError(error as HttpErrorResponse)); + } + } + // ************************ Event Handlers ************************* // ********************** Support Procedures ***********************