mirror of
https://github.com/opelly27/WinStudentGoalTracker.git
synced 2026-05-20 14:37:34 +00:00
Updates to encompass benchmarks
This commit is contained in:
+44
@@ -0,0 +1,44 @@
|
||||
<div class="toolbar">
|
||||
<button class="toolbar-btn back-btn" (click)="onBack()">↑ Benchmarks</button>
|
||||
<span class="toolbar-title">{{ isNew() ? 'New Benchmark' : 'Benchmark Detail' }}</span>
|
||||
<span class="spacer"></span>
|
||||
</div>
|
||||
|
||||
@if (errorMessage()) {
|
||||
<p class="error">{{ errorMessage() }}</p>
|
||||
}
|
||||
|
||||
@if (successMessage()) {
|
||||
<p class="success">{{ successMessage() }}</p>
|
||||
}
|
||||
|
||||
@if (loaded()) {
|
||||
<div class="detail-card">
|
||||
<div class="field">
|
||||
<span class="field-label">Goal</span>
|
||||
<span class="field-value">{{ goalTitle }}</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="benchmarkText">Benchmark</label>
|
||||
<textarea id="benchmarkText" class="field-input field-textarea" [(ngModel)]="benchmarkText" rows="4"
|
||||
placeholder="Enter benchmark text..."></textarea>
|
||||
</div>
|
||||
@if (!isNew()) {
|
||||
<div class="metadata">
|
||||
@if (createdByName) {
|
||||
<span class="meta-item">Created by: {{ createdByName }}</span>
|
||||
}
|
||||
<span class="meta-item">Created: {{ createdAt | date:'medium' }}</span>
|
||||
@if (updatedAt) {
|
||||
<span class="meta-item">Updated: {{ updatedAt | date:'medium' }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="actions">
|
||||
<button class="toolbar-btn" (click)="onCancel()" [disabled]="!hasChanges()">Cancel</button>
|
||||
<button class="toolbar-btn save-btn" (click)="onSave()" [disabled]="!hasChanges() || saving()">
|
||||
{{ saving() ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
+146
@@ -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;
|
||||
}
|
||||
+23
@@ -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<BenchmarkCardFull>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [BenchmarkCardFull]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(BenchmarkCardFull);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+169
@@ -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<string | null>(null);
|
||||
protected readonly successMessage = signal<string | null>(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 ?? '';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="goal-badge">{{ benchmark().goalTitle }}</span>
|
||||
@if (benchmark().updatedAt) {
|
||||
<span class="date">Updated: {{ benchmark().updatedAt | date:'M/d/yy' }}</span>
|
||||
} @else {
|
||||
<span class="date">{{ benchmark().createdAt | date:'M/d/yy' }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="benchmark-text">{{ benchmark().benchmark }}</p>
|
||||
</div>
|
||||
+51
@@ -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;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { BenchmarkCard } from './benchmark-card';
|
||||
|
||||
describe('BenchmarkCard', () => {
|
||||
let component: BenchmarkCard;
|
||||
let fixture: ComponentFixture<BenchmarkCard>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [BenchmarkCard]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(BenchmarkCard);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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<BenchmarkDto>();
|
||||
|
||||
// ************************** Properties ***************************
|
||||
|
||||
// ************************ Public Methods *************************
|
||||
|
||||
// ************************ Event Handlers *************************
|
||||
|
||||
// ********************** Support Procedures ***********************
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<div class="toolbar">
|
||||
<button class="toolbar-btn back-btn" (click)="onBack()">↑ Goal</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="toolbar-btn" (click)="onAddBenchmark()">+ Add a Benchmark</button>
|
||||
</div>
|
||||
|
||||
@if (studentIdentifier()) {
|
||||
<h2 class="section-header">Benchmarks for {{ studentIdentifier() }}</h2>
|
||||
}
|
||||
|
||||
@if (errorMessage()) {
|
||||
<p class="error">{{ errorMessage() }}</p>
|
||||
}
|
||||
|
||||
@if (benchmarks().length === 0 && !errorMessage()) {
|
||||
<p class="empty-state">No benchmarks yet. Click <a class="empty-link" (click)="onAddBenchmark()">Add a Benchmark</a> to
|
||||
get started.</p>
|
||||
} @else {
|
||||
<div class="card-grid">
|
||||
@for (bm of benchmarks(); track bm.benchmarkId) {
|
||||
<app-benchmark-card [benchmark]="bm" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
+75
@@ -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;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { BenchmarkList } from './benchmark-list';
|
||||
|
||||
describe('BenchmarkList', () => {
|
||||
let component: BenchmarkList;
|
||||
let fixture: ComponentFixture<BenchmarkList>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [BenchmarkList]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(BenchmarkList);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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<string | null>(null);
|
||||
protected readonly benchmarks = signal<BenchmarkDto[]>([]);
|
||||
protected readonly errorMessage = signal<string | null>(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 ?? []);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<div class="toolbar">
|
||||
<button class="toolbar-btn back-btn" (click)="onBack()">↑ Student</button>
|
||||
<span class="toolbar-title">Goal Detail</span>
|
||||
<span class="spacer"></span>
|
||||
</div>
|
||||
|
||||
@if (errorMessage()) {
|
||||
<p class="error">{{ errorMessage() }}</p>
|
||||
}
|
||||
|
||||
@if (successMessage()) {
|
||||
<p class="success">{{ successMessage() }}</p>
|
||||
}
|
||||
|
||||
@if (loaded()) {
|
||||
<div class="detail-card">
|
||||
<div class="field">
|
||||
<label class="field-label" for="title">Title</label>
|
||||
<input id="title" class="field-input" type="text" [(ngModel)]="title" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="description">Description</label>
|
||||
<textarea id="description" class="field-input field-textarea" [(ngModel)]="description" rows="4"
|
||||
placeholder="Enter description..."></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="category">Category</label>
|
||||
<input id="category" class="field-input" type="text" [(ngModel)]="category" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="toolbar-btn" (click)="onCancel()" [disabled]="!hasChanges()">Cancel</button>
|
||||
<button class="toolbar-btn save-btn" (click)="onSave()" [disabled]="!hasChanges() || saving()">
|
||||
{{ saving() ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<a class="detail-link" (click)="onProgressEvents()">Progress Events</a>
|
||||
<a class="detail-link" (click)="onBenchmarks()">Benchmarks</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
+149
@@ -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;
|
||||
}
|
||||
+23
@@ -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<GoalCardFull>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [GoalCardFull]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(GoalCardFull);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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<string | null>(null);
|
||||
protected readonly successMessage = signal<string | null>(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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="card clickable" (click)="onCardClick()">
|
||||
<div class="card" (click)="onCardClick()">
|
||||
<div class="card-header">
|
||||
<span class="category-badge">{{ goal().category }}</span>
|
||||
<span class="event-count">{{ goal().progressEventCount }} events</span>
|
||||
@@ -6,4 +6,9 @@
|
||||
|
||||
<h3 class="title">{{ goal().title }}</h3>
|
||||
<p class="description">{{ goal().description }}</p>
|
||||
|
||||
<div class="card-footer">
|
||||
<a class="footer-link" (click)="$event.stopPropagation(); onBenchmarksClick()">Benchmarks</a>
|
||||
<a class="footer-link" (click)="$event.stopPropagation(); onProgressEventsClick()">Progress Events</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<div class="toolbar">
|
||||
<button class="toolbar-btn back-btn" (click)="onBack()">← Students</button>
|
||||
<button class="toolbar-btn back-btn" (click)="onBack()">↑ Student</button>
|
||||
<span class="toolbar-title">Goals</span>
|
||||
<span class="spacer"></span>
|
||||
<button class="generate-report"> ⭐ Generate progress report</button>
|
||||
<button class="toolbar-btn" (click)="onAddGoal()">+ Add a Goal</button>
|
||||
</div>
|
||||
|
||||
<!-- <img class="hero-image" src="/hurdlescropped.png" alt="Hurdles" /> -->
|
||||
|
||||
|
||||
@if (studentIdentifier()) {
|
||||
<h2 class="section-header">Goals for {{ studentIdentifier() }}</h2>
|
||||
<h2 class="section-header">Student: {{ studentIdentifier() }}</h2>
|
||||
}
|
||||
|
||||
@if (showAddModal()) {
|
||||
@@ -26,4 +29,9 @@
|
||||
<app-goal-card [goal]="goal" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<footer class="goal-footer">
|
||||
<span class="spacer"></span>
|
||||
<button class="toolbar-btn"> ⭐ Generate progress report</button>
|
||||
</footer>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
protected readonly goals = signal<StudentGoalItem[]>([]);
|
||||
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 ***********************
|
||||
|
||||
+9
-4
@@ -1,13 +1,18 @@
|
||||
<div class="toolbar">
|
||||
<button class="toolbar-btn back-btn" (click)="onBack()">← Goals</button>
|
||||
<button class="toolbar-btn back-btn" (click)="onBack()">↑ Goal</button>
|
||||
<span class="toolbar-title">Progress Events</span>
|
||||
<span class="spacer"></span>
|
||||
<button class="toolbar-btn" (click)="onAddProgressEvent()">+ Add Progress Event</button>
|
||||
</div>
|
||||
|
||||
<!-- <img class="hero-image" src="/slalomcropped.png" alt="Slalom" /> -->
|
||||
|
||||
@if (studentIdentifier() && goalTitle()) {
|
||||
<div class="header-row">
|
||||
<h2 class="section-header">
|
||||
{{ events().length }} Progress Events for {{ studentIdentifier() }} for goal: {{ goalTitle() }}
|
||||
Student: {{ studentIdentifier() }} Goal: {{ goalTitle() }}
|
||||
@if (isFiltered()) {
|
||||
<span class="filter-count">(showing {{ filteredEvents().length }})</span>
|
||||
<span class="filter-count">(showing {{ filteredEvents().length }} of {{ events().length }})</span>
|
||||
}
|
||||
</h2>
|
||||
<div class="search-box">
|
||||
@@ -25,7 +30,7 @@
|
||||
}
|
||||
|
||||
@if (filteredEvents().length === 0 && !errorMessage()) {
|
||||
<p class="empty-state">No progress events recorded yet.</p>
|
||||
<p class="empty-state">No progress events recorded yet. Click <strong>+ Add Progress Event</strong> to get started.</p>
|
||||
} @else {
|
||||
<div class="event-list">
|
||||
@for (evt of filteredEvents(); track evt.progressEventId) {
|
||||
|
||||
@@ -4,9 +4,19 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
max-width: 50%;
|
||||
max-height: 100px;
|
||||
object-fit: contain;
|
||||
margin-bottom: 1rem;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
gap: 0.75rem;
|
||||
height: 40px;
|
||||
padding-right: 0.5rem;
|
||||
@@ -17,6 +27,19 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: transparent;
|
||||
|
||||
@@ -43,6 +43,7 @@ export class ProgressList implements OnDestroy {
|
||||
protected readonly errorMessage = signal<string | null>(null);
|
||||
protected readonly rawSearchText = signal('');
|
||||
protected readonly searchTerm = signal('');
|
||||
protected readonly showAddModal = signal(false);
|
||||
|
||||
// ************************** Properties ***************************
|
||||
|
||||
@@ -65,11 +66,16 @@ export class ProgressList implements OnDestroy {
|
||||
|
||||
// ************************ Event Handlers *************************
|
||||
|
||||
onAddProgressEvent() {
|
||||
this.showAddModal.set(true);
|
||||
// TODO: Wire up add-progress-event modal component
|
||||
}
|
||||
|
||||
// *****************************************************************
|
||||
// Navigates back to the goals list for this student.
|
||||
// Navigates back to the parent goal detail.
|
||||
// *****************************************************************
|
||||
onBack() {
|
||||
this.router.navigate(['/students', this.studentId, 'goals']);
|
||||
this.router.navigate(['/students', this.studentId, 'goals', this.goalId]);
|
||||
}
|
||||
|
||||
// *****************************************************************
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
@for (node of nodes(); track node.label) {
|
||||
<div class="node-row" [style.padding-left]="indent()">
|
||||
@if (hasToggle(node)) {
|
||||
<span class="toggle-indicator" (click)="onToggle(node, $event)">{{ node.expanded ? '−' : '+' }}</span>
|
||||
}
|
||||
@if (node.routerLink) {
|
||||
<a class="node-label" [routerLink]="node.routerLink" routerLinkActive="active"
|
||||
[routerLinkActiveOptions]="{ exact: true }">{{ node.label }}</a>
|
||||
} @else if (hasToggle(node)) {
|
||||
<span class="node-label clickable" (click)="onToggle(node, $event)">{{ node.label }}</span>
|
||||
} @else {
|
||||
<span class="node-label">{{ node.label }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (node.expanded && node.children) {
|
||||
<app-sidebar-tree-node [nodes]="node.children" [depth]="depth() + 1" />
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.node-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
min-height: 1.75rem;
|
||||
}
|
||||
|
||||
.node-row:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.node-row:has(.node-label.active) {
|
||||
background: #e5e5e5;
|
||||
}
|
||||
|
||||
.toggle-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 2px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
flex: 1;
|
||||
font-size: 0.8125rem;
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.node-label.clickable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.node-label.active {
|
||||
font-weight: 600;
|
||||
color: #4f46e5;
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { SidebarNode } from '../../../shared/classes/sidebar-node';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sidebar-tree-node',
|
||||
imports: [RouterLink, RouterLinkActive, SidebarTreeNode],
|
||||
templateUrl: './sidebar-tree-node.html',
|
||||
styleUrl: './sidebar-tree-node.scss',
|
||||
})
|
||||
export class SidebarTreeNode {
|
||||
|
||||
// ************************** Constructor **************************
|
||||
|
||||
// ************************** Declarations *************************
|
||||
|
||||
readonly nodes = input.required<SidebarNode[]>();
|
||||
readonly depth = input<number>(0);
|
||||
|
||||
// ************************** Properties ***************************
|
||||
|
||||
// *****************************************************************
|
||||
// Computed indentation in rem based on depth.
|
||||
// *****************************************************************
|
||||
indent(): string {
|
||||
return (1 + this.depth() * 1.5) + 'rem';
|
||||
}
|
||||
|
||||
// ************************ Public Methods *************************
|
||||
|
||||
// *****************************************************************
|
||||
// Returns true if a node should show the +/- toggle.
|
||||
// A node is expandable if it has static children, or has a
|
||||
// loadChildren function with a non-zero childCount.
|
||||
// *****************************************************************
|
||||
hasToggle(node: SidebarNode): boolean {
|
||||
if (node.children && node.children.length > 0) return true;
|
||||
if (node.loadChildren && node.childCount !== 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ************************ Event Handlers *************************
|
||||
|
||||
// *****************************************************************
|
||||
// Toggles a node's expanded state. On first expand of a lazy node,
|
||||
// calls loadChildren and caches the result in node.children.
|
||||
// *****************************************************************
|
||||
async onToggle(node: SidebarNode, event: Event) {
|
||||
if (!this.hasToggle(node)) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (node.expanded) {
|
||||
node.expanded = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.loadChildren && !node.children) {
|
||||
node.children = await node.loadChildren();
|
||||
}
|
||||
|
||||
node.expanded = true;
|
||||
}
|
||||
|
||||
// ********************** Support Procedures ***********************
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<div class="toolbar">
|
||||
<button class="toolbar-btn back-btn" (click)="onBack()">↑ Students</button>
|
||||
<span class="toolbar-title">Student Detail</span>
|
||||
<span class="spacer"></span>
|
||||
</div>
|
||||
|
||||
@if (errorMessage()) {
|
||||
<p class="error">{{ errorMessage() }}</p>
|
||||
}
|
||||
|
||||
|
||||
|
||||
@if (loaded()) {
|
||||
<div class="detail-card">
|
||||
<div class="field">
|
||||
<label class="field-label" for="identifier">Name</label>
|
||||
<input id="identifier" class="field-input" type="text" [(ngModel)]="identifier" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="expectedGrad">Expected Graduation</label>
|
||||
<input id="expectedGrad" class="field-input" type="date" [(ngModel)]="expectedGradDate" />
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="toolbar-btn" (click)="onCancel()" [disabled]="!hasChanges()">Cancel</button>
|
||||
<button class="toolbar-btn save-btn" (click)="onSave()" [disabled]="!hasChanges() || saving()">
|
||||
{{ saving() ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<a class="detail-link" (click)="onGoals()">Goals</a>
|
||||
<span class="spacer"></span>
|
||||
@if (successMessage()) {
|
||||
<span class="success-label" [class.fade-out]="fading()">{{ successMessage() }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
: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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.detail-card {
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 1rem -1.5rem -1rem;
|
||||
padding: 0.5rem 1.5rem 0.5rem;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.detail-link {
|
||||
font-size: 0.875rem;
|
||||
color: #4f46e5;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.detail-link:hover {
|
||||
color: #4338ca;
|
||||
}
|
||||
|
||||
.success-label {
|
||||
font-size: 0.8125rem;
|
||||
color: #16a34a;
|
||||
opacity: 1;
|
||||
transition: opacity 1s ease;
|
||||
}
|
||||
|
||||
.success-label.fade-out {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { StudentCardFull } from './student-card-full';
|
||||
|
||||
describe('StudentCardFull', () => {
|
||||
let component: StudentCardFull;
|
||||
let fixture: ComponentFixture<StudentCardFull>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [StudentCardFull]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(StudentCardFull);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
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 { StudentCardDto } from '../../../shared/classes/student-card.dto';
|
||||
|
||||
@Component({
|
||||
selector: 'app-student-card-full',
|
||||
imports: [FormsModule],
|
||||
templateUrl: './student-card-full.html',
|
||||
styleUrl: './student-card-full.scss',
|
||||
})
|
||||
export class StudentCardFull implements OnDestroy {
|
||||
|
||||
// ************************** Constructor **************************
|
||||
|
||||
constructor() {
|
||||
this.paramSub = this.route.paramMap.subscribe(params => {
|
||||
this.studentId = params.get('studentId')!;
|
||||
this.loadStudent();
|
||||
});
|
||||
}
|
||||
|
||||
// ************************** Declarations *************************
|
||||
|
||||
private readonly studentService = inject(StudentService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly paramSub: Subscription;
|
||||
|
||||
private studentId!: string;
|
||||
|
||||
protected readonly errorMessage = signal<string | null>(null);
|
||||
protected readonly successMessage = signal<string | null>(null);
|
||||
protected readonly saving = signal(false);
|
||||
protected readonly loaded = signal(false);
|
||||
protected readonly fading = signal(false);
|
||||
private successTimer: any = null;
|
||||
|
||||
// Form fields — always editable
|
||||
protected identifier = '';
|
||||
protected expectedGradDate = '';
|
||||
|
||||
// Snapshot of last-saved values for cancel
|
||||
private savedIdentifier = '';
|
||||
private savedExpectedGradDate = '';
|
||||
|
||||
// ************************** Properties ***************************
|
||||
|
||||
// *****************************************************************
|
||||
// Returns true if form values differ from the saved snapshot.
|
||||
// *****************************************************************
|
||||
hasChanges(): boolean {
|
||||
return this.identifier !== this.savedIdentifier
|
||||
|| this.expectedGradDate !== this.savedExpectedGradDate;
|
||||
}
|
||||
|
||||
// ************************ Public Methods *************************
|
||||
|
||||
// ************************ Event Handlers *************************
|
||||
|
||||
// *****************************************************************
|
||||
// Saves changes to the student via the API.
|
||||
// *****************************************************************
|
||||
async onSave() {
|
||||
this.saving.set(true);
|
||||
this.errorMessage.set(null);
|
||||
this.successMessage.set(null);
|
||||
|
||||
const result = await this.studentService.updateStudent(this.studentId, {
|
||||
identifier: this.identifier,
|
||||
expectedGrad: this.expectedGradDate || null,
|
||||
});
|
||||
|
||||
this.saving.set(false);
|
||||
|
||||
if (result.success) {
|
||||
this.savedIdentifier = this.identifier;
|
||||
this.savedExpectedGradDate = this.expectedGradDate;
|
||||
this.showSuccessTemporarily('Changes saved.');
|
||||
this.studentService.notifyDataChanged();
|
||||
} else {
|
||||
this.errorMessage.set(result.message);
|
||||
}
|
||||
}
|
||||
|
||||
// *****************************************************************
|
||||
// Reverts form fields to the last-saved snapshot.
|
||||
// *****************************************************************
|
||||
onCancel() {
|
||||
this.identifier = this.savedIdentifier;
|
||||
this.expectedGradDate = this.savedExpectedGradDate;
|
||||
this.errorMessage.set(null);
|
||||
this.successMessage.set(null);
|
||||
}
|
||||
|
||||
onBack() {
|
||||
this.router.navigate(['/students']);
|
||||
}
|
||||
|
||||
onGoals() {
|
||||
this.router.navigate(['/students', this.studentId, 'goals']);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.paramSub.unsubscribe();
|
||||
if (this.successTimer) clearTimeout(this.successTimer);
|
||||
}
|
||||
|
||||
// ********************** Support Procedures ***********************
|
||||
|
||||
// *****************************************************************
|
||||
// Shows a success message for 4 seconds, then fades it out over 1s.
|
||||
// *****************************************************************
|
||||
private showSuccessTemporarily(message: string) {
|
||||
if (this.successTimer) clearTimeout(this.successTimer);
|
||||
this.fading.set(false);
|
||||
this.successMessage.set(message);
|
||||
|
||||
this.successTimer = setTimeout(() => {
|
||||
this.fading.set(true);
|
||||
this.successTimer = setTimeout(() => {
|
||||
this.successMessage.set(null);
|
||||
this.fading.set(false);
|
||||
}, 1000);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// *****************************************************************
|
||||
// Loads the student by ID and populates form fields.
|
||||
// *****************************************************************
|
||||
private loadStudent() {
|
||||
if (!this.loaded()) {
|
||||
this.loaded.set(false);
|
||||
}
|
||||
this.studentService.getStudentById(this.studentId).then(result => {
|
||||
if (result.success && result.payload) {
|
||||
const s = result.payload;
|
||||
this.identifier = s.identifier;
|
||||
this.expectedGradDate = this.toDateInput(s.expectedGradDate);
|
||||
|
||||
this.savedIdentifier = this.identifier;
|
||||
this.savedExpectedGradDate = this.expectedGradDate;
|
||||
this.loaded.set(true);
|
||||
} else {
|
||||
this.errorMessage.set(result.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// *****************************************************************
|
||||
// Converts a Date to a YYYY-MM-DD string for date input binding.
|
||||
// *****************************************************************
|
||||
private toDateInput(date: Date | null): string {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -16,10 +16,11 @@
|
||||
margin-bottom: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
@@ -40,8 +41,8 @@
|
||||
.card-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
gap: 1rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
}
|
||||
+14
-7
@@ -41,7 +41,7 @@ export class StudentCardList {
|
||||
}
|
||||
|
||||
onStudentCreated(student: StudentCardDto) {
|
||||
this.students.update(list => [...list, student]);
|
||||
this.students.update(list => this.sortByIdentifier([...list, student]));
|
||||
this.showAddModal.set(false);
|
||||
}
|
||||
|
||||
@@ -51,19 +51,26 @@ export class StudentCardList {
|
||||
|
||||
// ********************** Support Procedures ***********************
|
||||
|
||||
// *****************************************************************
|
||||
// Sorts an array of students alphabetically by identifier.
|
||||
// *****************************************************************
|
||||
private sortByIdentifier(students: StudentCardDto[]): StudentCardDto[] {
|
||||
return students.sort((a, b) =>
|
||||
a.identifier.localeCompare(b.identifier, undefined, { sensitivity: 'base' })
|
||||
);
|
||||
}
|
||||
|
||||
// *****************************************************************
|
||||
// Loads students from the service and populates the students signal.
|
||||
// *****************************************************************
|
||||
private loadStudents() {
|
||||
this.studentService.getMyStudents().then(data => {
|
||||
|
||||
if(!data.success)
|
||||
{
|
||||
|
||||
if (!data.success) {
|
||||
this.errorMessage.set(data.message);
|
||||
}
|
||||
else
|
||||
{
|
||||
this.students.set(data.payload || [])
|
||||
else {
|
||||
this.students.set(this.sortByIdentifier(data.payload || []))
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="card" [routerLink]="['/students', student().studentId, 'goals']">
|
||||
<div class="card" [routerLink]="['/students', student().studentId]">
|
||||
<h2 class="identifier">🎓 {{ student().identifier }}</h2>
|
||||
|
||||
<div class="meta">
|
||||
|
||||
Reference in New Issue
Block a user