mirror of
https://github.com/opelly27/WinStudentGoalTracker.git
synced 2026-05-20 01:47:41 +00:00
Updates to encompass benchmarks
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
+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">
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { Home } from './pages/home/home';
|
||||
import { StudentCardList } from './components/student-card-list/student-card-list';
|
||||
import { StudentCardFull } from './components/student-card-full/student-card-full';
|
||||
import { GoalList } from './components/goal-list/goal-list';
|
||||
import { GoalCardFull } from './components/goal-card-full/goal-card-full';
|
||||
import { ProgressList } from './components/progress-list/progress-list';
|
||||
import { BenchmarkList } from './components/benchmark-list/benchmark-list';
|
||||
import { BenchmarkCardFull } from './components/benchmark-card-full/benchmark-card-full';
|
||||
|
||||
export default [
|
||||
{
|
||||
@@ -11,8 +15,14 @@ export default [
|
||||
children: [
|
||||
{ path: '', redirectTo: 'students', pathMatch: 'full' },
|
||||
{ path: 'students', component: StudentCardList },
|
||||
{ path: 'students/:studentId', component: StudentCardFull },
|
||||
{ path: 'students/:studentId/goals', component: GoalList },
|
||||
{ path: 'students/:studentId/goals/:goalId', component: GoalCardFull },
|
||||
{ path: 'students/:studentId/goals/:goalId/progress', component: ProgressList },
|
||||
{ path: 'students/:studentId/goals/:goalId/benchmarks', component: BenchmarkList },
|
||||
{ path: 'students/:studentId/goals/:goalId/benchmarks/new', component: BenchmarkCardFull },
|
||||
{ path: 'students/:studentId/goals/:goalId/benchmarks/:benchmarkId', component: BenchmarkCardFull },
|
||||
{ path: 'students/:studentId/benchmarks', component: BenchmarkList },
|
||||
],
|
||||
},
|
||||
] satisfies Routes;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="body">
|
||||
<nav class="sidebar" [class.expanded]="sidebarExpanded()">
|
||||
<a class="nav-item" routerLink="/students">Home</a>
|
||||
<a class="nav-item sub" routerLink="/students" routerLinkActive="active">My Students</a>
|
||||
<app-sidebar-tree-node [nodes]="sidebarTree()" [depth]="0" />
|
||||
</nav>
|
||||
|
||||
<main class="content">
|
||||
|
||||
@@ -79,7 +79,8 @@
|
||||
}
|
||||
|
||||
.sidebar.expanded {
|
||||
width: 220px;
|
||||
width: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
@@ -95,11 +96,6 @@
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.nav-item.sub {
|
||||
padding-left: 2rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
font-weight: 600;
|
||||
color: #4f46e5;
|
||||
|
||||
@@ -1,21 +1,52 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
import { Component, effect, inject, OnDestroy, signal } from '@angular/core';
|
||||
import { NavigationEnd, Router, RouterLink, RouterOutlet } from '@angular/router';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { Auth } from '../../../shared/services/auth';
|
||||
import { StudentService } from '../../../shared/services/student.service';
|
||||
import { StudentCardDto } from '../../../shared/classes/student-card.dto';
|
||||
import { SidebarNode } from '../../../shared/classes/sidebar-node';
|
||||
import { SidebarTreeNode } from '../../components/sidebar-tree-node/sidebar-tree-node';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive],
|
||||
imports: [RouterOutlet, RouterLink, SidebarTreeNode],
|
||||
templateUrl: './home.html',
|
||||
styleUrl: './home.scss',
|
||||
})
|
||||
export class Home {
|
||||
export class Home implements OnDestroy {
|
||||
|
||||
// ************************** Constructor **************************
|
||||
|
||||
constructor() {
|
||||
this.loadStudents();
|
||||
|
||||
// Reload the sidebar tree whenever data changes elsewhere.
|
||||
let initialized = false;
|
||||
effect(() => {
|
||||
this.studentService.dataVersion();
|
||||
if (initialized) {
|
||||
this.loadStudents();
|
||||
}
|
||||
initialized = true;
|
||||
});
|
||||
|
||||
// Auto-expand sidebar nodes to match the current route.
|
||||
this.routeSub = this.router.events.pipe(
|
||||
filter(e => e instanceof NavigationEnd)
|
||||
).subscribe(() => {
|
||||
this.expandToRoute(this.router.url);
|
||||
});
|
||||
}
|
||||
|
||||
// ************************** Declarations *************************
|
||||
|
||||
private readonly auth = inject(Auth);
|
||||
protected readonly sidebarExpanded = signal(false);
|
||||
private readonly router = inject(Router);
|
||||
private readonly studentService = inject(StudentService);
|
||||
private readonly routeSub: Subscription;
|
||||
protected readonly sidebarExpanded = signal(true);
|
||||
protected readonly sidebarTree = signal<SidebarNode[]>([]);
|
||||
|
||||
// ************************** Properties ***************************
|
||||
|
||||
@@ -35,5 +66,132 @@ export class Home {
|
||||
this.auth.forceLogout();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.routeSub.unsubscribe();
|
||||
}
|
||||
|
||||
// ********************** Support Procedures ***********************
|
||||
|
||||
// *****************************************************************
|
||||
// Loads student list, sorts by identifier, and builds the sidebar
|
||||
// tree with lazy-loading callbacks for goals and benchmarks.
|
||||
// *****************************************************************
|
||||
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.sidebarTree.set(this.buildTree(sorted));
|
||||
this.expandToRoute(this.router.url);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// *****************************************************************
|
||||
// Builds the sidebar node tree from a list of students.
|
||||
// *****************************************************************
|
||||
private buildTree(students: StudentCardDto[]): SidebarNode[] {
|
||||
return [{
|
||||
label: 'My Students',
|
||||
routerLink: ['/students'],
|
||||
expanded: true,
|
||||
childCount: students.length,
|
||||
children: students.map(s => ({
|
||||
label: s.identifier,
|
||||
routerLink: ['/students', s.studentId],
|
||||
childCount: s.goalCount > 0 ? 1 : 0,
|
||||
children: s.goalCount > 0 ? [{
|
||||
label: 'Goals',
|
||||
routerLink: ['/students', s.studentId, 'goals'],
|
||||
childCount: s.goalCount,
|
||||
loadChildren: () => this.loadGoalNodes(s.studentId),
|
||||
}] : undefined,
|
||||
})),
|
||||
}];
|
||||
}
|
||||
|
||||
// *****************************************************************
|
||||
// Lazy-loads individual goal nodes for a student. Called when
|
||||
// the "Goals" node is expanded for the first time.
|
||||
// *****************************************************************
|
||||
private async loadGoalNodes(studentId: string): Promise<SidebarNode[]> {
|
||||
const result = await this.studentService.getGoalsForStudent(studentId);
|
||||
if (!result.success || !result.payload) return [];
|
||||
|
||||
return result.payload.goals.map(goal => ({
|
||||
label: goal.title,
|
||||
routerLink: ['/students', studentId, 'goals', goal.goalId],
|
||||
childCount: 2,
|
||||
children: [
|
||||
{
|
||||
label: 'Progress Events',
|
||||
routerLink: ['/students', studentId, 'goals', goal.goalId, 'progress'],
|
||||
childCount: goal.progressEventCount,
|
||||
},
|
||||
{
|
||||
label: 'Benchmarks',
|
||||
routerLink: ['/students', studentId, 'goals', goal.goalId, 'benchmarks'],
|
||||
childCount: goal.benchmarkCount,
|
||||
loadChildren: goal.benchmarkCount > 0
|
||||
? () => this.loadBenchmarkNodes(studentId, goal.goalId)
|
||||
: undefined,
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
// *****************************************************************
|
||||
// Lazy-loads benchmark leaf nodes for a goal. Called when a
|
||||
// "Benchmarks" node is expanded for the first time.
|
||||
// *****************************************************************
|
||||
private async loadBenchmarkNodes(studentId: string, goalId: string): Promise<SidebarNode[]> {
|
||||
const result = await this.studentService.getBenchmarksForStudent(studentId);
|
||||
if (!result.success || !result.payload) return [];
|
||||
|
||||
return result.payload.benchmarks
|
||||
.filter(b => b.goalId === goalId)
|
||||
.map(b => ({
|
||||
label: b.benchmark,
|
||||
}));
|
||||
}
|
||||
|
||||
// *****************************************************************
|
||||
// Walks the sidebar tree and expands any node whose routerLink is
|
||||
// a prefix of the current URL. Triggers lazy loading if needed.
|
||||
// Returns true if the current URL matches or is a descendant of
|
||||
// any node in the given list.
|
||||
// *****************************************************************
|
||||
private async expandToRoute(url: string, nodes?: SidebarNode[]): Promise<boolean> {
|
||||
const tree = nodes || this.sidebarTree();
|
||||
let matched = false;
|
||||
|
||||
for (const node of tree) {
|
||||
const nodePath = node.routerLink ? node.routerLink.join('/') : '';
|
||||
|
||||
// Check if this node is the target or an ancestor of the target.
|
||||
const isMatch = nodePath !== '' && url === nodePath;
|
||||
const isAncestor = nodePath !== '' && url.startsWith(nodePath + '/');
|
||||
|
||||
if (isMatch || isAncestor) {
|
||||
matched = true;
|
||||
|
||||
if (isAncestor) {
|
||||
// Expand this node to reveal children.
|
||||
if (node.loadChildren && !node.children) {
|
||||
node.children = await node.loadChildren();
|
||||
}
|
||||
node.expanded = true;
|
||||
|
||||
// Continue down the tree.
|
||||
if (node.children) {
|
||||
await this.expandToRoute(url, node.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matched;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface StudentBenchmarkSummary {
|
||||
studentIdentifier: string;
|
||||
benchmarks: BenchmarkDto[];
|
||||
}
|
||||
|
||||
export interface BenchmarkDto {
|
||||
benchmarkId: string;
|
||||
goalId: string;
|
||||
goalTitle: string;
|
||||
benchmark: string;
|
||||
createdByName: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface SidebarNode {
|
||||
label: string;
|
||||
routerLink?: string[];
|
||||
children?: SidebarNode[];
|
||||
expanded?: boolean;
|
||||
loadChildren?: () => Promise<SidebarNode[]>;
|
||||
childCount?: number;
|
||||
}
|
||||
@@ -10,4 +10,5 @@ export interface StudentGoalItem {
|
||||
description: string; // goal.description — text
|
||||
category: string; // goal.category — varchar(100)
|
||||
progressEventCount: number; // count of progress_event rows for this goal
|
||||
benchmarkCount: number; // count of benchmark rows for this goal
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ApiResult } from '../classes/api-result';
|
||||
import { StudentGoalSummary, StudentGoalItem } from '../classes/student-goal';
|
||||
import { CreateGoalDto } from '../classes/create-goal.dto';
|
||||
import { ProgressEventDto } from '../classes/progress-event.dto';
|
||||
import { StudentBenchmarkSummary, BenchmarkDto } from '../classes/benchmark.dto';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -18,41 +19,41 @@ export class DummyStudentService {
|
||||
'1': {
|
||||
studentIdentifier: 'J.B',
|
||||
goals: [
|
||||
{ goalId: 'g1', goalParentId: null, title: 'Improve reading comprehension', description: 'Work on main-idea identification and inference skills across fiction and nonfiction texts.', category: 'Academics', progressEventCount: 5 },
|
||||
{ goalId: 'g2', goalParentId: null, title: 'Complete algebra module', description: 'Finish all units in the algebra course including linear equations and graphing.', category: 'Academics', progressEventCount: 2 },
|
||||
{ goalId: 'g3', goalParentId: null, title: 'Weekly journal entries', description: 'Write a reflective journal entry each week to build writing fluency.', category: 'Communication', progressEventCount: 8 },
|
||||
{ goalId: 'g1', goalParentId: null, title: 'Improve reading comprehension', description: 'Work on main-idea identification and inference skills across fiction and nonfiction texts.', category: 'Academics', progressEventCount: 5, benchmarkCount: 2 },
|
||||
{ goalId: 'g2', goalParentId: null, title: 'Complete algebra module', description: 'Finish all units in the algebra course including linear equations and graphing.', category: 'Academics', progressEventCount: 2, benchmarkCount: 0 },
|
||||
{ goalId: 'g3', goalParentId: null, title: 'Weekly journal entries', description: 'Write a reflective journal entry each week to build writing fluency.', category: 'Communication', progressEventCount: 8, benchmarkCount: 1 },
|
||||
],
|
||||
},
|
||||
'2': {
|
||||
studentIdentifier: 'M.K',
|
||||
goals: [
|
||||
{ goalId: 'g4', goalParentId: null, title: 'Pass certification exam', description: 'Prepare for and pass the industry certification exam by end of quarter.', category: 'Career Readiness', progressEventCount: 3 },
|
||||
{ goalId: 'g5', goalParentId: null, title: 'Attendance above 90%', description: 'Maintain consistent attendance throughout the term.', category: 'Behavior', progressEventCount: 0 },
|
||||
{ goalId: 'g6', goalParentId: null, title: 'Complete internship hours', description: 'Log the required 40 hours at the assigned internship site.', category: 'Career Readiness', progressEventCount: 12 },
|
||||
{ goalId: 'g7', goalParentId: null, title: 'Portfolio project', description: 'Build a personal portfolio showcasing completed coursework and projects.', category: 'Career Readiness', progressEventCount: 1 },
|
||||
{ goalId: 'g4', goalParentId: null, title: 'Pass certification exam', description: 'Prepare for and pass the industry certification exam by end of quarter.', category: 'Career Readiness', progressEventCount: 3, benchmarkCount: 0 },
|
||||
{ goalId: 'g5', goalParentId: null, title: 'Attendance above 90%', description: 'Maintain consistent attendance throughout the term.', category: 'Behavior', progressEventCount: 0, benchmarkCount: 0 },
|
||||
{ goalId: 'g6', goalParentId: null, title: 'Complete internship hours', description: 'Log the required 40 hours at the assigned internship site.', category: 'Career Readiness', progressEventCount: 12, benchmarkCount: 0 },
|
||||
{ goalId: 'g7', goalParentId: null, title: 'Portfolio project', description: 'Build a personal portfolio showcasing completed coursework and projects.', category: 'Career Readiness', progressEventCount: 1, benchmarkCount: 0 },
|
||||
],
|
||||
},
|
||||
'3': {
|
||||
studentIdentifier: 'A.R',
|
||||
goals: [
|
||||
{ goalId: 'g8', goalParentId: null, title: 'GED preparation', description: 'Complete practice tests and study modules for GED math and reading sections.', category: 'Academics', progressEventCount: 6 },
|
||||
{ goalId: 'g9', goalParentId: null, title: 'Resume workshop', description: 'Attend the resume writing workshop and produce a final draft.', category: 'Career Readiness', progressEventCount: 0 },
|
||||
{ goalId: 'g8', goalParentId: null, title: 'GED preparation', description: 'Complete practice tests and study modules for GED math and reading sections.', category: 'Academics', progressEventCount: 6, benchmarkCount: 0 },
|
||||
{ goalId: 'g9', goalParentId: null, title: 'Resume workshop', description: 'Attend the resume writing workshop and produce a final draft.', category: 'Career Readiness', progressEventCount: 0, benchmarkCount: 0 },
|
||||
],
|
||||
},
|
||||
'4': {
|
||||
studentIdentifier: 'T.W',
|
||||
goals: [
|
||||
{ goalId: 'g10', goalParentId: null, title: 'Public speaking practice', description: 'Present in front of the class at least once per month.', category: 'Communication', progressEventCount: 4 },
|
||||
{ goalId: 'g11', goalParentId: null, title: 'Math placement improvement', description: 'Move up one placement level in math by the end of the semester.', category: 'Academics', progressEventCount: 7 },
|
||||
{ goalId: 'g12', goalParentId: null, title: 'Conflict resolution strategies', description: 'Learn and apply at least three de-escalation techniques.', category: 'Behavior', progressEventCount: 2 },
|
||||
{ goalId: 'g13', goalParentId: null, title: 'Daily attendance streak', description: 'Achieve a 30-day unbroken attendance streak.', category: 'Behavior', progressEventCount: 0 },
|
||||
{ goalId: 'g14', goalParentId: null, title: 'Job shadow experience', description: 'Complete a job shadow day in a field of interest.', category: 'Career Readiness', progressEventCount: 1 },
|
||||
{ goalId: 'g10', goalParentId: null, title: 'Public speaking practice', description: 'Present in front of the class at least once per month.', category: 'Communication', progressEventCount: 4, benchmarkCount: 0 },
|
||||
{ goalId: 'g11', goalParentId: null, title: 'Math placement improvement', description: 'Move up one placement level in math by the end of the semester.', category: 'Academics', progressEventCount: 7, benchmarkCount: 0 },
|
||||
{ goalId: 'g12', goalParentId: null, title: 'Conflict resolution strategies', description: 'Learn and apply at least three de-escalation techniques.', category: 'Behavior', progressEventCount: 2, benchmarkCount: 0 },
|
||||
{ goalId: 'g13', goalParentId: null, title: 'Daily attendance streak', description: 'Achieve a 30-day unbroken attendance streak.', category: 'Behavior', progressEventCount: 0, benchmarkCount: 0 },
|
||||
{ goalId: 'g14', goalParentId: null, title: 'Job shadow experience', description: 'Complete a job shadow day in a field of interest.', category: 'Career Readiness', progressEventCount: 1, benchmarkCount: 0 },
|
||||
],
|
||||
},
|
||||
'5': {
|
||||
studentIdentifier: 'L.C',
|
||||
goals: [
|
||||
{ goalId: 'g15', goalParentId: null, title: 'Improve typing speed', description: 'Reach 40 WPM with 95% accuracy on typing assessments.', category: 'Career Readiness', progressEventCount: 3 },
|
||||
{ goalId: 'g15', goalParentId: null, title: 'Improve typing speed', description: 'Reach 40 WPM with 95% accuracy on typing assessments.', category: 'Career Readiness', progressEventCount: 3, benchmarkCount: 0 },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -119,6 +120,7 @@ export class DummyStudentService {
|
||||
description: data.description,
|
||||
category: data.category,
|
||||
progressEventCount: 0,
|
||||
benchmarkCount: 0,
|
||||
};
|
||||
|
||||
student.goals.push(newGoal);
|
||||
@@ -161,6 +163,28 @@ export class DummyStudentService {
|
||||
return ApiResult.ok(events);
|
||||
}
|
||||
|
||||
// *****************************************************************
|
||||
// Returns hardcoded benchmarks for a given student.
|
||||
// TODO: Replace with actual API call
|
||||
// *****************************************************************
|
||||
async getBenchmarksForStudent(studentId: string): Promise<ApiResult<StudentBenchmarkSummary | null>> {
|
||||
const studentGoals = this.data[studentId];
|
||||
if (!studentGoals) {
|
||||
return ApiResult.fail('Student not found');
|
||||
}
|
||||
|
||||
const benchmarks: BenchmarkDto[] = [
|
||||
{ benchmarkId: 'bm1', goalId: 'g1', goalTitle: 'Improve reading comprehension', benchmark: 'Student will identify the main idea of a grade-level nonfiction passage with 80% accuracy.', createdByName: 'Jane Smith', createdAt: new Date('2026-02-15'), updatedAt: null },
|
||||
{ benchmarkId: 'bm2', goalId: 'g1', goalTitle: 'Improve reading comprehension', benchmark: 'Student will make at least two supported inferences per reading session.', createdByName: 'Jane Smith', createdAt: new Date('2026-02-16'), updatedAt: new Date('2026-02-20') },
|
||||
{ benchmarkId: 'bm3', goalId: 'g3', goalTitle: 'Weekly journal entries', benchmark: 'Student will complete a minimum of one paragraph (5 sentences) per journal entry.', createdByName: 'John Doe', createdAt: new Date('2026-02-18'), updatedAt: null },
|
||||
];
|
||||
|
||||
return ApiResult.ok({
|
||||
studentIdentifier: studentGoals.studentIdentifier,
|
||||
benchmarks: benchmarks
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ************************ Event Handlers *************************
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { inject, Injectable, signal } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { ApiResult } from '../classes/api-result';
|
||||
@@ -10,6 +10,7 @@ import { CreateGoalDto } from '../classes/create-goal.dto';
|
||||
import { StudentCardDto } from '../classes/student-card.dto';
|
||||
import { StudentGoalSummary, StudentGoalItem } from '../classes/student-goal';
|
||||
import { ProgressEventDto } from '../classes/progress-event.dto';
|
||||
import { StudentBenchmarkSummary } from '../classes/benchmark.dto';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -23,10 +24,20 @@ export class StudentService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly base = environment.apiBaseUrl;
|
||||
|
||||
// Incremented after any data mutation so subscribers can refresh.
|
||||
readonly dataVersion = signal(0);
|
||||
|
||||
// ************************** Properties ***************************
|
||||
|
||||
// ************************ Public Methods *************************
|
||||
|
||||
// *****************************************************************
|
||||
// Increments the data version signal so subscribers can refresh.
|
||||
// *****************************************************************
|
||||
notifyDataChanged() {
|
||||
this.dataVersion.update(v => v + 1);
|
||||
}
|
||||
|
||||
// *****************************************************************
|
||||
// Returns student card summaries for the authenticated user.
|
||||
// *****************************************************************
|
||||
@@ -123,4 +134,100 @@ export class StudentService {
|
||||
// ************************ Event Handlers *************************
|
||||
|
||||
// ********************** Support Procedures ***********************
|
||||
|
||||
// *****************************************************************
|
||||
// Returns a single student by ID.
|
||||
// *****************************************************************
|
||||
async getStudentById(studentId: string): Promise<ApiResult<StudentCardDto>> {
|
||||
try {
|
||||
const result = await firstValueFrom(
|
||||
this.http.get<ResponseResult<StudentCardDto>>(`${this.base}/api/Student/${studentId}`)
|
||||
);
|
||||
return result.success && result.data
|
||||
? ApiResult.ok(result.data)
|
||||
: ApiResult.fail(result.message);
|
||||
} catch (error) {
|
||||
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
|
||||
}
|
||||
}
|
||||
|
||||
// *****************************************************************
|
||||
// Updates a student and returns the refreshed student data.
|
||||
// *****************************************************************
|
||||
async updateStudent(studentId: string, data: { identifier?: string; programYear?: number | null; enrollmentDate?: string | null; expectedGrad?: string | null }): Promise<ApiResult<StudentCardDto>> {
|
||||
try {
|
||||
const result = await firstValueFrom(
|
||||
this.http.put<ResponseResult<StudentCardDto>>(`${this.base}/api/Student/${studentId}`, data)
|
||||
);
|
||||
return result.success && result.data
|
||||
? ApiResult.ok(result.data)
|
||||
: ApiResult.fail(result.message);
|
||||
} catch (error) {
|
||||
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
|
||||
}
|
||||
}
|
||||
|
||||
// *****************************************************************
|
||||
// Returns benchmarks for a given student.
|
||||
// *****************************************************************
|
||||
async getBenchmarksForStudent(studentId: string): Promise<ApiResult<StudentBenchmarkSummary | null>> {
|
||||
try {
|
||||
const result = await firstValueFrom(
|
||||
this.http.get<ResponseResult<StudentBenchmarkSummary>>(`${this.base}/api/Student/${studentId}/benchmarks`)
|
||||
);
|
||||
return result.success && result.data
|
||||
? ApiResult.ok(result.data)
|
||||
: ApiResult.fail(result.message);
|
||||
} catch (error) {
|
||||
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
|
||||
}
|
||||
}
|
||||
|
||||
// *****************************************************************
|
||||
// Creates a new benchmark for a student.
|
||||
// *****************************************************************
|
||||
async createBenchmark(studentId: string, data: { goalId: string; benchmark: string }): Promise<ApiResult<any>> {
|
||||
try {
|
||||
const result = await firstValueFrom(
|
||||
this.http.post<ResponseResult<any>>(`${this.base}/api/Student/${studentId}/benchmarks`, data)
|
||||
);
|
||||
return result.success
|
||||
? ApiResult.ok(result.data)
|
||||
: ApiResult.fail(result.message);
|
||||
} catch (error) {
|
||||
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
|
||||
}
|
||||
}
|
||||
|
||||
// *****************************************************************
|
||||
// Updates a benchmark's text.
|
||||
// *****************************************************************
|
||||
async updateBenchmark(studentId: string, benchmarkId: string, benchmarkText: string): Promise<ApiResult<any>> {
|
||||
try {
|
||||
const result = await firstValueFrom(
|
||||
this.http.put<ResponseResult<any>>(`${this.base}/api/Student/${studentId}/benchmarks/${benchmarkId}`, { benchmark: benchmarkText })
|
||||
);
|
||||
return result.success
|
||||
? ApiResult.ok(result.data)
|
||||
: ApiResult.fail(result.message);
|
||||
} catch (error) {
|
||||
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
|
||||
}
|
||||
}
|
||||
|
||||
// *****************************************************************
|
||||
// Updates a goal's title, description, and category.
|
||||
// *****************************************************************
|
||||
async updateGoal(studentId: string, goalId: string, data: { title?: string; description?: string; category?: string }): Promise<ApiResult<any>> {
|
||||
try {
|
||||
const result = await firstValueFrom(
|
||||
this.http.put<ResponseResult<any>>(`${this.base}/api/Student/${studentId}/goals/${goalId}`, data)
|
||||
);
|
||||
return result.success
|
||||
? ApiResult.ok(result.data)
|
||||
: ApiResult.fail(result.message);
|
||||
} catch (error) {
|
||||
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user