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

@@ -1,5 +1,13 @@
# Changelog # Changelog
## [Unreleased]
### Added
- Angular 22 frontend in `agiledancers-fe` with Dutch-language school management screens.
- Forms for dancers, groups and shows, including multiple group memberships and deletion confirmation.
- Development proxy to the Quarkus REST API, frontend unit tests and end-to-end browser tests.
## [1.0] - 2026-10-27 ## [1.0] - 2026-10-27
### Added ### Added

View File

@@ -147,3 +147,24 @@ Run the HTTP integration tests with:
```sh ```sh
mvn -pl agiledancers-be -Dtest=SchoolManagementResourceTest test mvn -pl agiledancers-be -Dtest=SchoolManagementResourceTest test
``` ```
## Angular frontend
The Angular 22 frontend is in [`agiledancers-fe`](agiledancers-fe/README.md),
next to `agiledancers-be`. It provides Dutch-language screens to manage schools,
dancers, groups, memberships and shows through the REST API.
Use Node.js 22.22.3+ (22.x), 24.15.0+ (24.x), or 26+.
Start the backend as described above, then run in a second terminal:
```sh
cd agiledancers-fe
npm ci
npm start
```
Open <http://localhost:4200>. The development proxy forwards API requests to the
backend on port 8080. Frontend builds and tests run independently of Maven:
`npm run build` and `npm test`. See the frontend README for browser tests and
production hosting requirements.

View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

48
agiledancers-fe/.gitignore vendored Normal file
View File

@@ -0,0 +1,48 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db
# Browser test artifacts
/test-results
/playwright-report

1
agiledancers-fe/.nvmrc Normal file
View File

@@ -0,0 +1 @@
22.22.3

View File

@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}

View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
agiledancers-fe/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

42
agiledancers-fe/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}

86
agiledancers-fe/README.md Normal file
View File

@@ -0,0 +1,86 @@
# Agile Dancers frontend
Angular 22 application for managing dance schools through the existing Quarkus
REST API.
## Requirements
- Node.js 22.22.3 or newer in the 22.x line, Node.js 24.15.0 or newer in the 24.x
line, or Node.js 26+. See [Angular version compatibility](https://angular.dev/reference/versions).
- npm and the Java 25 / Maven backend setup described in the repository README.
An `.nvmrc` is included. With nvm installed:
```sh
nvm install
nvm use
npm ci
```
## Run locally
Start the backend from the repository root:
```sh
mvn -pl agiledancers-be quarkus:dev
```
In a second terminal, from `agiledancers-fe`:
```sh
npm ci
npm start
```
Open <http://localhost:4200>. The Angular development proxy forwards `/api/**`
to `http://127.0.0.1:8080`. Update `proxy.conf.json` to use another backend address.
## Screens
- School overview: list schools and create a school.
- School dashboard: summary, quick actions, next show, rename and delete a school.
- Dancers: create, edit, search and delete dancers; see their group memberships.
- Groups: create, edit and delete groups; manage dancers in each group.
- Shows: create, edit and delete shows; see the three automatically created day parts.
Deleting requires confirmation. Errors leave entered form values intact.
Membership changes are saved immediately, and a dancer can belong to multiple
groups. Assigning groups to show day parts is not available yet.
The backend currently stores data in memory, so restarting it clears the data.
## Checks
```sh
npm test
npm run build
```
Unit tests cover API URLs and payloads, ID zero, form validation, and save failures.
The production build is written to `dist/agiledancers-fe/browser`.
When deploying it, configure the web server to forward `/api/**` to Quarkus and
serve `index.html` for application routes such as `/schools/0/overview`.
The development proxy is not included in the production bundle.
## Browser tests
Keep the backend running on port 8080, then run:
```sh
npx playwright install chromium
npm run e2e
```
Playwright starts the frontend on port 4200. The tests create a uniquely named
school, exercise CRUD and multiple group memberships against the real backend,
check mobile layout, and clean up the school afterwards. A second test checks
connection failure and retry behavior. Tests use only their own school data.
To use an existing Chrome installation instead of downloading Chromium:
```sh
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chrome npm run e2e
```
Browser test screenshots and failure traces are written to the ignored
`test-results` directory.

View File

@@ -0,0 +1,72 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": false
},
"newProjectRoot": "projects",
"projects": {
"agiledancers-fe": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": ["src/styles.css"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "agiledancers-fe:build:production"
},
"development": {
"buildTarget": "agiledancers-fe:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}

View File

@@ -0,0 +1,162 @@
import { test, expect, Page } from '@playwright/test';
const backend = 'http://127.0.0.1:8080';
let schoolToDelete: number | undefined;
test.afterEach(async ({ request }) => {
if (schoolToDelete !== undefined) {
await request.delete(`${backend}/api/dance-schools/${schoolToDelete}`);
schoolToDelete = undefined;
}
});
async function tab(page: Page, label: string) {
await page
.getByRole('navigation', { name: 'Dansschool onderdelen' })
.getByRole('link', { name: new RegExp(`^${label}`) })
.click();
}
async function save(page: Page) {
await page.getByRole('dialog').getByRole('button', { name: 'Opslaan', exact: true }).click();
await expect(page.getByRole('dialog')).toHaveCount(0);
}
async function confirmDelete(page: Page) {
await page
.getByRole('dialog')
.getByRole('button', { name: 'Definitief verwijderen', exact: true })
.click();
await expect(page.getByRole('dialog')).toHaveCount(0);
}
test('manages a school, shared memberships and shows through the real REST API', async ({
page,
}) => {
const schoolName = `E2E Studio ${Date.now()}`;
let schoolId: number | undefined;
const browserErrors: string[] = [];
page.on('pageerror', (error) => browserErrors.push(error.message));
await page.goto('/');
await page.getByRole('button', { name: 'Dansschool toevoegen' }).click();
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill(schoolName);
const created = page.waitForResponse(
(response) =>
response.url().endsWith('/api/dance-schools') && response.request().method() === 'POST',
);
await save(page);
schoolId = (await (await created).json()).id;
schoolToDelete = schoolId;
await page.getByRole('link', { name: new RegExp(schoolName) }).click();
await expect(page.getByRole('heading', { name: schoolName, exact: true })).toBeVisible();
await page.getByRole('button', { name: 'School bewerken' }).click();
await page
.getByRole('dialog')
.getByLabel('Naam', { exact: true })
.fill(`${schoolName} vernieuwd`);
await save(page);
await expect(
page.getByRole('heading', { name: `${schoolName} vernieuwd`, exact: true }),
).toBeVisible();
await page.getByRole('button', { name: /Voeg een danser toe/ }).click();
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Sam Testdanser');
await page.getByLabel('Geboortedatum').fill('2010-05-12');
await save(page);
await tab(page, 'Dansers');
await expect(page.getByRole('cell', { name: 'Sam Testdanser', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Sam Testdanser bewerken' }).click();
await page.getByLabel('Geboortedatum').fill('2011-06-13');
await save(page);
await expect(page.getByRole('cell', { name: '13 jun 2011' })).toBeVisible();
await tab(page, 'Dansgroepen');
for (const name of ['Hiphop Teens', 'Jazz Teens']) {
await page.getByRole('button', { name: 'Dansgroep toevoegen' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByLabel('Naam / categorie').fill(name);
await dialog.getByLabel('Leeftijd vanaf').fill('12');
await dialog.getByLabel('Leeftijd tot en met').fill('18');
await dialog.getByLabel(name.startsWith('Hiphop') ? 'Hiphop' : 'Jazz', { exact: true }).check();
await save(page);
await page.getByRole('button', { name: `Dansers beheren voor ${name}`, exact: true }).click();
await page
.getByRole('dialog')
.locator('.member-row')
.filter({ hasText: 'Sam Testdanser' })
.getByRole('checkbox')
.check();
await expect(page.getByRole('dialog').getByText('1 van 1 dansers gekoppeld')).toBeVisible();
await page.getByRole('button', { name: 'Gereed', exact: true }).click();
await expect(
page
.getByRole('article')
.filter({ has: page.getByRole('heading', { name, exact: true }) })
.getByText(/1 dansers/),
).toBeVisible();
}
await page.getByRole('button', { name: 'Hiphop Teens bewerken' }).click();
await page.getByLabel('Naam / categorie').fill('Hiphop Selectie');
await page.getByLabel('Type', { exact: true }).selectOption({ label: 'Selectie' });
await save(page);
await expect(
page
.getByRole('article')
.filter({ hasText: 'Hiphop Selectie' })
.getByText(/1 dansers/),
).toBeVisible();
await page.screenshot({ path: 'test-results/groups-desktop.png', fullPage: true });
await tab(page, 'Shows');
await page.getByRole('button', { name: 'Show toevoegen' }).click();
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Zomershow');
await page.getByLabel('Datum', { exact: true }).fill('2027-06-12');
await save(page);
await expect(page.getByText('Ochtend', { exact: true })).toBeVisible();
await expect(page.getByText('Middag', { exact: true })).toBeVisible();
await expect(page.getByText('Avond', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Zomershow bewerken' }).click();
await page.getByLabel('Datum', { exact: true }).fill('2027-07-01');
await save(page);
await expect(page.locator('.date-tile')).toContainText('jul 2027');
await tab(page, 'Overzicht');
await page.setViewportSize({ width: 390, height: 844 });
await expect(
page.getByRole('heading', { name: `${schoolName} vernieuwd`, exact: true }),
).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(
true,
);
await page.screenshot({ path: 'test-results/overview-mobile.png', fullPage: true });
await page.setViewportSize({ width: 1440, height: 1000 });
await page.screenshot({ path: 'test-results/overview-desktop.png', fullPage: true });
await tab(page, 'Dansers');
await page.getByRole('button', { name: 'Sam Testdanser verwijderen' }).click();
await confirmDelete(page);
await expect(page.getByText('De dansvloer is nog leeg')).toBeVisible();
await tab(page, 'Dansgroepen');
await expect(page.getByText(/0 dansers/)).toHaveCount(2);
await page.getByRole('button', { name: 'Hiphop Selectie verwijderen' }).click();
await confirmDelete(page);
await expect(page.getByRole('heading', { name: 'Hiphop Selectie', exact: true })).toHaveCount(0);
await tab(page, 'Shows');
await page.getByRole('button', { name: 'Zomershow verwijderen' }).click();
await confirmDelete(page);
await expect(page.getByText('Het podium wacht op je')).toBeVisible();
await tab(page, 'Overzicht');
await page.getByRole('button', { name: 'School verwijderen', exact: true }).click();
await confirmDelete(page);
await expect(page).toHaveURL(/\/schools$/);
await expect(page.getByRole('link', { name: new RegExp(schoolName) })).toHaveCount(0);
expect(browserErrors).toEqual([]);
});
test('shows a useful connection error and recovers on retry', async ({ page }) => {
await page.route('**/api/dance-schools', (route) => route.abort('connectionfailed'));
await page.goto('/schools');
await expect(page.getByRole('alert')).toContainText('niet bereikbaar');
await page.unroute('**/api/dance-schools');
await page.getByRole('button', { name: 'Opnieuw proberen' }).click();
await expect(page.getByRole('heading', { name: /Mijn dansscholen/ })).toBeVisible();
});

7015
agiledancers-fe/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
{
"name": "agiledancers-fe",
"version": "1.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test --watch=false",
"e2e": "playwright test"
},
"private": true,
"dependencies": {
"@angular/common": "22.2.3",
"@angular/compiler": "22.2.3",
"@angular/core": "22.2.3",
"@angular/forms": "22.2.3",
"@angular/platform-browser": "22.2.3",
"@angular/router": "22.2.3",
"rxjs": "7.8.2",
"tslib": "2.8.1"
},
"devDependencies": {
"@angular/build": "22.2.4",
"@angular/cli": "22.2.4",
"@angular/compiler-cli": "22.2.3",
"@playwright/test": "1.64.1",
"jsdom": "30.1.3",
"prettier": "3.9.10",
"typescript": "6.0.3",
"vitest": "5.0.5"
},
"engines": {
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
}
}

View File

@@ -0,0 +1,26 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
workers: 1,
retries: 0,
timeout: 60_000,
reporter: 'list',
use: {
...devices['Desktop Chrome'],
baseURL: 'http://127.0.0.1:4200',
trace: 'retain-on-failure',
actionTimeout: 15_000,
screenshot: 'only-on-failure',
launchOptions: process.env['PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH']
? { executablePath: process.env['PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH'] }
: {},
},
webServer: {
command: 'npm start -- --host 127.0.0.1 --port 4200',
url: 'http://127.0.0.1:4200',
reuseExistingServer: !process.env['CI'],
timeout: 60_000,
},
});

View File

@@ -0,0 +1,7 @@
{
"/api/**": {
"target": "http://127.0.0.1:8080",
"secure": false,
"changeOrigin": true
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect width="40" height="40" rx="12" fill="#185744"/><path d="M12 27V16a5 5 0 0 1 10 0v11M12 21h10m1-11h7v7m0-7-9 9" stroke="#ddf4b6" stroke-width="3" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 231 B

View File

@@ -0,0 +1,17 @@
import { ApplicationConfig, LOCALE_ID, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { registerLocaleData } from '@angular/common';
import localeNl from '@angular/common/locales/nl';
import { provideRouter, withInMemoryScrolling } from '@angular/router';
import { routes } from './app.routes';
registerLocaleData(localeNl);
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideHttpClient(),
provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'enabled' })),
{ provide: LOCALE_ID, useValue: 'nl-NL' },
],
};

View File

@@ -0,0 +1,239 @@
:host {
display: block;
min-height: 100vh;
}
.app-layout {
display: grid;
grid-template-columns: 250px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
display: flex;
flex-direction: column;
padding: 36px 22px 22px;
background: #fff;
border-right: 1px solid var(--line);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
color: var(--ink);
font-size: 21px;
font-weight: 800;
letter-spacing: -0.8px;
padding: 0 8px;
}
.brand-mark {
background: var(--forest);
color: var(--lime);
width: 42px;
height: 44px;
border-radius: 13px 13px 13px 3px;
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
position: relative;
}
.brand-mark > span {
font-size: 23px;
position: absolute;
right: 3px;
top: -5px;
}
.brand-light {
font-weight: 450;
}
.brand small {
font-size: 8px;
display: block;
letter-spacing: 1.8px;
font-weight: 600;
margin-top: 5px;
color: var(--muted);
}
.nav-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 1.8px;
color: var(--muted);
margin: 60px 14px 14px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
font-weight: 650;
padding: 15px 12px;
border-radius: 9px;
}
.nav-item.selected {
background: #eaf1ec;
color: var(--forest);
}
.nav-item svg {
width: 20px;
height: 20px;
}
.nav-item > span {
margin-left: auto;
}
.sidebar-note {
margin-top: auto;
padding: 28px 16px;
}
.sidebar-note > span {
color: #569473;
font-size: 45px;
}
.sidebar-note h3 {
font-weight: 600;
font-size: 24px;
letter-spacing: -0.8px;
line-height: 1.25;
margin: 14px 0;
}
.sidebar-note p {
font-size: 12px;
line-height: 1.8;
color: var(--muted);
}
.sidebar-footer {
display: flex;
align-items: center;
gap: 8px;
border-top: 1px solid var(--line);
padding: 22px 10px 0;
font-size: 11px;
color: var(--muted);
}
.sidebar-footer > span:last-child {
margin-left: auto;
}
.status-dot,
.topbar-dot {
width: 6px;
height: 6px;
background: #6a9c7b;
border-radius: 50%;
display: inline-block;
}
.main-column {
min-width: 0;
display: flex;
flex-direction: column;
}
.topbar {
height: 78px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 48px;
border-bottom: 1px solid var(--line);
background: #fff;
font-size: 12px;
font-weight: 550;
}
.topbar-right {
display: flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 11px;
}
main {
padding: 44px 48px 60px;
max-width: 1500px;
width: 100%;
margin: 0 auto;
flex: 1;
}
.page-footer {
margin: 0 48px;
border-top: 1px solid var(--line);
padding: 22px 0;
display: flex;
justify-content: space-between;
gap: 20px;
color: var(--muted);
font-size: 10px;
}
@media (min-width: 1600px) {
main {
padding: 58px 68px;
}
}
@media (max-width: 1100px) {
.app-layout {
grid-template-columns: 205px minmax(0, 1fr);
}
.sidebar {
padding-left: 14px;
padding-right: 14px;
}
.brand {
font-size: 18px;
gap: 8px;
}
main {
padding: 32px 28px 48px;
}
.topbar {
padding: 0 28px;
}
.page-footer {
margin: 0 28px;
}
}
@media (max-width: 760px) {
.app-layout {
display: block;
}
.sidebar {
position: static;
height: auto;
padding: 18px 20px;
flex-direction: row;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--line);
border-right: 0;
gap: 18px;
}
.brand {
padding: 0;
}
.brand-mark {
width: 34px;
height: 36px;
}
.nav-label,
.sidebar-note,
.sidebar-footer,
.nav-item svg,
.nav-item > span {
display: none;
}
.nav-item {
font-size: 11px;
padding: 12px;
}
.topbar {
height: 48px;
padding: 0 22px;
}
.topbar-right {
font-size: 10px;
}
main {
padding: 28px 20px 42px;
}
.page-footer {
margin: 0 20px;
}
}

View File

@@ -0,0 +1,46 @@
<a class="skip-link" href="#main-content">Naar inhoud</a>
<div class="app-layout">
<aside class="sidebar">
<a routerLink="/schools" class="brand" aria-label="Agile Dancers – alle dansscholen"
><span class="brand-mark" aria-hidden="true">a<span>↗</span></span
><span
>agile<span class="brand-light">dancers</span><small>IN BEWEGING, SAMEN.</small></span
></a
>
<p class="nav-label">WERKRUIMTE</p>
<nav aria-label="Hoofdnavigatie">
<a class="nav-item selected" routerLink="/schools"
><svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
aria-hidden="true"
>
<path d="m3 10 9-7 9 7v10H3Z" />
<path d="M9 20v-7h6v7" /></svg
>Mijn dansscholen<span aria-hidden="true">→</span></a
>
</nav>
<div class="sidebar-note">
<span aria-hidden="true">✳</span>
<h3>Alles begint<br />met beweging.</h3>
<p>Jij zorgt voor de dans.<br />Wij houden het overzicht.</p>
</div>
<div class="sidebar-footer">
<span class="status-dot" aria-hidden="true"></span> Agile Dancers <span>1.0</span>
</div>
</aside>
<div class="main-column">
<header class="topbar">
<span>Dansschoolbeheer</span
><span class="topbar-right"
><span class="topbar-dot" aria-hidden="true"></span> Studio workspace</span
>
</header>
<main id="main-content" tabindex="-1"><router-outlet /></main>
<footer class="page-footer">
<span>Gemaakt voor de mensen achter de dans.</span><span>Agile Dancers</span>
</footer>
</div>
</div>

View File

@@ -0,0 +1,21 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'schools' },
{
path: 'schools',
title: 'Mijn dansscholen · Agile Dancers',
loadComponent: () => import('./schools/schools-page').then((m) => m.SchoolsPage),
},
{ path: 'schools/:schoolId', pathMatch: 'full', redirectTo: 'schools/:schoolId/overview' },
{
path: 'schools/:schoolId/:section',
title: 'Dansschool beheren · Agile Dancers',
loadComponent: () => import('./schools/school-page').then((m) => m.SchoolPage),
canMatch: [
(_route, segments) =>
['overview', 'dancers', 'groups', 'shows'].includes(segments[2]?.path ?? ''),
],
},
{ path: '**', redirectTo: 'schools' },
];

View File

@@ -0,0 +1,19 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { App } from './app';
describe('Application shell', () => {
it('provides navigation and a keyboard skip link', async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter([])],
}).compileComponents();
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const page = fixture.nativeElement as HTMLElement;
expect(page.querySelector('nav[aria-label="Hoofdnavigatie"] a')?.getAttribute('href')).toBe(
'/schools',
);
expect(page.querySelector('.skip-link')?.getAttribute('href')).toBe('#main-content');
});
});

View File

@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet, RouterLink],
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {}

View File

@@ -0,0 +1,24 @@
import { HttpErrorResponse } from '@angular/common/http';
export function apiError(error: unknown): string {
if (!(error instanceof HttpErrorResponse)) return 'Er ging iets mis. Probeer het opnieuw.';
if (error.status === 0 || error.status === 502 || error.status === 504) {
return 'De server is niet bereikbaar. Controleer of de backend draait en probeer het opnieuw.';
}
if (error.status === 404) return 'Deze gegevens zijn niet meer beschikbaar. Vernieuw de pagina.';
const body: unknown = error.error;
if (body && typeof body === 'object') {
if ('message' in body && typeof body.message === 'string') return body.message;
if ('violations' in body && Array.isArray(body.violations)) {
const messages = body.violations
.map((v: unknown) =>
v && typeof v === 'object' && 'message' in v ? String(v.message) : '',
)
.filter(Boolean);
if (messages.length) return messages.join(' · ');
}
}
return error.status === 400
? 'Controleer de ingevulde gegevens en probeer het opnieuw.'
: 'Opslaan is niet gelukt. Probeer het opnieuw.';
}

View File

@@ -0,0 +1,74 @@
export interface School {
id: number;
name: string;
dancerIds: number[];
danceGroupIds: number[];
showIds: number[];
}
export interface Dancer {
id: number;
name: string;
birthDate: string;
}
export type GroupType = 'SHOW_CASE' | 'PRE_EDUCATION' | 'SELECTION' | 'REGULAR' | 'PRE_SCHOOLER';
export type GroupStyle = 'HIPHOP' | 'JAZZ' | 'MODERN' | 'STREETDANCE' | 'CLASSIC' | 'BREAKDANCE';
export interface DanceGroup {
id: number;
category: string;
type: GroupType;
startAge: number;
endAge: number;
styles: GroupStyle[];
dancerIds: number[];
}
export interface Show {
id: number;
name: string;
date: string;
dayParts: { type: 'MORNING' | 'AFTERNOON' | 'EVENING'; danceGroupIds: number[] }[];
}
export interface SchoolRequest {
name: string;
}
export interface DancerRequest {
name: string;
birthDate: string;
}
export interface GroupRequest {
category: string;
type: GroupType;
startAge: number;
endAge: number;
styles: GroupStyle[];
}
export interface ShowRequest {
name: string;
date: string;
}
export type ResourceKind = 'school' | 'dancer' | 'group' | 'show';
export type EditorTarget =
| { kind: 'school'; value?: School }
| { kind: 'dancer'; value?: Dancer }
| { kind: 'group'; value?: DanceGroup }
| { kind: 'show'; value?: Show };
export interface SchoolData {
school: School;
dancers: Dancer[];
groups: DanceGroup[];
shows: Show[];
}
export const GROUP_TYPES: { value: GroupType; label: string }[] = [
{ value: 'REGULAR', label: 'Regulier' },
{ value: 'SHOW_CASE', label: 'Showcase' },
{ value: 'PRE_EDUCATION', label: 'Vooropleiding' },
{ value: 'SELECTION', label: 'Selectie' },
{ value: 'PRE_SCHOOLER', label: 'Kleuters' },
];
export const GROUP_STYLES: { value: GroupStyle; label: string }[] = [
{ value: 'HIPHOP', label: 'Hiphop' },
{ value: 'JAZZ', label: 'Jazz' },
{ value: 'MODERN', label: 'Modern' },
{ value: 'STREETDANCE', label: 'Streetdance' },
{ value: 'CLASSIC', label: 'Klassiek' },
{ value: 'BREAKDANCE', label: 'Breakdance' },
];

View File

@@ -0,0 +1,69 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { SchoolApi } from './school-api';
describe('SchoolApi contract', () => {
let api: SchoolApi;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
api = TestBed.inject(SchoolApi);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
it('loads all resources from the same school', () => {
const received = vi.fn();
api.schoolData(7).subscribe(received);
const school = { id: 7, name: 'Studio', dancerIds: [], danceGroupIds: [], showIds: [] };
http.expectOne('/api/dance-schools/7').flush(school);
http.expectOne('/api/dance-schools/7/dancers').flush([]);
http.expectOne('/api/dance-schools/7/dance-groups').flush([]);
expect(received).not.toHaveBeenCalled();
http.expectOne('/api/dance-schools/7/shows').flush([]);
expect(received).toHaveBeenCalledWith({ school, dancers: [], groups: [], shows: [] });
});
it('updates an existing entity even when its id is zero', () => {
api.saveSchool({ name: 'Renamed' }, 0).subscribe();
const request = http.expectOne('/api/dance-schools/0');
expect(request.request.method).toBe('PUT');
expect(request.request.body).toEqual({ name: 'Renamed' });
request.flush({ id: 0, name: 'Renamed', dancerIds: [], danceGroupIds: [], showIds: [] });
});
it('keeps group details separate from memberships', () => {
api
.saveGroup(
7,
{ category: 'Teens', type: 'REGULAR', startAge: 12, endAge: 18, styles: ['HIPHOP'] },
0,
)
.subscribe();
const request = http.expectOne('/api/dance-schools/7/dance-groups/0');
expect(request.request.method).toBe('PUT');
expect(request.request.body).not.toHaveProperty('dancerIds');
request.flush({});
api.membership(7, 0, 3, true).subscribe();
const add = http.expectOne('/api/dance-schools/7/dance-groups/0/dancers/3');
expect(add.request.method).toBe('PUT');
expect(add.request.body).toBeNull();
add.flush(null);
api.membership(7, 0, 3, false).subscribe();
const remove = http.expectOne('/api/dance-schools/7/dance-groups/0/dancers/3');
expect(remove.request.method).toBe('DELETE');
remove.flush(null);
});
it('propagates server errors instead of pretending a save succeeded', () => {
const failure = vi.fn();
api.saveDancer(7, { name: 'Sam', birthDate: '2010-05-12' }).subscribe({ error: failure });
http
.expectOne('/api/dance-schools/7/dancers')
.flush({ message: 'Invalid data' }, { status: 400, statusText: 'Bad Request' });
expect(failure).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,63 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { forkJoin, Observable } from 'rxjs';
import {
DanceGroup,
Dancer,
DancerRequest,
GroupRequest,
ResourceKind,
School,
SchoolData,
SchoolRequest,
Show,
ShowRequest,
} from './api-models';
@Injectable({ providedIn: 'root' })
export class SchoolApi {
private readonly http = inject(HttpClient);
private readonly base = '/api/dance-schools';
schools(): Observable<School[]> {
return this.http.get<School[]>(this.base);
}
schoolData(schoolId: number): Observable<SchoolData> {
const base = `${this.base}/${schoolId}`;
return forkJoin({
school: this.http.get<School>(base),
dancers: this.http.get<Dancer[]>(`${base}/dancers`),
groups: this.http.get<DanceGroup[]>(`${base}/dance-groups`),
shows: this.http.get<Show[]>(`${base}/shows`),
});
}
saveSchool(body: SchoolRequest, id?: number): Observable<School> {
return id === undefined
? this.http.post<School>(this.base, body)
: this.http.put<School>(`${this.base}/${id}`, body);
}
saveDancer(schoolId: number, body: DancerRequest, id?: number): Observable<Dancer> {
return this.save(`${this.base}/${schoolId}/dancers`, body, id);
}
saveGroup(schoolId: number, body: GroupRequest, id?: number): Observable<DanceGroup> {
return this.save(`${this.base}/${schoolId}/dance-groups`, body, id);
}
saveShow(schoolId: number, body: ShowRequest, id?: number): Observable<Show> {
return this.save(`${this.base}/${schoolId}/shows`, body, id);
}
delete(kind: ResourceKind, schoolId: number, id: number): Observable<void> {
const collection = { school: '', dancer: '/dancers', group: '/dance-groups', show: '/shows' }[
kind
];
const url =
kind === 'school' ? `${this.base}/${id}` : `${this.base}/${schoolId}${collection}/${id}`;
return this.http.delete<void>(url);
}
membership(schoolId: number, groupId: number, dancerId: number, add: boolean): Observable<void> {
const url = `${this.base}/${schoolId}/dance-groups/${groupId}/dancers/${dancerId}`;
return add ? this.http.put<void>(url, null) : this.http.delete<void>(url);
}
private save<T>(url: string, body: unknown, id?: number): Observable<T> {
return id === undefined ? this.http.post<T>(url, body) : this.http.put<T>(`${url}/${id}`, body);
}
}

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();
}
}

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);
}
}
}

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<title>Agile Dancers · Dansschoolbeheer</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,5 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig).catch((err) => console.error(err));

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}

View File

@@ -0,0 +1,31 @@
{
"compileOnSave": false,
"compilerOptions": {
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve",
"strict": true
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}