This commit is contained in:
ivan-pelly
2026-04-19 14:09:30 -07:00
parent 09673ab53c
commit cd204e4d10
33 changed files with 1521 additions and 11 deletions
@@ -1,11 +1,13 @@
import { inject } from '@angular/core';
import { Routes } from '@angular/router';
import { Login } from './shared/pages/login/login';
import { Register } from './shared/pages/register/register';
import { PlatformService } from './shared/services/platform.service';
import { authGuard } from './shared/guards/auth.guard';
export const routes: Routes = [
{ path: 'login', component: Login },
{ path: 'register', component: Register },
{
path: '',
canMatch: [() => inject(PlatformService).formFactor() === 'mobile'],
@@ -19,6 +19,29 @@
<input class="field-input" type="date" [(ngModel)]="form.targetCompletionDate" />
</div>
@if (isEditMode) {
<hr class="close-divider" />
<div class="field">
<label class="field-label">Close Date</label>
<input class="field-input" type="date" [(ngModel)]="closeDate" />
</div>
@if (closeDate) {
<div class="field">
<label class="field-check">
<input type="checkbox" [(ngModel)]="achieved" />
Goal achieved
</label>
</div>
@if (!achieved) {
<div class="field">
<label class="field-label">Close Notes</label>
<textarea class="field-input field-textarea" [(ngModel)]="closeNotes"
placeholder="Reason the goal was closed without being achieved..."></textarea>
</div>
}
}
}
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}
@@ -1 +1,24 @@
/* Inherits all styles from modal-shell via ::ng-deep */
:host ::ng-deep {
.close-divider {
border: none;
border-top: 1px solid var(--border-color);
margin: 6px 0 14px;
}
.field-check {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #333;
cursor: pointer;
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
}
}
@@ -43,6 +43,11 @@ export class GoalModal {
targetCompletionDate: null,
};
// Close-goal fields — only used in edit mode.
protected closeDate: string | null = null;
protected achieved = false;
protected closeNotes = '';
protected get isEditMode(): boolean {
return !!this.goal();
}
@@ -66,6 +71,11 @@ export class GoalModal {
this.form.targetCompletionDate = existing.targetCompletionDate
? existing.targetCompletionDate.substring(0, 10)
: null;
this.closeDate = existing.closeDate
? existing.closeDate.substring(0, 10)
: null;
this.achieved = existing.achieved ?? false;
this.closeNotes = existing.closeNotes ?? '';
} else {
// Add mode — pre-fill target date from IEP if available
const iepDate = this.nextIepDate?.();
@@ -89,6 +99,9 @@ export class GoalModal {
description: this.form.description,
baseline: this.form.baseline,
targetCompletionDate: this.form.targetCompletionDate,
closeDate: this.closeDate || null,
achieved: this.closeDate ? this.achieved : null,
closeNotes: this.closeDate && !this.achieved ? this.closeNotes || null : null,
},
);
this.isSubmitting.set(false);
@@ -39,6 +39,7 @@
[message]="'Delete \u0022' + (deletingBenchmark()!.shortName || deletingBenchmark()!.benchmark) + '\u0022? This cannot be undone.'"
confirmLabel="Delete"
[destructive]="true"
[doubleConfirm]="true"
(confirmed)="onDeleteBenchmarkConfirmed()"
(closed)="showDeleteBenchmarkConfirm.set(false)" />
}
@@ -80,6 +81,7 @@
<button class="goal-tab add-goal" (click)="onAddGoal()">+ Goal</button>
@for (g of goals(); track g.goalId) {
<button class="goal-tab" [class.active]="selectedGoalId() === g.goalId || (selectedGoal()?.goalId === g.goalId)"
[class.closed]="!!g.closeDate"
(click)="onSelectGoal(g.goalId)">
{{ g.category }}
</button>
@@ -98,6 +100,11 @@
@if (selectedGoal()!.targetCompletionDate) {
<span class="goal-due">Due {{ formatDate(selectedGoal()!.targetCompletionDate) }}</span>
}
@if (selectedGoal()!.closeDate) {
<span class="goal-status" [class.achieved]="selectedGoal()!.achieved">
{{ selectedGoal()!.achieved ? '✓ Achieved' : 'Closed' }}
</span>
}
<button class="delete-goal-btn" (click)="onDeleteGoal()" aria-label="Delete goal" title="Delete goal">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
@@ -137,6 +137,28 @@
margin-left: auto;
}
.goal-status {
font-size: 11px;
font-weight: 600;
padding: 3px 8px;
border-radius: var(--radius-sm);
background: #FEF3C7;
color: #92400E;
}
.goal-status.achieved {
background: #D1FAE5;
color: #065F46;
}
.goal-tab.closed {
opacity: 0.55;
}
.goal-tab.closed.active {
opacity: 1;
}
.delete-goal-btn {
display: flex;
align-items: center;
@@ -3,6 +3,7 @@ import { Home } from './pages/home/home';
import { Workspace } from './components/workspace/workspace';
import { Reports } from './components/reports/reports';
import { StudentProgressReport } from './components/student-progress-report/student-progress-report';
import { Admin } from './pages/admin/admin';
export default [
{
path: '',
@@ -14,6 +15,7 @@ export default [
{ path: 'students/:studentId/goals/:goalId', component: Workspace },
{ path: 'reports', component: Reports },
{ path: 'reports/student-progress', component: StudentProgressReport },
{ path: 'admin', component: Admin },
],
},
] satisfies Routes;
@@ -0,0 +1,138 @@
<div class="admin-page">
<div class="admin-header">
<h1>Administration</h1>
</div>
@if (error()) {
<p class="error">{{ error() }}</p>
}
<div class="tabs">
<button class="tab" [class.active]="activeTab() === 'programs'" (click)="onSwitchTab('programs')">Programs</button>
<button class="tab" [class.active]="activeTab() === 'users'" (click)="onSwitchTab('users')">Users</button>
</div>
<!-- Programs Tab -->
@if (activeTab() === 'programs') {
<div class="tab-content">
<div class="toolbar">
<button class="action-btn" (click)="onAddProgram()">+ Add Program</button>
</div>
@if (programs().length === 0) {
<p class="empty">No programs yet. Create your first program to get started.</p>
} @else {
<div class="list">
@for (program of programs(); track program.programId) {
<div class="list-item">
<div class="list-item-info">
<div class="list-item-name">{{ program.name }}</div>
@if (program.description) {
<div class="list-item-meta">{{ program.description }}</div>
}
</div>
<button class="edit-btn" (click)="onEditProgram(program)">Edit</button>
</div>
}
</div>
}
</div>
}
<!-- Users Tab -->
@if (activeTab() === 'users') {
<div class="tab-content">
<div class="toolbar">
<button class="action-btn" (click)="onAddUser()">+ Add User</button>
</div>
@if (users().length === 0) {
<p class="empty">No users yet.</p>
} @else {
<div class="list">
@for (user of users(); track user.userId + user.programId) {
<div class="list-item">
<div class="list-item-info">
<div class="list-item-name">{{ user.name }}</div>
<div class="list-item-meta">{{ user.email }} · {{ user.roleName }} · {{ user.programName }}</div>
</div>
</div>
}
</div>
}
</div>
}
<!-- Database Section -->
<div class="section-divider"></div>
<h2 class="section-heading">Database</h2>
<div class="backup-row">
<button class="action-btn backup-btn" (click)="onBackupDatabase()" [disabled]="backingUp()">
@if (backingUp()) {
Backing up…
} @else {
Back Up Database
}
</button>
@if (backupSuccess()) {
<span class="backup-success">{{ backupSuccess() }}</span>
}
</div>
</div>
<!-- Program Modal -->
@if (showProgramModal()) {
<app-modal-shell [title]="editingProgram() ? 'Edit Program' : 'New Program'" (closed)="showProgramModal.set(false)">
<form (ngSubmit)="onSaveProgram()" class="modal-form">
<label>
Program Name
<input type="text" [(ngModel)]="programName" name="programName" required />
</label>
<label>
Description
<textarea [(ngModel)]="programDescription" name="programDescription" rows="3"></textarea>
</label>
<button type="submit">{{ editingProgram() ? 'Save' : 'Create' }}</button>
</form>
</app-modal-shell>
}
<!-- User Modal -->
@if (showUserModal()) {
<app-modal-shell title="New User" (closed)="showUserModal.set(false)">
<form (ngSubmit)="onSaveUser()" class="modal-form">
<label>
Name
<input type="text" [(ngModel)]="userName" name="userName" required />
</label>
<label>
Email
<input type="email" [(ngModel)]="userEmail" name="userEmail" required />
</label>
<label>
Password
<input type="password" [(ngModel)]="userPassword" name="userPassword" required />
</label>
<label>
Program
<select [(ngModel)]="userProgramId" name="userProgramId" required>
<option value="" disabled>Select a program</option>
@for (p of programs(); track p.programId) {
<option [value]="p.programId">{{ p.name }}</option>
}
</select>
</label>
<label>
Role
<select [(ngModel)]="userRoleId" name="userRoleId" required>
<option value="" disabled>Select a role</option>
@for (r of roles(); track r.roleId) {
<option [value]="r.roleId">{{ r.name }}</option>
}
</select>
</label>
<button type="submit">Create User</button>
</form>
</app-modal-shell>
}
@@ -0,0 +1,193 @@
.admin-page {
padding: 24px 32px;
max-width: 800px;
}
.admin-header h1 {
font-size: 22px;
font-weight: 600;
margin: 0 0 16px;
}
.error {
background: #fef2f2;
color: #dc2626;
padding: 10px 12px;
border-radius: 6px;
font-size: 14px;
margin-bottom: 12px;
}
.tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
}
.tab {
padding: 8px 18px;
font-size: 14px;
font-weight: 500;
border: none;
background: none;
color: var(--text-faint);
cursor: pointer;
border-radius: 8px;
transition: background 0.15s, color 0.15s;
&.active {
background: #EEF2FF;
color: #4338CA;
}
&:hover:not(.active) {
background: #f5f5f5;
}
}
.toolbar {
margin-bottom: 16px;
}
.action-btn {
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
background: #4f46e5;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #4338ca;
}
}
.empty {
color: var(--text-faint);
font-size: 14px;
padding: 20px 0;
}
.list {
display: flex;
flex-direction: column;
gap: 6px;
}
.list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #fff;
border: 1px solid #e5e5e0;
border-radius: 10px;
}
.list-item-info {
flex: 1;
min-width: 0;
}
.list-item-name {
font-size: 14px;
font-weight: 600;
}
.list-item-meta {
font-size: 12px;
color: var(--text-faint);
margin-top: 2px;
}
.edit-btn {
padding: 4px 12px;
font-size: 13px;
background: none;
border: 1px solid #ddd;
border-radius: 6px;
color: #555;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
&:hover {
background: #f5f5f5;
border-color: #bbb;
}
}
.modal-form {
display: flex;
flex-direction: column;
gap: 12px;
label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 14px;
font-weight: 500;
color: #333;
}
input, textarea, select {
padding: 8px 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
&:focus {
border-color: #4f46e5;
}
}
button[type='submit'] {
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
background: #4f46e5;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
margin-top: 4px;
transition: background 0.15s;
&:hover {
background: #4338ca;
}
}
}
.section-divider {
border-top: 1px solid #e5e5e0;
margin: 28px 0 20px;
}
.section-heading {
font-size: 16px;
font-weight: 600;
margin: 0 0 12px;
}
.backup-row {
display: flex;
align-items: center;
gap: 12px;
}
.backup-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.backup-success {
font-size: 13px;
color: #16a34a;
}
@@ -0,0 +1,141 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AdminService, ProgramDto, DistrictUserDto, RoleDto } from '../../../shared/services/admin.service';
import { ModalShell } from '../../components/modal-shell/modal-shell';
@Component({
selector: 'app-admin',
imports: [FormsModule, ModalShell],
templateUrl: './admin.html',
styleUrl: './admin.scss',
})
export class Admin {
// ************************** Constructor **************************
constructor() {
this.loadAll();
}
// ************************** Declarations *************************
private readonly adminService = inject(AdminService);
protected readonly activeTab = signal<'programs' | 'users'>('programs');
protected readonly programs = signal<ProgramDto[]>([]);
protected readonly users = signal<DistrictUserDto[]>([]);
protected readonly roles = signal<RoleDto[]>([]);
protected readonly error = signal<string | null>(null);
// Program modal
protected readonly showProgramModal = signal(false);
protected readonly editingProgram = signal<ProgramDto | null>(null);
protected programName = '';
protected programDescription = '';
// User modal
protected readonly showUserModal = signal(false);
protected userName = '';
protected userEmail = '';
protected userPassword = '';
protected userProgramId = '';
protected userRoleId = '';
// Backup
protected readonly backingUp = signal(false);
protected readonly backupSuccess = signal<string | null>(null);
// ************************ Event Handlers *************************
onSwitchTab(tab: 'programs' | 'users') {
this.activeTab.set(tab);
}
// --- Programs ---
onAddProgram() {
this.editingProgram.set(null);
this.programName = '';
this.programDescription = '';
this.showProgramModal.set(true);
}
onEditProgram(program: ProgramDto) {
this.editingProgram.set(program);
this.programName = program.name;
this.programDescription = program.description || '';
this.showProgramModal.set(true);
}
async onSaveProgram() {
this.error.set(null);
const editing = this.editingProgram();
if (editing) {
const result = await this.adminService.updateProgram(editing.programId, this.programName, this.programDescription);
if (!result.success) {
this.error.set(result.message);
return;
}
} else {
const result = await this.adminService.createProgram(this.programName, this.programDescription);
if (!result.success) {
this.error.set(result.message);
return;
}
}
this.showProgramModal.set(false);
this.programs.set(await this.adminService.getPrograms());
}
// --- Users ---
onAddUser() {
this.userName = '';
this.userEmail = '';
this.userPassword = '';
this.userProgramId = '';
this.userRoleId = '';
this.showUserModal.set(true);
}
async onSaveUser() {
this.error.set(null);
const result = await this.adminService.createUser(
this.userEmail, this.userName, this.userPassword,
this.userProgramId, this.userRoleId
);
if (!result.success) {
this.error.set(result.message);
return;
}
this.showUserModal.set(false);
this.users.set(await this.adminService.getUsers());
}
// --- Backup ---
async onBackupDatabase() {
this.backingUp.set(true);
this.backupSuccess.set(null);
this.error.set(null);
try {
await this.adminService.backupDatabase();
this.backupSuccess.set('Backup downloaded successfully.');
} catch {
this.error.set('Database backup failed. Please try again.');
} finally {
this.backingUp.set(false);
}
}
// ********************** Support Procedures ***********************
private async loadAll() {
this.programs.set(await this.adminService.getPrograms());
this.users.set(await this.adminService.getUsers());
this.roles.set(await this.adminService.getRoles());
}
}
@@ -54,6 +54,9 @@
<div class="sidebar-nav">
<a class="nav-link" routerLink="/reports">Reports</a>
@if (auth.user()?.role === 'district_admin' || auth.user()?.role === 'super_admin') {
<a class="nav-link" routerLink="/admin">Admin</a>
}
</div>
<div class="sidebar-bottom">
@@ -22,6 +22,8 @@
{{ loading() ? 'Signing in...' : 'Sign in' }}
</button>
</form>
<a class="link-button" routerLink="/register">Register your school district</a>
</div>
}
@@ -1,13 +1,13 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { Router } from '@angular/router';
import { Router, RouterLink } from '@angular/router';
import { Auth } from '../../services/auth';
import { describeHttpError } from '../../classes/http-errors';
@Component({
selector: 'app-login',
imports: [FormsModule],
imports: [FormsModule, RouterLink],
templateUrl: './login.html',
styleUrl: './login.css',
})
@@ -0,0 +1,71 @@
@if (success()) {
<div class="card">
<h2>Registration Complete</h2>
<p class="subtitle">Your district has been created. You can now log in.</p>
<a class="link-button" routerLink="/login">Go to Login</a>
</div>
} @else {
<div class="card">
<h2>Register Your School District</h2>
<p class="subtitle">Create your account and set up your school district.</p>
@if (error()) {
<p class="error">{{ error() }}</p>
}
<form (ngSubmit)="onRegister()">
<fieldset>
<legend>Your Details</legend>
<label>
Name
<input type="text" [(ngModel)]="name" name="name" required />
</label>
<label>
Email
<input type="email" [(ngModel)]="email" name="email" required />
</label>
<label>
Password
<input type="password" [(ngModel)]="password" name="password" required />
</label>
</fieldset>
<fieldset>
<legend>District Details</legend>
<label>
District Name
<input type="text" [(ngModel)]="districtName" name="districtName" required />
</label>
<label>
Contact Email (optional)
<input type="email" [(ngModel)]="districtContactEmail" name="districtContactEmail" />
</label>
</fieldset>
<fieldset>
<legend>Your First Program</legend>
<label>
Program Name
<input type="text" [(ngModel)]="programName" name="programName" required />
</label>
<label>
Description (optional)
<textarea [(ngModel)]="programDescription" name="programDescription" rows="2"></textarea>
</label>
</fieldset>
<button type="submit" [disabled]="loading()">
{{ loading() ? 'Creating...' : 'Create District & Account' }}
</button>
</form>
<a class="link-button" routerLink="/login">Already have an account? Sign in</a>
</div>
}
@@ -0,0 +1,64 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { Api } from '../../services/api';
@Component({
selector: 'app-register',
imports: [FormsModule, RouterLink],
templateUrl: './register.html',
styleUrl: './register.css',
})
export class Register {
// ************************** Constructor **************************
private readonly api = inject(Api);
private readonly router = inject(Router);
// ************************** Declarations *************************
name = '';
email = '';
password = '';
districtName = '';
districtContactEmail = '';
programName = '';
programDescription = '';
// ************************** Properties ***************************
protected readonly loading = signal(false);
protected readonly error = signal<string | null>(null);
protected readonly success = signal(false);
// ************************ Event Handlers *************************
onRegister() {
this.error.set(null);
this.loading.set(true);
this.api.register({
email: this.email,
password: this.password,
name: this.name,
districtName: this.districtName,
districtContactEmail: this.districtContactEmail || undefined,
programName: this.programName,
programDescription: this.programDescription || undefined,
}).subscribe({
next: (res) => {
this.loading.set(false);
if (res.success) {
this.success.set(true);
} else {
this.error.set(res.message);
}
},
error: () => {
this.loading.set(false);
this.error.set('An unexpected error occurred. Please try again.');
},
});
}
}
@@ -53,6 +53,12 @@ export class Api {
);
}
// Self-service registration — creates a new district + program + user
register(request: { email: string; password: string; name: string; districtName: string; districtContactEmail?: string; programName: string; programDescription?: string }): Observable<ResponseResult<object>> {
return this.http.post<ResponseResult<object>>(
`${this.base}/api/Auth/Register`,
request,
);
}
}