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,423 @@
<a routerLink="/schools" class="back-link">← Alle dansscholen</a>
@if (loading()) {
<div class="loading-state" role="status"><span class="spinner"></span> Dansschool laden…</div>
} @else if (!data()) {
<div class="empty-state">
<h1>Dansschool niet beschikbaar</h1>
<p role="alert">{{ error() }}</p>
<button type="button" class="button secondary" (click)="retry()">Opnieuw proberen</button>
</div>
} @else if (data(); as context) {
<div class="page-heading school-heading">
<div class="school-title">
<span aria-hidden="true" class="avatar large">{{
context.school.name.charAt(0).toUpperCase()
}}</span>
<div>
<p class="eyebrow">MIJN DANSSCHOOL</p>
<h1>{{ context.school.name }}</h1>
<p class="lede">Jouw dansers. Jouw ritme. Jouw podium.</p>
</div>
</div>
<button
type="button"
class="button secondary"
(click)="editor.set({ kind: 'school', value: context.school })"
>
School bewerken
</button>
</div>
<nav class="tabs" aria-label="Dansschool onderdelen">
@for (tab of tabs; track tab.value) {
<a
[routerLink]="['/schools', context.school.id, tab.value]"
routerLinkActive="active"
ariaCurrentWhenActive="page"
>{{ tab.label }}
@switch (tab.value) {
@case ('dancers') {
<span>{{ context.dancers.length }}</span>
}
@case ('groups') {
<span>{{ context.groups.length }}</span>
}
@case ('shows') {
<span>{{ context.shows.length }}</span>
}
}
</a>
}
</nav>
@if (notice()) {
<div class="alert success" role="status">
{{ notice()
}}<button type="button" aria-label="Melding sluiten" (click)="notice.set('')">×</button>
</div>
}
@if (error()) {
<div class="alert error" role="alert">
{{ error()
}}<button type="button" class="button secondary" (click)="reload()">Vernieuwen</button>
</div>
}
@switch (section()) {
@case ('overview') {
<div class="stats-grid">
<a class="stat" [routerLink]="['/schools', context.school.id, 'dancers']"
><span class="stat-label">Dansers <span aria-hidden="true">↗</span></span
><strong>{{ context.dancers.length }}</strong
><span class="stat-caption">Alle talenten bij elkaar</span></a
>
<a class="stat" [routerLink]="['/schools', context.school.id, 'groups']"
><span class="stat-label">Dansgroepen <span aria-hidden="true">↗</span></span
><strong>{{ context.groups.length }}</strong
><span class="stat-caption">Samen de vloer op</span></a
>
<a class="stat" [routerLink]="['/schools', context.school.id, 'shows']"
><span class="stat-label">Shows <span aria-hidden="true">↗</span></span
><strong>{{ context.shows.length }}</strong
><span class="stat-caption">Momenten om naar uit te kijken</span></a
>
</div>
<div class="overview-grid">
<section class="panel">
<p class="eyebrow">DE VOLGENDE STAP</p>
<h2>Breng je school in beweging</h2>
<p class="muted">Een goede show begint in de studio. Zorg dat iedereen een plek heeft.</p>
<div class="quick-actions">
<button type="button" (click)="editor.set({ kind: 'dancer' })">
<span class="step-number">01</span
><span
><strong>Voeg een danser toe</strong
><small>Maak ruimte voor nieuw talent</small></span
><span aria-hidden="true">+</span>
</button>
<button type="button" (click)="editor.set({ kind: 'group' })">
<span class="step-number">02</span
><span
><strong>Stel een dansgroep samen</strong
><small>Verbind dansers met dezelfde passie</small></span
><span aria-hidden="true">+</span>
</button>
<button type="button" (click)="editor.set({ kind: 'show' })">
<span class="step-number">03</span
><span
><strong>Plan een show</strong
><small>Zet het volgende podium op de kalender</small></span
><span aria-hidden="true">+</span>
</button>
</div>
</section>
<section class="show-spotlight">
<div class="spotlight-decoration" aria-hidden="true">✳</div>
<p class="eyebrow">IN DE SPOTLIGHT</p>
@if (upcoming(); as show) {
<h2>{{ show.name }}</h2>
<p class="spotlight-date">{{ show.date | date: 'd MMMM yyyy' }}</p>
<p>Drie dagdelen. Eén gedeelde passie.</p>
<a class="button light" [routerLink]="['/schools', context.school.id, 'shows']"
>Shows bekijken <span aria-hidden="true">→</span></a
>
} @else {
<h2>Klaar voor<br />het grote podium?</h2>
<p>Maak een show aan en geef je dansers iets om naartoe te werken.</p>
<button type="button" class="button light" (click)="editor.set({ kind: 'show' })">
Eerste show plannen <span aria-hidden="true">→</span>
</button>
}
</section>
</div>
<section class="danger-zone">
<div>
<h3>Dansschool verwijderen</h3>
<p>Verwijdert ook alle dansers, groepen en shows van deze school.</p>
</div>
<button
class="button danger-outline"
type="button"
(click)="
askDelete({
kind: 'school',
id: context.school.id,
name: context.school.name,
explanation:
'Alle dansers, dansgroepen en shows van deze school worden ook definitief verwijderd.',
})
"
>
School verwijderen
</button>
</section>
}
@case ('dancers') {
<div class="section-heading">
<div>
<h2>Dansers</h2>
<p class="muted">De mensen die jouw school in beweging brengen.</p>
</div>
<button type="button" class="button primary" (click)="editor.set({ kind: 'dancer' })">
+ Danser toevoegen
</button>
</div>
@if (context.dancers.length) {
<label class="search-field"
><span aria-hidden="true">⌕</span
><input
type="search"
aria-label="Zoek een danser"
placeholder="Zoek op naam…"
[value]="search()"
(input)="search.set($any($event.target).value)"
/></label>
}
<div class="table-container">
<table>
<caption class="sr-only">
Dansers in
{{
context.school.name
}}
</caption>
<thead>
<tr>
<th>Danser</th>
<th>Geboortedatum</th>
<th>Dansgroepen</th>
<th><span class="sr-only">Acties</span></th>
</tr>
</thead>
<tbody>
@for (dancer of dancers(); track dancer.id) {
<tr>
<td>
<div class="person">
<span aria-hidden="true" class="avatar small">{{
dancer.name.charAt(0).toUpperCase()
}}</span
><strong>{{ dancer.name }}</strong>
</div>
</td>
<td class="nowrap">{{ dancer.birthDate | date: 'd MMM yyyy' }}</td>
<td class="muted">{{ groupNames(dancer.id) }}</td>
<td>
<div class="row-actions">
<button
class="text-button"
type="button"
[attr.aria-label]="dancer.name + ' bewerken'"
(click)="editor.set({ kind: 'dancer', value: dancer })"
>
Bewerken</button
><button
class="text-button destructive"
type="button"
[attr.aria-label]="dancer.name + ' verwijderen'"
(click)="
askDelete({
kind: 'dancer',
id: dancer.id,
name: dancer.name,
explanation:
'De danser wordt ook uit alle dansgroepen van deze school verwijderd.',
})
"
>
Verwijderen
</button>
</div>
</td>
</tr>
} @empty {
<tr>
<td colspan="4">
<div class="empty-state">
<h3>{{ search() ? 'Geen dansers gevonden' : 'De dansvloer is nog leeg' }}</h3>
<p>
{{
search()
? 'Probeer een andere naam.'
: 'Voeg je eerste danser toe. Daarna kun je die aan één of meerdere groepen koppelen.'
}}
</p>
@if (!search()) {
<button
class="button primary"
type="button"
(click)="editor.set({ kind: 'dancer' })"
>
Danser toevoegen
</button>
}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
@case ('groups') {
<div class="section-heading">
<div>
<h2>Dansgroepen</h2>
<p class="muted">Iedere groep een eigen stijl. Iedere danser een plek.</p>
</div>
<button class="button primary" type="button" (click)="editor.set({ kind: 'group' })">
+ Dansgroep toevoegen
</button>
</div>
<div class="school-grid">
@for (group of context.groups; track group.id) {
<article class="group-card">
<div class="card-top">
<span class="group-symbol" aria-hidden="true">↗</span
><span class="tag">{{ groupType(group.type) }}</span>
</div>
<h3>{{ group.category }}</h3>
<p class="muted">
{{ group.startAge }}–{{ group.endAge }} jaar · {{ group.dancerIds.length }} dansers
</p>
<div class="tag-list">
@for (style of group.styles; track style) {
<span class="style-tag">{{ groupStyle(style) }}</span>
} @empty {
<span class="muted">Nog geen dansstijlen</span>
}
</div>
<button
class="button secondary full-width"
type="button"
[attr.aria-label]="'Dansers beheren voor ' + group.category"
(click)="members.set(group)"
>
Dansers beheren <span aria-hidden="true">→</span>
</button>
<div class="card-bottom">
<button
class="text-button"
type="button"
[attr.aria-label]="group.category + ' bewerken'"
(click)="editor.set({ kind: 'group', value: group })"
>
Bewerken</button
><button
class="text-button destructive"
type="button"
[attr.aria-label]="group.category + ' verwijderen'"
(click)="
askDelete({
kind: 'group',
id: group.id,
name: group.category,
explanation:
'De groep wordt verwijderd. De dansers blijven beschikbaar in de school.',
})
"
>
Verwijderen
</button>
</div>
</article>
} @empty {
<div class="empty-state full-width">
<span class="empty-symbol" aria-hidden="true">↗</span>
<h2>Samen vind je het ritme</h2>
<p>Maak een dansgroep aan en koppel er dansers aan.</p>
<button class="button primary" type="button" (click)="editor.set({ kind: 'group' })">
Eerste dansgroep aanmaken
</button>
</div>
}
</div>
}
@case ('shows') {
<div class="section-heading">
<div>
<h2>Shows</h2>
<p class="muted">Van maanden oefenen naar een onvergetelijk optreden.</p>
</div>
<button class="button primary" type="button" (click)="editor.set({ kind: 'show' })">
+ Show toevoegen
</button>
</div>
<div class="show-list">
@for (show of shows(); track show.id) {
<article class="show-card">
<div class="date-tile">
<strong>{{ show.date | date: 'dd' }}</strong
><span>{{ show.date | date: 'MMM yyyy' }}</span>
</div>
<div class="show-details">
<h3>{{ show.name }}</h3>
<div class="tag-list">
<span class="style-tag">Ochtend</span><span class="style-tag">Middag</span
><span class="style-tag">Avond</span>
</div>
<p class="muted">Groepen indelen over dagdelen is nog niet beschikbaar.</p>
</div>
<div class="row-actions">
<button
class="text-button"
type="button"
[attr.aria-label]="show.name + ' bewerken'"
(click)="editor.set({ kind: 'show', value: show })"
>
Bewerken</button
><button
class="text-button destructive"
type="button"
[attr.aria-label]="show.name + ' verwijderen'"
(click)="
askDelete({
kind: 'show',
id: show.id,
name: show.name,
explanation: 'Deze show wordt definitief verwijderd.',
})
"
>
Verwijderen
</button>
</div>
</article>
} @empty {
<div class="empty-state">
<span class="empty-symbol" aria-hidden="true">✦</span>
<h2>Het podium wacht op je</h2>
<p>Plan een show met een ochtend-, middag- en avonddeel.</p>
<button class="button primary" type="button" (click)="editor.set({ kind: 'show' })">
Eerste show plannen
</button>
</div>
}
</div>
}
}
@if (editor(); as target) {
<app-entity-editor
[target]="target"
[schoolId]="context.school.id"
(closed)="editor.set(null)"
(saved)="saved()"
/>
}
@if (members(); as group) {
<app-group-members
[group]="group"
[schoolId]="context.school.id"
[dancers]="context.dancers"
(closed)="members.set(null)"
(changed)="membershipChanged($event)"
/>
}
@if (deleting(); as target) {
<app-confirm-dialog
[name]="target.name"
[explanation]="target.explanation"
[busy]="deleteBusy()"
[error]="deleteError()"
(closed)="deleting.set(null)"
(confirmed)="confirmDelete()"
/>
}
}

View File

@@ -0,0 +1,162 @@
import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterLink, RouterLinkActive } from '@angular/router';
import { catchError, EMPTY, firstValueFrom, switchMap } from 'rxjs';
import {
DanceGroup,
EditorTarget,
GROUP_STYLES,
GROUP_TYPES,
ResourceKind,
SchoolData,
} from '../core/api-models';
import { apiError } from '../core/api-error';
import { SchoolApi } from '../core/school-api';
import { EntityEditor } from '../shared/entity-editor';
import { ConfirmDialog } from '../shared/confirm-dialog';
import { GroupMembers } from '../shared/group-members';
interface DeleteTarget {
kind: ResourceKind;
id: number;
name: string;
explanation: string;
}
@Component({
selector: 'app-school-page',
imports: [DatePipe, RouterLink, RouterLinkActive, EntityEditor, ConfirmDialog, GroupMembers],
templateUrl: './school-page.html',
})
export class SchoolPage implements OnInit {
private readonly api = inject(SchoolApi);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
readonly data = signal<SchoolData | null>(null);
readonly section = signal('overview');
readonly loading = signal(true);
readonly error = signal('');
readonly notice = signal('');
readonly editor = signal<EditorTarget | null>(null);
readonly members = signal<DanceGroup | null>(null);
readonly deleting = signal<DeleteTarget | null>(null);
readonly deleteBusy = signal(false);
readonly deleteError = signal('');
readonly search = signal('');
readonly dancers = computed(() =>
[...(this.data()?.dancers ?? [])]
.filter((d) => d.name.toLocaleLowerCase('nl').includes(this.search().toLocaleLowerCase('nl')))
.sort((a, b) => a.name.localeCompare(b.name, 'nl')),
);
readonly shows = computed(() =>
[...(this.data()?.shows ?? [])].sort((a, b) => a.date.localeCompare(b.date)),
);
readonly today = new Date().toLocaleDateString('en-CA');
readonly upcoming = computed(() => this.shows().find((show) => show.date >= this.today));
readonly tabs = [
{ value: 'overview', label: 'Overzicht' },
{ value: 'dancers', label: 'Dansers' },
{ value: 'groups', label: 'Dansgroepen' },
{ value: 'shows', label: 'Shows' },
];
ngOnInit(): void {
this.route.paramMap
.pipe(
switchMap((params) => {
this.loading.set(true);
this.error.set('');
this.search.set('');
this.editor.set(null);
this.members.set(null);
this.deleting.set(null);
this.section.set(params.get('section') ?? 'overview');
return this.api.schoolData(Number(params.get('schoolId'))).pipe(
catchError((error) => {
this.data.set(null);
this.error.set(apiError(error));
this.loading.set(false);
return EMPTY;
}),
);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((data) => {
this.data.set(data);
this.loading.set(false);
});
}
async reload(): Promise<void> {
const id = this.data()?.school.id;
if (id === undefined) return;
this.error.set('');
try {
const data = await firstValueFrom(this.api.schoolData(id));
if (this.data()?.school.id === id) this.data.set(data);
} catch (error: unknown) {
this.error.set(apiError(error));
}
}
retry(): void {
window.location.reload();
}
async saved(): Promise<void> {
this.editor.set(null);
this.notice.set('Wijzigingen opgeslagen.');
await this.reload();
}
groupType(value: string): string {
return GROUP_TYPES.find((option) => option.value === value)?.label ?? value;
}
groupStyle(value: string): string {
return GROUP_STYLES.find((option) => option.value === value)?.label ?? value;
}
groupNames(dancerId: number): string {
return (
this.data()
?.groups.filter((group) => group.dancerIds.includes(dancerId))
.map((group) => group.category)
.join(', ') || 'Nog geen groep'
);
}
askDelete(target: DeleteTarget): void {
this.deleteError.set('');
this.deleting.set(target);
}
membershipChanged(ids: number[]): void {
const groupId = this.members()?.id;
this.data.update((data) =>
data
? {
...data,
groups: data.groups.map((group) =>
group.id === groupId ? { ...group, dancerIds: [...ids] } : group,
),
}
: data,
);
}
async confirmDelete(): Promise<void> {
const target = this.deleting();
const schoolId = this.data()?.school.id;
if (!target || schoolId === undefined || this.deleteBusy()) return;
this.deleteBusy.set(true);
this.deleteError.set('');
try {
await firstValueFrom(this.api.delete(target.kind, schoolId, target.id));
this.deleting.set(null);
if (target.kind === 'school') await this.router.navigate(['/schools']);
else {
this.notice.set(`${target.name} is verwijderd.`);
await this.reload();
}
} catch (error: unknown) {
this.deleteError.set(apiError(error));
} finally {
this.deleteBusy.set(false);
}
}
}

View File

@@ -0,0 +1,93 @@
<div class="page-heading">
<div>
<p class="eyebrow">JOUW ORGANISATIE</p>
<h1>Alle ruimte om te dansen.</h1>
<p class="lede">Van de eerste les tot het grote podium. Beheer je dansschool op één plek.</p>
</div>
<button type="button" class="button primary" (click)="editor.set(true)">
<span aria-hidden="true">+</span> Dansschool toevoegen
</button>
</div>
@if (notice()) {
<div class="alert success" role="status">
{{ notice()
}}<button type="button" aria-label="Melding sluiten" (click)="notice.set('')">×</button>
</div>
}
@if (loading()) {
<div class="loading-state" role="status"><span class="spinner"></span> Dansscholen laden…</div>
} @else if (error()) {
<div class="alert error" role="alert">
<p>{{ error() }}</p>
<button class="button secondary" type="button" (click)="load()">Opnieuw proberen</button>
</div>
} @else {
<div class="stats-grid">
<div class="stat">
<span class="stat-label">Dansscholen</span><strong>{{ schools().length }}</strong
><span class="stat-caption">Een plek voor iedere danser</span>
</div>
<div class="stat">
<span class="stat-label">Dansers</span><strong>{{ totalDancers() }}</strong
><span class="stat-caption">Samen in beweging</span>
</div>
<div class="stat">
<span class="stat-label">Dansgroepen</span><strong>{{ totalGroups() }}</strong
><span class="stat-caption">Ieder met een eigen ritme</span>
</div>
</div>
<div class="section-heading">
<h2>
Mijn dansscholen <span class="count-badge">{{ schools().length }}</span>
</h2>
<span class="muted">Selecteer een school om aan de slag te gaan</span>
</div>
<div class="school-grid">
@for (school of schools(); track school.id) {
<a class="school-card" [routerLink]="['/schools', school.id, 'overview']">
<div class="card-top">
<span aria-hidden="true" class="avatar">{{ school.name.charAt(0).toUpperCase() }}</span
><span class="round-arrow" aria-hidden="true">↗</span>
</div>
<h3>{{ school.name }}</h3>
<p>Dansschool</p>
<div class="card-meta">
<span
><strong>{{ school.dancerIds.length }}</strong> dansers</span
><span
><strong>{{ school.danceGroupIds.length }}</strong> groepen</span
><span
><strong>{{ school.showIds.length }}</strong> shows</span
>
</div>
<div class="card-link">Dansschool beheren <span aria-hidden="true">→</span></div>
</a>
} @empty {
<div class="empty-state full-width">
<span class="empty-symbol" aria-hidden="true">✳</span>
<h2>Hier begint jouw dansschool</h2>
<p>Maak een school aan en geef je dansers, groepen en shows een thuis.</p>
<button class="button primary" type="button" (click)="editor.set(true)">
Eerste dansschool aanmaken
</button>
</div>
}
</div>
<aside class="tip-panel">
<span class="tip-icon" aria-hidden="true">✦</span>
<div>
<h3>Van studio naar spotlight</h3>
<p>
Voeg je dansers toe, stel groepen samen en maak alvast een show aan. Zo bouw je stap voor
stap aan je volgende optreden.
</p>
</div>
</aside>
}
@if (editor()) {
<app-entity-editor
[target]="{ kind: 'school' }"
(closed)="editor.set(false)"
(saved)="created()"
/>
}

View File

@@ -0,0 +1,55 @@
import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { RouterLink } from '@angular/router';
import { School } from '../core/api-models';
import { SchoolApi } from '../core/school-api';
import { apiError } from '../core/api-error';
import { EntityEditor } from '../shared/entity-editor';
@Component({
selector: 'app-schools-page',
imports: [RouterLink, EntityEditor],
templateUrl: './schools-page.html',
})
export class SchoolsPage implements OnInit {
private readonly api = inject(SchoolApi);
private readonly destroyRef = inject(DestroyRef);
readonly schools = signal<School[]>([]);
readonly loading = signal(true);
readonly error = signal('');
readonly editor = signal(false);
readonly notice = signal('');
readonly totalDancers = computed(() =>
this.schools().reduce((sum, school) => sum + school.dancerIds.length, 0),
);
readonly totalGroups = computed(() =>
this.schools().reduce((sum, school) => sum + school.danceGroupIds.length, 0),
);
ngOnInit(): void {
this.load();
}
load(): void {
this.loading.set(true);
this.error.set('');
this.api
.schools()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (schools) => {
this.schools.set(schools);
this.loading.set(false);
},
error: (error) => {
this.error.set(apiError(error));
this.loading.set(false);
},
});
}
created(): void {
this.editor.set(false);
this.notice.set(
'Dansschool aangemaakt. Open de school om dansers, groepen en shows toe te voegen.',
);
this.load();
}
}