mirror of
https://github.com/opelly27/WinStudentGoalTracker.git
synced 2026-05-20 13:27:35 +00:00
Desktop Update
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
<div class="card">
|
||||
<div class="card clickable" (click)="onCardClick()">
|
||||
<div class="card-header">
|
||||
<span class="category-badge">{{ goal().category }}</span>
|
||||
<span class="event-count">{{ goal().progressEventCount }} events</span>
|
||||
@@ -6,4 +6,4 @@
|
||||
|
||||
<h3 class="title">{{ goal().title }}</h3>
|
||||
<p class="description">{{ goal().description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -11,6 +11,16 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
height: 130px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card.clickable:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@@ -38,6 +48,9 @@
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.description {
|
||||
@@ -45,4 +58,8 @@
|
||||
font-size: 0.875rem;
|
||||
color: #555;
|
||||
line-height: 1.5;
|
||||
}
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { Component, inject, input } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { StudentGoalItem } from '../../../shared/classes/student-goal';
|
||||
|
||||
@Component({
|
||||
@@ -13,6 +14,9 @@ export class GoalCard {
|
||||
|
||||
// ************************** Declarations *************************
|
||||
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
readonly goal = input.required<StudentGoalItem>();
|
||||
|
||||
// ************************** Properties ***************************
|
||||
@@ -21,5 +25,13 @@ export class GoalCard {
|
||||
|
||||
// ************************ Event Handlers *************************
|
||||
|
||||
// *****************************************************************
|
||||
// Navigates to the progress events page for this goal.
|
||||
// *****************************************************************
|
||||
onCardClick() {
|
||||
const studentId = this.route.snapshot.paramMap.get('studentId')!;
|
||||
this.router.navigate(['/students', studentId, 'goals', this.goal().goalId, 'progress']);
|
||||
}
|
||||
|
||||
// ********************** Support Procedures ***********************
|
||||
}
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
<div class="toolbar">
|
||||
<button class="toolbar-btn back-btn" (click)="onBack()">← 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 (studentIdentifier()) {
|
||||
<h2 class="section-header">Goals for {{ studentIdentifier() }}</h2>
|
||||
}
|
||||
|
||||
@if (showAddModal()) {
|
||||
<app-add-goal-modal
|
||||
[studentId]="studentId"
|
||||
[existingGoals]="goals()"
|
||||
(goalCreated)="onGoalCreated($event)"
|
||||
(cancelled)="onModalCancelled()"
|
||||
/>
|
||||
<app-add-goal-modal [studentId]="studentId" [existingGoals]="goals()" (goalCreated)="onGoalCreated($event)"
|
||||
(cancelled)="onModalCancelled()" />
|
||||
}
|
||||
|
||||
@if (errorMessage()) {
|
||||
<p class="error">{{ errorMessage() }}</p>
|
||||
<p class="error">{{ errorMessage() }}</p>
|
||||
}
|
||||
|
||||
@if (goals().length === 0 && !errorMessage()) {
|
||||
<p class="empty-state">No goals yet. Click <strong>+ Add a Goal</strong> to get started.</p>
|
||||
<p class="empty-state">No goals yet. Click <strong>+ Add a Goal</strong> to get started.</p>
|
||||
} @else {
|
||||
<div class="card-grid">
|
||||
@for (goal of goals(); track goal.goalId) {
|
||||
<app-goal-card [goal]="goal" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="card-grid">
|
||||
@for (goal of goals(); track goal.goalId) {
|
||||
<app-goal-card [goal]="goal" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -36,10 +36,11 @@
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.student-label {
|
||||
font-size: 0.9375rem;
|
||||
.section-header {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
@@ -65,4 +66,4 @@
|
||||
gap: 1rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<div class="card">
|
||||
<p class="content">{{ event().content }}</p>
|
||||
<div class="action-icons">
|
||||
<button class="icon-btn" title="Edit">✎</button>
|
||||
<button class="icon-btn" title="Delete">🗑</button>
|
||||
</div>
|
||||
<span class="author">{{ event().createdByName }}</span>
|
||||
<span class="date">{{ event().createdAt | date:'MMM d, y' }}</span>
|
||||
</div>
|
||||
@@ -0,0 +1,61 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
padding: 1.25rem 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.action-icons {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-self: start;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.author {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.date {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
font-size: 0.8125rem;
|
||||
color: #888;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
color: #888;
|
||||
padding: 0.125rem;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
color: #4f46e5;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ProgressItem } from './progress-item';
|
||||
|
||||
describe('ProgressItem', () => {
|
||||
let component: ProgressItem;
|
||||
let fixture: ComponentFixture<ProgressItem>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ProgressItem]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProgressItem);
|
||||
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 { ProgressEventDto } from '../../../shared/classes/progress-event.dto';
|
||||
|
||||
@Component({
|
||||
selector: 'app-progress-item',
|
||||
imports: [DatePipe],
|
||||
templateUrl: './progress-item.html',
|
||||
styleUrl: './progress-item.scss',
|
||||
})
|
||||
export class ProgressItem {
|
||||
|
||||
// ************************** Constructor **************************
|
||||
|
||||
// ************************** Declarations *************************
|
||||
|
||||
readonly event = input.required<ProgressEventDto>();
|
||||
|
||||
// ************************** Properties ***************************
|
||||
|
||||
// ************************ Public Methods *************************
|
||||
|
||||
// ************************ Event Handlers *************************
|
||||
|
||||
// ********************** Support Procedures ***********************
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<div class="toolbar">
|
||||
<button class="toolbar-btn back-btn" (click)="onBack()">← Goals</button>
|
||||
</div>
|
||||
|
||||
@if (studentIdentifier() && goalTitle()) {
|
||||
<div class="header-row">
|
||||
<h2 class="section-header">
|
||||
{{ events().length }} Progress Events for {{ studentIdentifier() }} for goal: {{ goalTitle() }}
|
||||
@if (isFiltered()) {
|
||||
<span class="filter-count">(showing {{ filteredEvents().length }})</span>
|
||||
}
|
||||
</h2>
|
||||
<div class="search-box">
|
||||
<input type="text" class="search-input" placeholder="Search..." [value]="rawSearchText()"
|
||||
(input)="onSearchInput($any($event.target).value)" />
|
||||
@if (rawSearchText()) {
|
||||
<button class="clear-btn" (click)="onClearSearch()">×</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (errorMessage()) {
|
||||
<p class="error">{{ errorMessage() }}</p>
|
||||
}
|
||||
|
||||
@if (filteredEvents().length === 0 && !errorMessage()) {
|
||||
<p class="empty-state">No progress events recorded yet.</p>
|
||||
} @else {
|
||||
<div class="event-list">
|
||||
@for (evt of filteredEvents(); track evt.progressEventId) {
|
||||
<app-progress-item [event]="evt" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
: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;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
padding-right: calc(0.75rem + 17px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
font-weight: 400;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 0.375rem 2rem 0.375rem 0.75rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
width: 200px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
right: 0.375rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.125rem;
|
||||
color: #888;
|
||||
line-height: 1;
|
||||
padding: 0 0.125rem;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
flex: 1;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ProgressList } from './progress-list';
|
||||
|
||||
describe('ProgressList', () => {
|
||||
let component: ProgressList;
|
||||
let fixture: ComponentFixture<ProgressList>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ProgressList]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProgressList);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Component, computed, inject, signal, OnDestroy } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Subject } from 'rxjs';
|
||||
import { debounceTime } from 'rxjs/operators';
|
||||
import { ProgressItem } from '../progress-item/progress-item';
|
||||
import { ProgressEventDto } from '../../../shared/classes/progress-event.dto';
|
||||
import { DummyStudentService } from '../../../shared/services/dummy-student.service';
|
||||
import { StudentService } from '../../../shared/services/student.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-progress-list',
|
||||
imports: [ProgressItem],
|
||||
templateUrl: './progress-list.html',
|
||||
styleUrl: './progress-list.scss',
|
||||
})
|
||||
export class ProgressList implements OnDestroy {
|
||||
|
||||
// ************************** Constructor **************************
|
||||
|
||||
constructor() {
|
||||
this.studentId = this.route.snapshot.paramMap.get('studentId')!;
|
||||
this.goalId = this.route.snapshot.paramMap.get('goalId')!;
|
||||
this.loadEvents();
|
||||
this.loadGoalTitle();
|
||||
|
||||
this.searchInput$.pipe(debounceTime(300)).subscribe(term => {
|
||||
this.searchTerm.set(term);
|
||||
});
|
||||
}
|
||||
|
||||
// ************************** Declarations *************************
|
||||
|
||||
private readonly dummyService = inject(DummyStudentService);
|
||||
private readonly studentService = inject(StudentService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
private readonly studentId: string;
|
||||
private readonly goalId: string;
|
||||
private readonly searchInput$ = new Subject<string>();
|
||||
|
||||
protected readonly studentIdentifier = signal<string | null>(null);
|
||||
protected readonly goalTitle = signal<string | null>(null);
|
||||
protected readonly events = signal<ProgressEventDto[]>([]);
|
||||
protected readonly errorMessage = signal<string | null>(null);
|
||||
protected readonly rawSearchText = signal('');
|
||||
protected readonly searchTerm = signal('');
|
||||
|
||||
// ************************** Properties ***************************
|
||||
|
||||
// *****************************************************************
|
||||
// Returns events filtered by the debounced search term. Matches
|
||||
// against the event content (case-insensitive). Only filters when
|
||||
// the term is at least 2 characters.
|
||||
// *****************************************************************
|
||||
protected readonly filteredEvents = computed(() => {
|
||||
const term = this.searchTerm().trim().toLowerCase();
|
||||
if (term.length < 2) return this.events();
|
||||
return this.events().filter(e => e.content.toLowerCase().includes(term));
|
||||
});
|
||||
|
||||
protected readonly isFiltered = computed(() => {
|
||||
return this.searchTerm().trim().length >= 2 && this.filteredEvents().length !== this.events().length;
|
||||
});
|
||||
|
||||
// ************************ Public Methods *************************
|
||||
|
||||
// ************************ Event Handlers *************************
|
||||
|
||||
// *****************************************************************
|
||||
// Navigates back to the goals list for this student.
|
||||
// *****************************************************************
|
||||
onBack() {
|
||||
this.router.navigate(['/students', this.studentId, 'goals']);
|
||||
}
|
||||
|
||||
// *****************************************************************
|
||||
// Pushes the raw input value into the debounce stream.
|
||||
// *****************************************************************
|
||||
onSearchInput(value: string) {
|
||||
this.rawSearchText.set(value);
|
||||
this.searchInput$.next(value);
|
||||
}
|
||||
|
||||
// *****************************************************************
|
||||
// Clears the search box and resets the filter.
|
||||
// *****************************************************************
|
||||
onClearSearch() {
|
||||
this.rawSearchText.set('');
|
||||
this.searchTerm.set('');
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.searchInput$.complete();
|
||||
}
|
||||
|
||||
// ********************** Support Procedures ***********************
|
||||
|
||||
// *****************************************************************
|
||||
// Loads progress events for the given goal from the dummy service,
|
||||
// sorted newest-first by createdAt.
|
||||
// TODO: Replace DummyStudentService with StudentService
|
||||
// *****************************************************************
|
||||
private loadEvents() {
|
||||
this.dummyService.getProgressEventsForGoal(this.goalId).then(result => {
|
||||
if (!result.success) {
|
||||
this.errorMessage.set(result.message);
|
||||
} else {
|
||||
const sorted = (result.payload ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
this.events.set(sorted);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// *****************************************************************
|
||||
// Loads the goal title from the student's goal list so the heading
|
||||
// can display "Progress for <goal title>".
|
||||
// *****************************************************************
|
||||
private loadGoalTitle() {
|
||||
this.studentService.getGoalsForStudent(this.studentId).then(result => {
|
||||
if (result.success && result.payload) {
|
||||
this.studentIdentifier.set(result.payload.studentIdentifier);
|
||||
const goal = result.payload.goals.find(g => g.goalId === this.goalId);
|
||||
this.goalTitle.set(goal?.title ?? null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user