This commit is contained in:
2026-03-02 17:16:24 -08:00
parent c8315d472c
commit 55d2c42376
20 changed files with 652 additions and 6 deletions
@@ -0,0 +1,58 @@
<div class="overlay" (click)="onCancel()"></div>
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">Add Goal</h2>
<button class="close-btn" (click)="onCancel()" aria-label="Close">&times;</button>
</div>
<form class="modal-body" (ngSubmit)="onSubmit()" #goalForm="ngForm">
<div class="field">
<label for="title">Title</label>
<input
id="title"
type="text"
[(ngModel)]="form.title"
name="title"
required
placeholder="e.g. Improve reading comprehension"
/>
</div>
<div class="field">
<label for="category">Category</label>
<input
id="category"
type="text"
[(ngModel)]="form.category"
name="category"
required
placeholder="e.g. Academics"
/>
</div>
<div class="field">
<label for="description">Description</label>
<textarea
id="description"
[(ngModel)]="form.description"
name="description"
rows="3"
placeholder="Describe the goal..."
></textarea>
</div>
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}
<div class="modal-actions">
<button type="button" class="btn btn-secondary" (click)="onCancel()">Cancel</button>
<button type="submit" class="btn btn-primary" [disabled]="goalForm.invalid || isSubmitting()">
{{ isSubmitting() ? 'Saving...' : 'Add Goal' }}
</button>
</div>
</form>
</div>
@@ -0,0 +1,134 @@
:host {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
}
.modal {
position: relative;
background: #fff;
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
width: 420px;
max-width: 95vw;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem 0;
}
.modal-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: #666;
padding: 0;
}
.close-btn:hover {
color: #111;
}
.modal-body {
padding: 1.25rem 1.5rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field label {
font-size: 0.875rem;
font-weight: 500;
color: #333;
}
.field input,
.field textarea {
padding: 0.5rem 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 0.9375rem;
font-family: inherit;
outline: none;
resize: vertical;
}
.field input:focus,
.field textarea:focus {
border-color: #4f46e5;
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.15);
}
.error {
font-size: 0.875rem;
color: #dc2626;
margin: 0;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 0.25rem;
}
.btn {
padding: 0.5rem 1.125rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
}
.btn-secondary {
background: transparent;
border-color: #ddd;
color: #555;
}
.btn-secondary:hover {
background: #f5f5f5;
}
.btn-primary {
background: #4f46e5;
color: #fff;
border-color: #4f46e5;
}
.btn-primary:hover:not(:disabled) {
background: #4338ca;
border-color: #4338ca;
}
.btn-primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
@@ -0,0 +1,61 @@
import { Component, inject, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CreateGoalDto } from '../../../shared/classes/create-goal.dto';
import { StudentGoalItem } from '../../../shared/classes/student-goal';
import { StudentService } from '../../../shared/services/student.service';
@Component({
selector: 'app-add-goal-modal',
imports: [FormsModule],
templateUrl: './add-goal-modal.html',
styleUrl: './add-goal-modal.scss',
})
export class AddGoalModal {
// ************************** Constructor **************************
// ************************** Declarations *************************
private readonly studentService = inject(StudentService);
readonly studentId = input.required<string>();
readonly goalCreated = output<StudentGoalItem>();
readonly cancelled = output<void>();
protected readonly isSubmitting = signal(false);
protected readonly errorMessage = signal<string | null>(null);
protected form: CreateGoalDto = {
title: '',
description: '',
category: '',
};
// ************************** Properties ***************************
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
async onSubmit() {
this.errorMessage.set(null);
this.isSubmitting.set(true);
const result = await this.studentService.createGoal(this.studentId(), this.form);
this.isSubmitting.set(false);
if (!result.success) {
this.errorMessage.set(result.message);
return;
}
this.goalCreated.emit(result.payload!);
}
onCancel() {
this.cancelled.emit();
}
// ********************** Support Procedures ***********************
}
@@ -0,0 +1,9 @@
<div class="card">
<div class="card-header">
<span class="category-badge">{{ goal().category }}</span>
<span class="event-count">{{ goal().progressEventCount }} events</span>
</div>
<h3 class="title">{{ goal().title }}</h3>
<p class="description">{{ goal().description }}</p>
</div>
@@ -0,0 +1,48 @@
: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;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.category-badge {
padding: 0.2rem 0.6rem;
background: #eef2ff;
color: #4f46e5;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
}
.event-count {
font-size: 0.8125rem;
color: #888;
}
.title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #111;
}
.description {
margin: 0;
font-size: 0.875rem;
color: #555;
line-height: 1.5;
}
@@ -0,0 +1,25 @@
import { Component, input } from '@angular/core';
import { StudentGoalItem } from '../../../shared/classes/student-goal';
@Component({
selector: 'app-goal-card',
imports: [],
templateUrl: './goal-card.html',
styleUrl: './goal-card.scss',
})
export class GoalCard {
// ************************** Constructor **************************
// ************************** Declarations *************************
readonly goal = input.required<StudentGoalItem>();
// ************************** Properties ***************************
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
// ********************** Support Procedures ***********************
}
@@ -0,0 +1,26 @@
<div class="toolbar">
<button class="toolbar-btn back-btn" (click)="onBack()">&#8592; Students</button>
@if (studentIdentifier()) {
<span class="student-label">{{ studentIdentifier() }}</span>
}
<span class="spacer"></span>
<button class="toolbar-btn" (click)="onAddGoal()">+ Add a Goal</button>
</div>
@if (showAddModal()) {
<app-add-goal-modal
[studentId]="studentId"
(goalCreated)="onGoalCreated($event)"
(cancelled)="onModalCancelled()"
/>
}
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}
<div class="card-grid">
@for (goal of goals(); track goal.goalId) {
<app-goal-card [goal]="goal" />
}
</div>
@@ -0,0 +1,61 @@
: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;
}
.student-label {
font-size: 0.9375rem;
font-weight: 600;
color: #333;
}
.spacer {
flex: 1;
}
.error {
font-size: 0.875rem;
color: #dc2626;
margin: 0 0 1rem;
}
.card-grid {
display: flex;
flex-wrap: wrap;
gap: 1rem;
overflow-y: auto;
flex: 1;
}
@@ -0,0 +1,73 @@
import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { StudentGoalItem } from '../../../shared/classes/student-goal';
import { StudentService } from '../../../shared/services/student.service';
import { GoalCard } from '../goal-card/goal-card';
import { AddGoalModal } from '../add-goal-modal/add-goal-modal';
@Component({
selector: 'app-goal-list',
imports: [GoalCard, AddGoalModal],
templateUrl: './goal-list.html',
styleUrl: './goal-list.scss',
})
export class GoalList {
// ************************** Constructor **************************
constructor() {
this.studentId = this.route.snapshot.paramMap.get('studentId')!;
this.loadGoals();
}
// ************************** Declarations *************************
private readonly studentService = inject(StudentService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
protected readonly studentId: string;
protected readonly studentIdentifier = signal<string | null>(null);
protected readonly goals = signal<StudentGoalItem[]>([]);
protected readonly showAddModal = signal(false);
protected readonly errorMessage = signal<string | null>(null);
// ************************** Properties ***************************
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
onAddGoal() {
this.showAddModal.set(true);
}
onGoalCreated(goal: StudentGoalItem) {
this.goals.update(list => [...list, goal]);
this.showAddModal.set(false);
}
onModalCancelled() {
this.showAddModal.set(false);
}
onBack() {
this.router.navigate(['/students']);
}
// ********************** Support Procedures ***********************
// *****************************************************************
// Loads goals for the student from the service.
// *****************************************************************
private loadGoals() {
this.studentService.getGoalsForStudent(this.studentId).then(data => {
if (!data.success) {
this.errorMessage.set(data.message);
} else {
this.studentIdentifier.set(data.payload?.studentIdentifier ?? null);
this.goals.set(data.payload?.goals ?? []);
}
});
}
}
@@ -1,4 +1,4 @@
<div class="card">
<div class="card" [routerLink]="['/students', student().studentId, 'goals']">
<h2 class="identifier">🎓 {{ student().identifier }}</h2>
<div class="meta">
@@ -8,6 +8,13 @@
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 1.5rem;
cursor: pointer;
transition: box-shadow 0.15s ease, transform 0.15s ease;
}
.card:hover {
box-shadow: 0 4px 20px rgba(79, 70, 229, 0.15);
transform: translateY(-2px);
}
.identifier {
@@ -1,10 +1,11 @@
import { Component, computed, input } from '@angular/core';
import { DatePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { StudentCardDto } from '../../../shared/classes/student-card.dto';
@Component({
selector: 'app-student-card',
imports: [DatePipe],
imports: [DatePipe, RouterLink],
templateUrl: './student-card.html',
styleUrl: './student-card.scss',
})
@@ -1,6 +1,7 @@
import { Routes } from '@angular/router';
import { Home } from './pages/home/home';
import { StudentCardList } from './components/student-card-list/student-card-list';
import { GoalList } from './components/goal-list/goal-list';
export default [
{
@@ -9,6 +10,7 @@ export default [
children: [
{ path: '', redirectTo: 'students', pathMatch: 'full' },
{ path: 'students', component: StudentCardList },
{ path: 'students/:studentId/goals', component: GoalList },
],
},
] satisfies Routes;
@@ -0,0 +1,5 @@
export interface CreateGoalDto {
title: string;
description: string;
category: string;
}
@@ -1,7 +1,8 @@
import { Injectable } from '@angular/core';
import { StudentCardDto } from '../classes/student-card.dto';
import { ApiResult } from '../classes/api-result';
import { StudentGoalSummary } from '../classes/student-goal';
import { StudentGoalSummary, StudentGoalItem } from '../classes/student-goal';
import { CreateGoalDto } from '../classes/create-goal.dto';
@Injectable({
providedIn: 'root',
@@ -105,6 +106,25 @@ export class DummyStudentService {
}
async createGoal(studentId: string, data: CreateGoalDto): Promise<ApiResult<StudentGoalItem>> {
const student = this.data[studentId];
if (!student) {
return ApiResult.fail('Student not found');
}
const newGoal: StudentGoalItem = {
goalId: `g${Date.now()}`,
goalParentId: null,
title: data.title,
description: data.description,
category: data.category,
progressEventCount: 0,
};
student.goals.push(newGoal);
return ApiResult.ok(newGoal);
}
async addProgressEvent(studentId: string, goalId: string, content: string): Promise<ApiResult> {
return ApiResult.empty();
}
@@ -6,8 +6,9 @@ import { ApiResult } from '../classes/api-result';
import { ResponseResult } from '../classes/auth.models';
import { describeHttpError } from '../classes/http-errors';
import { CreateStudentDto } from '../classes/create-student.dto';
import { CreateGoalDto } from '../classes/create-goal.dto';
import { StudentCardDto } from '../classes/student-card.dto';
import { StudentGoalSummary } from '../classes/student-goal';
import { StudentGoalSummary, StudentGoalItem } from '../classes/student-goal';
@Injectable({
providedIn: 'root',
@@ -73,6 +74,22 @@ export class StudentService {
}
}
// *****************************************************************
// Creates a new goal for a student and returns the created goal.
// *****************************************************************
async createGoal(studentId: string, data: CreateGoalDto): Promise<ApiResult<StudentGoalItem>> {
try {
const result = await firstValueFrom(
this.http.post<ResponseResult<StudentGoalItem>>(`${this.base}/api/Student/${studentId}/goals`, data)
);
return result.success && result.data
? ApiResult.ok(result.data)
: ApiResult.fail(result.message);
} catch (error) {
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
}
}
async addProgressEvent(studentId: string, goalId: string, content: string): Promise<ApiResult> {
try {
const result = await firstValueFrom(