frontend-toegevoegd

This commit is contained in:
2026-10-27 15:34:56 +01:00
parent 777d9f199d
commit 02337fafaa
43 changed files with 10548 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
import { Component, input, output } from '@angular/core';
import { DialogFrame } from './dialog-frame';
@Component({
selector: 'app-confirm-dialog',
imports: [DialogFrame],
template: `
<app-dialog-frame title="Verwijderen bevestigen" [busy]="busy()" (dismissed)="closed.emit()">
<p>
Weet je zeker dat je <strong>{{ name() }}</strong> wilt verwijderen?
</p>
<p class="muted">{{ explanation() }}</p>
@if (error()) {
<p class="alert error" role="alert">{{ error() }}</p>
}
<div class="dialog-actions">
<button
autofocus
type="button"
class="button secondary"
[disabled]="busy()"
(click)="closed.emit()"
>
Annuleren
</button>
<button type="button" class="button danger" [disabled]="busy()" (click)="confirmed.emit()">
{{ busy() ? 'Verwijderen…' : 'Definitief verwijderen' }}
</button>
</div>
</app-dialog-frame>
`,
})
export class ConfirmDialog {
readonly name = input.required<string>();
readonly explanation = input('Dit kan niet ongedaan worden gemaakt.');
readonly busy = input(false);
readonly error = input('');
readonly confirmed = output<void>();
readonly closed = output<void>();
}

View File

@@ -0,0 +1,50 @@
import {
AfterViewInit,
Component,
ElementRef,
input,
OnDestroy,
output,
viewChild,
} from '@angular/core';
@Component({
selector: 'app-dialog-frame',
template: `
<dialog #dialog (cancel)="cancel($event)" aria-labelledby="dialog-title">
<div class="dialog-heading">
<div>
<p class="eyebrow">AGILE DANCERS</p>
<h2 id="dialog-title">{{ title() }}</h2>
</div>
<button
class="icon-button"
type="button"
aria-label="Sluiten"
[disabled]="busy()"
(click)="dismissed.emit()"
>
×
</button>
</div>
<ng-content />
</dialog>
`,
})
export class DialogFrame implements AfterViewInit, OnDestroy {
readonly title = input.required<string>();
readonly busy = input(false);
readonly dismissed = output<void>();
private readonly dialog = viewChild.required<ElementRef<HTMLDialogElement>>('dialog');
ngAfterViewInit(): void {
this.dialog().nativeElement.showModal();
}
ngOnDestroy(): void {
const dialog = this.dialog().nativeElement;
if (dialog.open) dialog.close();
}
cancel(event: Event): void {
event.preventDefault();
if (!this.busy()) this.dismissed.emit();
}
}

View File

@@ -0,0 +1,69 @@
import { TestBed } from '@angular/core/testing';
import { HttpErrorResponse } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { SchoolApi } from '../core/school-api';
import { EntityEditor } from './entity-editor';
import { EditorTarget } from '../core/api-models';
describe('EntityEditor', () => {
const api = { saveSchool: vi.fn(), saveGroup: vi.fn(), saveDancer: vi.fn(), saveShow: vi.fn() };
beforeEach(() => {
vi.resetAllMocks();
TestBed.configureTestingModule({
imports: [EntityEditor],
providers: [{ provide: SchoolApi, useValue: api }],
});
});
function editor(target: EditorTarget) {
const fixture = TestBed.createComponent(EntityEditor);
fixture.componentRef.setInput('target', target);
fixture.componentRef.setInput('schoolId', 7);
fixture.componentInstance.ngOnInit();
return fixture.componentInstance;
}
it('rejects blank names and invalid age ranges before calling the API', async () => {
const component = editor({ kind: 'group' });
component.name = ' ';
expect(component.validDetails()).toBe(false);
component.name = 'Teens';
component.startAge = 18;
component.endAge = 12;
await component.save();
expect(api.saveGroup).not.toHaveBeenCalled();
component.endAge = 18.5;
expect(component.validDetails()).toBe(false);
});
it('does not accept a future birth date', () => {
const component = editor({ kind: 'dancer' });
component.name = 'Sam';
component.date = '2999-01-01';
expect(component.validDetails()).toBe(false);
});
it('trims the school name and emits saved only after success', async () => {
api.saveSchool.mockReturnValue(of({ id: 0 }));
const component = editor({ kind: 'school' });
component.name = ' Studio ';
const saved = vi.fn();
component.saved.subscribe(saved);
await component.save();
expect(api.saveSchool).toHaveBeenCalledWith({ name: 'Studio' }, undefined);
expect(saved).toHaveBeenCalledOnce();
expect(component.busy()).toBe(false);
});
it('keeps the editor open and preserves input when saving fails', async () => {
api.saveSchool.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 0 })));
const component = editor({ kind: 'school' });
component.name = 'Studio';
const saved = vi.fn();
component.saved.subscribe(saved);
await component.save();
expect(saved).not.toHaveBeenCalled();
expect(component.name).toBe('Studio');
expect(component.error()).toContain('niet bereikbaar');
expect(component.busy()).toBe(false);
});
});

View File

@@ -0,0 +1,222 @@
import { Component, inject, input, OnInit, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { firstValueFrom, Observable } from 'rxjs';
import { EditorTarget, GROUP_STYLES, GROUP_TYPES, GroupStyle, GroupType } from '../core/api-models';
import { SchoolApi } from '../core/school-api';
import { apiError } from '../core/api-error';
import { DialogFrame } from './dialog-frame';
@Component({
selector: 'app-entity-editor',
imports: [FormsModule, DialogFrame],
template: `
<app-dialog-frame [title]="title" [busy]="busy()" (dismissed)="closed.emit()">
<form #form="ngForm" (ngSubmit)="form.valid && save()">
@if (error()) {
<p class="alert error" role="alert">{{ error() }}</p>
}
@if (target().kind === 'group') {
<label
>Naam / categorie<input
autofocus
name="category"
[(ngModel)]="name"
required
pattern=".*\\S.*"
maxlength="160"
placeholder="Bijvoorbeeld: Hiphop tieners"
/></label>
<label for="group-type">Type</label
><select id="group-type" name="type" [(ngModel)]="type" required>
@for (option of types; track option.value) {
<option [ngValue]="option.value">{{ option.label }}</option>
}
</select>
<div class="form-grid">
<label
>Leeftijd vanaf<input
type="number"
name="startAge"
[(ngModel)]="startAge"
required
min="0"
step="1"
/></label>
<label
>Leeftijd tot en met<input
type="number"
name="endAge"
[(ngModel)]="endAge"
required
[min]="startAge ?? 0"
step="1"
/></label>
</div>
<fieldset>
<legend>Dansstijlen <span class="muted">(optioneel)</span></legend>
<div class="checkbox-grid">
@for (style of styles; track style.value) {
<label class="checkbox"
><input
type="checkbox"
[name]="style.value"
[ngModel]="selectedStyles.includes(style.value)"
(ngModelChange)="toggleStyle(style.value, $event)"
/>{{ style.label }}</label
>
}
</div>
</fieldset>
} @else {
<label
>Naam<input
autofocus
name="name"
[(ngModel)]="name"
required
pattern=".*\\S.*"
maxlength="160"
[placeholder]="target().kind === 'school' ? 'Bijvoorbeeld: 24 Dance Studio' : 'Naam'"
/></label>
@if (target().kind === 'dancer') {
<label
>Geboortedatum<input
type="date"
name="birthDate"
[(ngModel)]="date"
required
[max]="today"
/></label>
}
@if (target().kind === 'show') {
<label>Datum<input type="date" name="showDate" [(ngModel)]="date" required /></label>
<p class="form-hint">
De show krijgt automatisch een ochtend-, middag- en avonddeel. De groepsindeling volgt
later.
</p>
}
}
<div class="dialog-actions">
<button
type="button"
class="button secondary"
[disabled]="busy()"
(click)="closed.emit()"
>
Annuleren
</button>
<button
class="button primary"
type="submit"
[disabled]="busy() || form.invalid || !validDetails()"
>
{{ busy() ? 'Opslaan…' : 'Opslaan' }}
</button>
</div>
</form>
</app-dialog-frame>
`,
})
export class EntityEditor implements OnInit {
readonly target = input.required<EditorTarget>();
readonly schoolId = input<number>();
readonly closed = output<void>();
readonly saved = output<void>();
readonly busy = signal(false);
readonly error = signal('');
readonly types = GROUP_TYPES;
readonly styles = GROUP_STYLES;
readonly today = new Date().toLocaleDateString('en-CA');
private readonly api = inject(SchoolApi);
name = '';
date = '';
type: GroupType = 'REGULAR';
startAge: number | null = 0;
endAge: number | null = 18;
selectedStyles: GroupStyle[] = [];
get title(): string {
const labels = { school: 'Dansschool', dancer: 'Danser', group: 'Dansgroep', show: 'Show' };
return `${labels[this.target().kind]} ${this.target().value ? 'bewerken' : 'toevoegen'}`;
}
ngOnInit(): void {
const target = this.target();
if (!target.value) return;
if (target.kind === 'group') {
this.name = target.value.category;
this.type = target.value.type;
this.startAge = target.value.startAge;
this.endAge = target.value.endAge;
this.selectedStyles = [...target.value.styles];
} else {
this.name = target.value.name;
if (target.kind === 'dancer') this.date = target.value.birthDate;
if (target.kind === 'show') this.date = target.value.date;
}
}
toggleStyle(style: GroupStyle, checked: boolean): void {
this.selectedStyles = checked
? [...this.selectedStyles, style]
: this.selectedStyles.filter((value) => value !== style);
}
validDetails(): boolean {
if (!this.name.trim()) return false;
if (this.target().kind === 'dancer') return !!this.date && this.date <= this.today;
if (this.target().kind === 'show') return !!this.date;
if (this.target().kind === 'group')
return (
this.startAge !== null &&
this.endAge !== null &&
Number.isInteger(this.startAge) &&
Number.isInteger(this.endAge) &&
this.startAge >= 0 &&
this.endAge >= this.startAge
);
return true;
}
async save(): Promise<void> {
if (this.busy() || !this.validDetails()) return;
this.busy.set(true);
this.error.set('');
const target = this.target();
const id = target.value?.id;
const schoolId = this.schoolId();
let request: Observable<unknown>;
switch (target.kind) {
case 'school':
request = this.api.saveSchool({ name: this.name.trim() }, id);
break;
case 'dancer':
request = this.api.saveDancer(
schoolId!,
{ name: this.name.trim(), birthDate: this.date },
id,
);
break;
case 'group':
request = this.api.saveGroup(
schoolId!,
{
category: this.name.trim(),
type: this.type,
startAge: this.startAge!,
endAge: this.endAge!,
styles: this.selectedStyles,
},
id,
);
break;
case 'show':
request = this.api.saveShow(schoolId!, { name: this.name.trim(), date: this.date }, id);
break;
}
try {
await firstValueFrom(request);
this.saved.emit();
} catch (error: unknown) {
this.error.set(apiError(error));
} finally {
this.busy.set(false);
}
}
}

View File

@@ -0,0 +1,76 @@
import { TestBed } from '@angular/core/testing';
import { HttpErrorResponse } from '@angular/common/http';
import { Subject } from 'rxjs';
import { SchoolApi } from '../core/school-api';
import { GroupMembers } from './group-members';
describe('Group membership persistence', () => {
const api = { membership: vi.fn() };
beforeEach(() => {
vi.resetAllMocks();
TestBed.configureTestingModule({
imports: [GroupMembers],
providers: [{ provide: SchoolApi, useValue: api }],
});
});
function component() {
const fixture = TestBed.createComponent(GroupMembers);
fixture.componentRef.setInput('schoolId', 7);
fixture.componentRef.setInput('group', {
id: 0,
category: 'Teens',
type: 'REGULAR',
startAge: 12,
endAge: 18,
styles: [],
dancerIds: [],
});
fixture.componentRef.setInput('dancers', [{ id: 3, name: 'Sam', birthDate: '2010-01-01' }]);
fixture.componentInstance.ngOnInit();
return fixture.componentInstance;
}
function change(checked: boolean): { checkbox: HTMLInputElement; event: Event } {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = checked;
const event = new Event('change');
Object.defineProperty(event, 'target', { value: checkbox });
return { checkbox, event };
}
it('shows the pending choice but emits new membership only after the server confirms', async () => {
const request = new Subject<void>();
api.membership.mockReturnValue(request);
const members = component();
const changed = vi.fn();
members.changed.subscribe(changed);
const { checkbox, event } = change(true);
const pending = members.toggle(3, event);
expect(checkbox.checked).toBe(true);
expect(members.busy()).toBe(true);
expect(changed).not.toHaveBeenCalled();
request.next();
request.complete();
await pending;
expect(changed).toHaveBeenCalledWith([3]);
expect(members.memberIds()).toEqual([3]);
expect(members.busy()).toBe(false);
});
it('restores the previous checkbox state when saving fails', async () => {
const request = new Subject<void>();
api.membership.mockReturnValue(request);
const members = component();
const changed = vi.fn();
members.changed.subscribe(changed);
const { checkbox, event } = change(true);
const pending = members.toggle(3, event);
request.error(new HttpErrorResponse({ status: 0 }));
await pending;
expect(checkbox.checked).toBe(false);
expect(members.memberIds()).toEqual([]);
expect(changed).not.toHaveBeenCalled();
expect(members.error()).toContain('niet bereikbaar');
expect(members.busy()).toBe(false);
});
});

View File

@@ -0,0 +1,98 @@
import { Component, computed, inject, input, OnInit, output, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { DanceGroup, Dancer } from '../core/api-models';
import { SchoolApi } from '../core/school-api';
import { apiError } from '../core/api-error';
import { DialogFrame } from './dialog-frame';
@Component({
selector: 'app-group-members',
imports: [DialogFrame],
template: `
<app-dialog-frame
[title]="'Dansers in ' + group().category"
[busy]="busy()"
(dismissed)="closed.emit()"
>
<p class="muted">
Koppel dansers aan deze groep. Een danser kan in meerdere groepen zitten. Wijzigingen worden
direct opgeslagen.
</p>
@if (error()) {
<p class="alert error" role="alert">{{ error() }}</p>
}
<p class="member-count" role="status">
{{
busy()
? 'Koppeling opslaan…'
: memberIds().length + ' van ' + dancers().length + ' dansers gekoppeld'
}}
</p>
<div class="member-list" [attr.aria-busy]="busy()">
@for (dancer of sortedDancers(); track dancer.id) {
<label class="member-row"
><span aria-hidden="true" class="avatar small">{{
dancer.name.charAt(0).toUpperCase()
}}</span>
<span>{{ dancer.name }}</span>
<input
type="checkbox"
[checked]="memberIds().includes(dancer.id)"
[disabled]="busy()"
(change)="toggle(dancer.id, $event)"
/>
</label>
} @empty {
<p class="empty-inline">
Er zijn nog geen dansers. Voeg eerst een danser toe via het tabblad Dansers.
</p>
}
</div>
<div class="dialog-actions">
<button class="button primary" type="button" [disabled]="busy()" (click)="closed.emit()">
Gereed
</button>
</div>
</app-dialog-frame>
`,
})
export class GroupMembers implements OnInit {
readonly schoolId = input.required<number>();
readonly group = input.required<DanceGroup>();
readonly dancers = input.required<Dancer[]>();
readonly closed = output<void>();
readonly changed = output<number[]>();
readonly memberIds = signal<number[]>([]);
readonly busy = signal(false);
readonly error = signal('');
readonly sortedDancers = computed(() =>
[...this.dancers()].sort((a, b) => a.name.localeCompare(b.name, 'nl')),
);
private readonly api = inject(SchoolApi);
ngOnInit(): void {
this.memberIds.set([...this.group().dancerIds]);
}
async toggle(dancerId: number, event: Event): Promise<void> {
const checkbox = event.target as HTMLInputElement;
const add = checkbox.checked;
if (this.busy()) {
checkbox.checked = this.memberIds().includes(dancerId);
return;
}
this.busy.set(true);
this.error.set('');
try {
await firstValueFrom(this.api.membership(this.schoolId(), this.group().id, dancerId, add));
this.memberIds.update((ids) =>
add ? [...ids, dancerId] : ids.filter((id) => id !== dancerId),
);
checkbox.checked = add;
this.changed.emit(this.memberIds());
} catch (error: unknown) {
checkbox.checked = this.memberIds().includes(dancerId);
this.error.set(apiError(error));
} finally {
this.busy.set(false);
}
}
}