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
@@ -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}`;
}
}