frontend-toegevoegd
This commit is contained in:
@@ -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
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -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.
|
||||||
|
|||||||
17
agiledancers-fe/.editorconfig
Normal file
17
agiledancers-fe/.editorconfig
Normal 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
48
agiledancers-fe/.gitignore
vendored
Normal 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
1
agiledancers-fe/.nvmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
22.22.3
|
||||||
12
agiledancers-fe/.prettierrc
Normal file
12
agiledancers-fe/.prettierrc
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.html",
|
||||||
|
"options": {
|
||||||
|
"parser": "angular"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
4
agiledancers-fe/.vscode/extensions.json
vendored
Normal file
4
agiledancers-fe/.vscode/extensions.json
vendored
Normal 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
20
agiledancers-fe/.vscode/launch.json
vendored
Normal 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
42
agiledancers-fe/.vscode/tasks.json
vendored
Normal 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
86
agiledancers-fe/README.md
Normal 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.
|
||||||
72
agiledancers-fe/angular.json
Normal file
72
agiledancers-fe/angular.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
162
agiledancers-fe/e2e/school-management.spec.ts
Normal file
162
agiledancers-fe/e2e/school-management.spec.ts
Normal 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
7015
agiledancers-fe/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
agiledancers-fe/package.json
Normal file
36
agiledancers-fe/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
agiledancers-fe/playwright.config.ts
Normal file
26
agiledancers-fe/playwright.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
7
agiledancers-fe/proxy.conf.json
Normal file
7
agiledancers-fe/proxy.conf.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"/api/**": {
|
||||||
|
"target": "http://127.0.0.1:8080",
|
||||||
|
"secure": false,
|
||||||
|
"changeOrigin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
1
agiledancers-fe/public/favicon.svg
Normal file
1
agiledancers-fe/public/favicon.svg
Normal 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 |
17
agiledancers-fe/src/app/app.config.ts
Normal file
17
agiledancers-fe/src/app/app.config.ts
Normal 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' },
|
||||||
|
],
|
||||||
|
};
|
||||||
239
agiledancers-fe/src/app/app.css
Normal file
239
agiledancers-fe/src/app/app.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
agiledancers-fe/src/app/app.html
Normal file
46
agiledancers-fe/src/app/app.html
Normal 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>
|
||||||
21
agiledancers-fe/src/app/app.routes.ts
Normal file
21
agiledancers-fe/src/app/app.routes.ts
Normal 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' },
|
||||||
|
];
|
||||||
19
agiledancers-fe/src/app/app.spec.ts
Normal file
19
agiledancers-fe/src/app/app.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
10
agiledancers-fe/src/app/app.ts
Normal file
10
agiledancers-fe/src/app/app.ts
Normal 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 {}
|
||||||
24
agiledancers-fe/src/app/core/api-error.ts
Normal file
24
agiledancers-fe/src/app/core/api-error.ts
Normal 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.';
|
||||||
|
}
|
||||||
74
agiledancers-fe/src/app/core/api-models.ts
Normal file
74
agiledancers-fe/src/app/core/api-models.ts
Normal 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' },
|
||||||
|
];
|
||||||
69
agiledancers-fe/src/app/core/school-api.spec.ts
Normal file
69
agiledancers-fe/src/app/core/school-api.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
63
agiledancers-fe/src/app/core/school-api.ts
Normal file
63
agiledancers-fe/src/app/core/school-api.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
423
agiledancers-fe/src/app/schools/school-page.html
Normal file
423
agiledancers-fe/src/app/schools/school-page.html
Normal 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()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
162
agiledancers-fe/src/app/schools/school-page.ts
Normal file
162
agiledancers-fe/src/app/schools/school-page.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
agiledancers-fe/src/app/schools/schools-page.html
Normal file
93
agiledancers-fe/src/app/schools/schools-page.html
Normal 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()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
55
agiledancers-fe/src/app/schools/schools-page.ts
Normal file
55
agiledancers-fe/src/app/schools/schools-page.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
40
agiledancers-fe/src/app/shared/confirm-dialog.ts
Normal file
40
agiledancers-fe/src/app/shared/confirm-dialog.ts
Normal 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>();
|
||||||
|
}
|
||||||
50
agiledancers-fe/src/app/shared/dialog-frame.ts
Normal file
50
agiledancers-fe/src/app/shared/dialog-frame.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
69
agiledancers-fe/src/app/shared/entity-editor.spec.ts
Normal file
69
agiledancers-fe/src/app/shared/entity-editor.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
222
agiledancers-fe/src/app/shared/entity-editor.ts
Normal file
222
agiledancers-fe/src/app/shared/entity-editor.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
agiledancers-fe/src/app/shared/group-members.spec.ts
Normal file
76
agiledancers-fe/src/app/shared/group-members.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
98
agiledancers-fe/src/app/shared/group-members.ts
Normal file
98
agiledancers-fe/src/app/shared/group-members.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
agiledancers-fe/src/index.html
Normal file
13
agiledancers-fe/src/index.html
Normal 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>
|
||||||
5
agiledancers-fe/src/main.ts
Normal file
5
agiledancers-fe/src/main.ts
Normal 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));
|
||||||
1023
agiledancers-fe/src/styles.css
Normal file
1023
agiledancers-fe/src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
14
agiledancers-fe/tsconfig.app.json
Normal file
14
agiledancers-fe/tsconfig.app.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
31
agiledancers-fe/tsconfig.json
Normal file
31
agiledancers-fe/tsconfig.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
14
agiledancers-fe/tsconfig.spec.json
Normal file
14
agiledancers-fe/tsconfig.spec.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user