Initial report

This commit is contained in:
ivan-pelly
2026-03-29 18:49:13 -07:00
parent 637c59d95d
commit bd360b42ff
24 changed files with 803 additions and 1 deletions
+44
View File
@@ -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<string>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResponseResult<string>), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ResponseResult<string>>> 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<string>
{
Success = false,
Message = "Student not found."
});
}
var report = await _studentRepository.GetProgressReportAsync(idStudent, fromDate, toDate, goalIds);
if (report is null)
{
return NotFound(new ResponseResult<string>
{
Success = false,
Message = "Student not found."
});
}
var markdown = ProgressReportBuilder.BuildMarkdown(report, fromDate, toDate);
return Ok(new ResponseResult<string>
{
Success = true,
Message = "Progress report generated successfully.",
Data = markdown
});
}
}
@@ -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; }
}
@@ -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; }
}
@@ -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<StudentProgressReportResponse?> 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<dbProgressReportGoalRow>()).ToList();
var eventRows = (await multi.ReadAsync<dbProgressReportRow>()).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()
};
}
}
@@ -0,0 +1,23 @@
namespace WinStudentGoalTracker.Models;
public class StudentProgressReportResponse
{
public string? StudentIdentifier { get; set; }
public List<ProgressReportGoal> Goals { get; set; } = [];
}
public class ProgressReportGoal
{
public Guid GoalId { get; set; }
public string? Category { get; set; }
public string? Description { get; set; }
public List<ProgressReportEvent> 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; }
}
@@ -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; }
+65
View File
@@ -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<string>();
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);
}
}
@@ -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 ;
@@ -8,6 +8,7 @@ BEGIN
vc.studentId,
vc.identifier,
vc.nextIepDate,
vc.firstEntryDate,
vc.lastEntryDate,
vc.goalCount,
vc.progressEventCount,
+1 -1
View File
@@ -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`)))
@@ -0,0 +1,10 @@
<div class="toolbar">
<h1 class="page-title">Reports</h1>
</div>
<div class="card-grid">
<div class="card" [routerLink]="['/reports/student-progress']">
<h2>Student Progress Report</h2>
<p>Extract periodic student progress data for external reporting.</p>
</div>
</div>
@@ -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;
}
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Reports } from './reports';
describe('Reports', () => {
let component: Reports;
let fixture: ComponentFixture<Reports>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Reports]
})
.compileComponents();
fixture = TestBed.createComponent(Reports);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -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 {
}
@@ -0,0 +1,57 @@
<div class="toolbar">
<button class="toolbar-btn back-btn" (click)="onBack()">&#8593; Reports</button>
<span class="toolbar-title">Student Progress Report</span>
<span class="spacer"></span>
</div>
<div class="detail-card">
<div class="card-header">
<span class="card-title">Report Parameters</span>
</div>
<div class="card-body">
<div class="field">
<label class="field-label" for="student">Student</label>
<select id="student" class="field-input" [(ngModel)]="selectedStudentId" (ngModelChange)="onStudentChange()">
<option value="">-- Select a student --</option>
@for (student of students(); track student.studentId) {
<option [value]="student.studentId">{{ student.identifier }}</option>
}
</select>
</div>
<div class="date-row">
<div class="field">
<label class="field-label" for="fromDate">From Date</label>
<input id="fromDate" class="field-input" type="date" [(ngModel)]="fromDate" />
</div>
<div class="field">
<label class="field-label" for="toDate">To Date</label>
<input id="toDate" class="field-input" type="date" [(ngModel)]="toDate" />
</div>
</div>
@if (goalItems().length > 0) {
<div class="field">
<label class="field-label">Goals to Include</label>
<div class="goal-checklist">
@for (goal of goalItems(); track goal.goalId) {
<label class="goal-check-item" (click)="onToggleGoal(goal.goalId); $event.preventDefault()">
<input type="checkbox" [checked]="goal.checked" tabindex="-1" />
<span>{{ goal.category }}</span>
</label>
}
</div>
</div>
}
<div class="actions">
<button
class="toolbar-btn run-btn"
(click)="onRun()"
[disabled]="!selectedStudentId || !fromDate || !toDate || running()">
{{ running() ? 'Generating...' : 'Run' }}
</button>
</div>
</div>
</div>
@@ -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;
}
@@ -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<StudentProgressReport>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [StudentProgressReport]
})
.compileComponents();
fixture = TestBed.createComponent(StudentProgressReport);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -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<StudentCardDto[]>([]);
protected readonly goalItems = signal<GoalCheckItem[]>([]);
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}`;
}
}
@@ -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;
@@ -132,6 +132,10 @@ export class Home implements OnDestroy {
loadChildren: () => this.loadGoalNodes(s.studentId),
}] : undefined,
})),
},
{
label: 'Reports',
routerLink: ['/reports'],
}];
}
@@ -2,6 +2,7 @@ export interface StudentCardDto {
studentId: string;
identifier: string;
nextIepDate: Date;
firstEntryDate: Date | null;
lastEntryDate: Date | null;
goalCount: number;
progressEventCount: number;
@@ -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;
}
@@ -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,
@@ -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<ApiResult<string>> {
try {
const params: any = { fromDate, toDate };
if (goalIds) params.goalIds = goalIds;
const result = await firstValueFrom(
this.http.get<ResponseResult<string>>(
`${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 ***********************