S18 Added OR Tools rules for scheduling show
All checks were successful
Build, test and publish / verify (push) Successful in 6m12s

This commit is contained in:
2026-11-03 11:03:47 +01:00
parent c965f9b41d
commit a7f7730ea0
48 changed files with 2262 additions and 256 deletions

View File

@@ -31,6 +31,7 @@ async function confirmDelete(page: Page) {
test('manages a school, shared memberships and shows through the real REST API', async ({
page,
request,
}) => {
const schoolName = `E2E Studio ${Date.now()}`;
const browserErrors: string[] = [];
@@ -107,6 +108,26 @@ test('manages a school, shared memberships and shows through the real REST API',
).toBeVisible();
await page.screenshot({ path: 'test-results/groups-desktop.png', fullPage: true });
// Two independent performances give Sam time to change between the shared groups.
for (const name of ['Modern adults', 'Streetdance adults']) {
const dancerResponse = await request.post(
`${backend}/api/dance-schools/${schoolToDelete}/dancers`,
{
data: { name, birthDate: '1990-01-01' },
},
);
const dancer = (await dancerResponse.json()) as { id: number };
const groupResponse = await request.post(
`${backend}/api/dance-schools/${schoolToDelete}/dance-groups`,
{
data: { category: name, type: 'REGULAR', startAge: 0, endAge: 99, styles: ['MODERN'] },
},
);
const group = (await groupResponse.json()) as { id: number };
await request.put(
`${backend}/api/dance-schools/${schoolToDelete}/dance-groups/${group.id}/dancers/${dancer.id}`,
);
}
await tab(page, 'Shows');
await page.getByRole('button', { name: 'Show toevoegen' }).click();
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Zomershow');
@@ -120,8 +141,19 @@ test('manages a school, shared memberships and shows through the real REST API',
await expect(page.locator('.date-tile')).toContainText('jul 2027');
page.once('dialog', (dialog) => dialog.accept());
await page.getByRole('button', { name: /Schedule groups/ }).click();
await expect(page.getByText('Planning voldoet aan alle optredensaantallen.')).toBeVisible();
await expect(page.getByRole('status').filter({ hasText: 'Optimale balans.' })).toBeVisible();
await expect(page.getByText('Planning voldoet aan alle harde regels.')).toBeVisible();
await expect(
page.getByRole('status').filter({ hasText: 'Optimaal conceptrooster.' }),
).toBeVisible();
await page.getByRole('button', { name: 'Indeling en volgorde bewerken' }).click();
const scheduleDialog = page.getByRole('dialog');
await scheduleDialog.getByRole('button', { name: 'Definitief maken' }).click();
await expect(scheduleDialog.getByText('Definitief', { exact: false }).first()).toBeVisible();
await scheduleDialog.getByRole('button', { name: 'Optreden verwijderen' }).first().click();
await scheduleDialog.getByRole('button', { name: 'Concept opslaan' }).click();
await expect(scheduleDialog.locator('.alert.error').first()).toBeVisible();
await expect(scheduleDialog.getByRole('button', { name: 'Definitief maken' })).toBeDisabled();
await scheduleDialog.getByRole('button', { name: 'Gereed', exact: true }).click();
await page.getByRole('button', { name: 'Zomershow bewerken' }).click();
await page.getByRole('button', { name: 'Dagdeel toevoegen', exact: true }).click();
await page.getByLabel('Datum', { exact: true }).nth(1).fill('2027-07-02');
@@ -148,7 +180,7 @@ test('manages a school, shared memberships and shows through the real REST API',
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 expect(page.getByRole('cell', { name: 'Sam Testdanser', exact: true })).toHaveCount(0);
await tab(page, 'Dansgroepen');
await expect(page.getByText(/0 dansers/)).toHaveCount(2);
await page.getByRole('button', { name: 'Hiphop Selectie verwijderen' }).click();
@@ -166,6 +198,66 @@ test('manages a school, shared memberships and shows through the real REST API',
expect(browserErrors).toEqual([]);
});
test('links a coach and finalizes performances spread across multiple dates', async ({
page,
request,
}) => {
const schoolResponse = await request.post(`${backend}/api/dance-schools`, {
data: { name: `Coach show ${Date.now()}` },
});
const school = (await schoolResponse.json()) as School;
schoolToDelete = school.id;
const base = `${backend}/api/dance-schools/${school.id}`;
const dancerResponse = await request.post(`${base}/dancers`, {
data: { name: 'Dancing coach', birthDate: '1990-01-01' },
});
const dancer = (await dancerResponse.json()) as { id: number };
const groupIds: number[] = [];
for (const name of ['Preschool', 'Adults']) {
const response = await request.post(`${base}/dance-groups`, {
data: {
category: name,
type: name === 'Preschool' ? 'PRE_SCHOOLER' : 'REGULAR',
startAge: 0,
endAge: 99,
styles: [],
},
});
const group = (await response.json()) as { id: number };
groupIds.push(group.id);
await request.put(`${base}/dance-groups/${group.id}/dancers/${dancer.id}`);
}
const showResponse = await request.post(`${base}/shows`, {
data: {
name: 'Coach across dates',
preferredDayParts: 1,
dayParts: [
{ date: '2027-06-01', type: 'AFTERNOON' },
{ date: '2027-06-02', type: 'AFTERNOON' },
],
},
});
const show = (await showResponse.json()) as { id: number };
await request.put(`${base}/shows/${show.id}/assignments`, {
data: { assignments: groupIds.map((id) => [id]) },
});
await page.goto(`/schools/${school.id}/shows`);
await expect(page.getByText(/exceeds x=1/)).toBeVisible();
await tab(page, 'Dansers');
await page.getByText('Coaches koppelen aan dansers', { exact: true }).click();
await page.getByLabel('Danser als coach registreren').selectOption({ label: 'Dancing coach' });
await expect(page.getByRole('combobox', { name: 'Dancing coach', exact: true })).toBeVisible();
await tab(page, 'Shows');
await expect(page.getByText('Planning voldoet aan alle harde regels.')).toBeVisible();
await page.getByRole('button', { name: 'Indeling en volgorde bewerken' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Definitief maken' }).click();
await expect(
page.getByRole('dialog').getByRole('button', { name: 'Definitief maken' }),
).toBeDisabled();
const finalResponse = await request.get(`${base}/shows/${show.id}`);
expect(((await finalResponse.json()) as { finalized: boolean }).finalized).toBe(true);
});
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');

View File

@@ -27,11 +27,19 @@ export interface DayPart {
type: DayPartType;
danceGroupIds: number[];
}
export interface Coach {
id: number;
name: string;
dancerId: number | null;
}
export interface Show {
id: number;
name: string;
dayParts: DayPart[];
appearances: Partial<Record<GroupType, number>>;
preferredDayParts: number;
finalized: boolean;
violations: string[];
}
export interface SchoolRequest {
name: string;
@@ -51,6 +59,7 @@ export interface ShowRequest {
name: string;
dayParts: { date: string; type: DayPartType }[];
appearances?: Partial<Record<GroupType, number>>;
preferredDayParts?: number;
}
export type ResourceKind = 'school' | 'dancer' | 'group' | 'show';
export type EditorTarget =
@@ -63,6 +72,7 @@ export interface SchoolData {
dancers: Dancer[];
groups: DanceGroup[];
shows: Show[];
coaches: Coach[];
}
export const GROUP_TYPES: { value: GroupType; label: string }[] = [
{ value: 'REGULAR', label: 'Regulier' },

View File

@@ -24,7 +24,14 @@ describe('SchoolApi contract', () => {
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: [] });
http.expectOne('/api/dance-schools/7/coaches').flush([]);
expect(received).toHaveBeenCalledWith({
school,
dancers: [],
groups: [],
shows: [],
coaches: [],
});
});
it('updates an existing entity even when its id is zero', () => {

View File

@@ -2,6 +2,7 @@ import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { forkJoin, Observable } from 'rxjs';
import {
Coach,
DanceGroup,
Dancer,
DancerRequest,
@@ -29,6 +30,7 @@ export class SchoolApi {
dancers: this.http.get<Dancer[]>(`${base}/dancers`),
groups: this.http.get<DanceGroup[]>(`${base}/dance-groups`),
shows: this.http.get<Show[]>(`${base}/shows`),
coaches: this.http.get<Coach[]>(`${base}/coaches`),
});
}
saveSchool(body: SchoolRequest, id?: number): Observable<School> {
@@ -52,6 +54,20 @@ export class SchoolApi {
{ timeout: 1860000 },
);
}
saveAssignments(schoolId: number, showId: number, assignments: number[][]): Observable<Show> {
return this.http.put<Show>(`${this.base}/${schoolId}/shows/${showId}/assignments`, {
assignments,
});
}
finalize(schoolId: number, showId: number): Observable<Show> {
return this.http.post<Show>(`${this.base}/${schoolId}/shows/${showId}/finalize`, null);
}
linkCoach(schoolId: number, dancerId: number, coachId?: number): Observable<Coach> {
return this.save<Coach>(`${this.base}/${schoolId}/coaches`, { dancerId }, coachId);
}
unlinkCoach(schoolId: number, coachId: number): Observable<void> {
return this.http.delete<void>(`${this.base}/${schoolId}/coaches/${coachId}/dancer`);
}
delete(kind: ResourceKind, schoolId: number, id: number): Observable<void> {
const collection = { school: '', dancer: '/dancers', group: '/dance-groups', show: '/shows' }[
kind

View File

@@ -151,6 +151,7 @@
</section>
}
@case ('dancers') {
<app-coach-editor [data]="context" (changed)="reload()" />
<div class="section-heading">
<div>
<h2>Dansers</h2>
@@ -350,6 +351,15 @@
</div>
<div class="show-details">
<h3>{{ show.name }}</h3>
<p>
{{ show.finalized ? 'Definitief' : 'Concept' }} · x = {{ show.preferredDayParts }}
</p>
@for (violation of show.violations; track $index) {
<p class="alert error">{{ violation }}</p>
}
<button type="button" class="button secondary" (click)="scheduleEditing.set(show)">
Indeling en volgorde bewerken
</button>
<p>{{ dateRange(show) }}</p>
@for (part of show.dayParts; track $index) {
<p>{{ part.date }} · {{ part.type }}: {{ groupNamesFor(part.danceGroupIds) }}</p>
@@ -357,7 +367,7 @@
<p role="status">
{{
scheduleValid(show)
? 'Planning voldoet aan alle optredensaantallen.'
? 'Planning voldoet aan alle harde regels.'
: 'Geen geldige planning. Plan de groepen opnieuw in.'
}}
</p>
@@ -365,7 +375,10 @@
type="button"
class="button primary"
[disabled]="
scheduling().includes(show.id) || !context.groups.length || !show.dayParts.length
scheduling().includes(show.id) ||
!context.groups.length ||
!show.dayParts.length ||
show.preferredDayParts > show.dayParts.length
"
(click)="schedule(show)"
>
@@ -426,6 +439,15 @@
(saved)="saved()"
/>
}
@if (scheduleEditing(); as show) {
<app-schedule-editor
[show]="show"
[groups]="context.groups"
[schoolId]="context.school.id"
(closed)="scheduleEditing.set(null)"
(saved)="reload()"
/>
}
@if (members(); as group) {
<app-group-members
[group]="group"

View File

@@ -17,6 +17,8 @@ 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 { ScheduleEditor } from '../shared/schedule-editor';
import { CoachEditor } from '../shared/coach-editor';
import { GroupMembers } from '../shared/group-members';
interface DeleteTarget {
@@ -29,6 +31,8 @@ interface DeleteTarget {
@Component({
selector: 'app-school-page',
imports: [
ScheduleEditor,
CoachEditor,
SchoolTransfer,
DatePipe,
RouterLink,
@@ -50,6 +54,7 @@ export class SchoolPage implements OnInit {
readonly error = signal('');
readonly notice = signal('');
readonly editor = signal<EditorTarget | null>(null);
readonly scheduleEditing = signal<Show | null>(null);
readonly members = signal<DanceGroup | null>(null);
readonly deleting = signal<DeleteTarget | null>(null);
readonly scheduling = signal<number[]>([]);
@@ -86,6 +91,7 @@ export class SchoolPage implements OnInit {
this.search.set('');
this.editor.set(null);
this.members.set(null);
this.scheduleEditing.set(null);
this.deleting.set(null);
this.section.set(params.get('section') ?? 'overview');
return this.api.schoolData(Number(params.get('schoolId'))).pipe(
@@ -121,22 +127,9 @@ export class SchoolPage implements OnInit {
return !first ? 'No dates configured' : first === last ? first : `${first} – ${last}`;
}
scheduleValid(show: Show): boolean {
const groups = this.data()?.groups ?? [];
return (
!!groups.length &&
!!show.dayParts.length &&
groups.every(
(g) =>
show.dayParts.filter((p) => p.danceGroupIds.includes(g.id)).length ===
show.appearances[g.type],
) &&
show.dayParts.every(
(p) =>
new Set(p.danceGroupIds).size === p.danceGroupIds.length &&
p.danceGroupIds.every((id) => groups.some((g) => g.id === id)),
)
);
return show.violations?.length === 0;
}
groupNamesFor(ids: number[]): string {
return (
ids.map((id) => this.data()?.groups.find((g) => g.id === id)?.category ?? '?').join(', ') ||
@@ -163,7 +156,9 @@ export class SchoolPage implements OnInit {
try {
const result = await firstValueFrom(this.api.schedule(schoolId, show.id));
this.notice.set(
result.optimal ? 'Optimale balans.' : 'Geldige planning; optimale balans niet bewezen.',
result.optimal
? 'Optimaal conceptrooster.'
: 'Geldig conceptrooster; optimaliteit niet bewezen.',
);
await this.reload();
} catch (error: unknown) {
@@ -221,6 +216,7 @@ export class SchoolPage implements OnInit {
this.deleting.set(target);
}
membershipChanged(ids: number[]): void {
void this.reload();
const groupId = this.members()?.id;
this.data.update((data) =>
data

View File

@@ -0,0 +1,70 @@
import { Component, inject, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { SchoolData } from '../core/api-models';
import { SchoolApi } from '../core/school-api';
import { apiError } from '../core/api-error';
@Component({
selector: 'app-coach-editor',
imports: [FormsModule],
template: `
<details>
<summary>Coaches koppelen aan dansers</summary>
<p>
Coaches mogen in alle dagdelen dansen. Voeg ze als danser toe aan elke groep waarin ze
meedansen. Ook voor coaches blijven twee andere optredens tussen hun dansen verplicht.
</p>
@if (error()) {
<p role="alert" class="alert error">{{ error() }}</p>
}
<fieldset [disabled]="busy()">
@for (coach of data().coaches; track coach.id) {
<label
>{{ coach.name }}
<select [ngModel]="coach.dancerId" (ngModelChange)="link($event, coach.id)">
<option [ngValue]="null">Geen danser gekoppeld</option>
@for (dancer of data().dancers; track dancer.id) {
<option [ngValue]="dancer.id">{{ dancer.name }}</option>
}
</select>
</label>
}
<label
>Danser als coach registreren
<select [ngModel]="null" (ngModelChange)="link($event)">
<option [ngValue]="null">Kies een danser</option>
@for (dancer of data().dancers; track dancer.id) {
<option [ngValue]="dancer.id">{{ dancer.name }}</option>
}
</select>
</label>
</fieldset>
</details>
`,
})
export class CoachEditor {
readonly data = input.required<SchoolData>();
readonly changed = output<void>();
readonly busy = signal(false);
readonly error = signal('');
private readonly api = inject(SchoolApi);
async link(dancerId: number | null, coachId?: number): Promise<void> {
if (this.busy() || (dancerId === null && coachId === undefined)) return;
this.busy.set(true);
this.error.set('');
try {
await firstValueFrom<unknown>(
dancerId === null
? this.api.unlinkCoach(this.data().school.id, coachId!)
: this.api.linkCoach(this.data().school.id, dancerId, coachId),
);
this.changed.emit();
} catch (error: unknown) {
this.error.set(apiError(error));
} finally {
this.busy.set(false);
}
}
}

View File

@@ -70,6 +70,9 @@ describe('EntityEditor', () => {
const show: Show = {
id: 3,
name: 'Summer',
preferredDayParts: 2,
finalized: false,
violations: [],
dayParts: [
{ date: '2027-06-01', type: 'AFTERNOON', danceGroupIds: [4] },
{ date: '2027-06-02', type: 'AFTERNOON', danceGroupIds: [4] },
@@ -77,7 +80,7 @@ describe('EntityEditor', () => {
appearances: { REGULAR: 2, SELECTION: 2, PRE_SCHOOLER: 1, SHOW_CASE: 2, PRE_EDUCATION: 2 },
};
it('initializes defaults for every type and rejects duplicate dated dayparts and fractional counts', () => {
it('defaults x to two and rejects duplicate dated dayparts and fractional x', () => {
const component = editor({ kind: 'show' });
component.name = 'Show';
expect(component.validDetails()).toBe(true);
@@ -87,31 +90,25 @@ describe('EntityEditor', () => {
component.dayParts[1].date = '2027-06-02';
component.addPart();
component.dayParts[2].date = '2027-06-03';
expect(component.appearances).toEqual({
REGULAR: 2,
SELECTION: 2,
PRE_SCHOOLER: 1,
SHOW_CASE: 3,
PRE_EDUCATION: 3,
});
expect(component.preferredDayParts).toBe(2);
expect(component.validDetails()).toBe(true);
component.dayParts[2].date = '2027-06-02';
expect(component.validDetails()).toBe(false);
component.dayParts[2].date = '2027-06-03';
component.appearances.PRE_SCHOOLER = 1.5;
component.preferredDayParts = 1.5;
expect(component.validDetails()).toBe(false);
});
it('preserves saved counts and requires explicit correction when reducing to one daypart', () => {
it('retains x when dayparts are removed so it can be explicitly corrected', () => {
const component = editor({ kind: 'show', value: show });
component.addPart();
component.dayParts[2].date = '2027-06-03';
expect(component.appearances.SHOW_CASE).toBe(2);
expect(component.preferredDayParts).toBe(2);
component.removePart(2);
component.removePart(1);
expect(component.appearances.REGULAR).toBe(2);
expect(component.validDetails()).toBe(false);
component.setSingleCounts();
expect(component.preferredDayParts).toBe(2);
expect(component.validDetails()).toBe(true);
component.preferredDayParts = 1;
expect(component.validDetails()).toBe(true);
});
@@ -120,11 +117,11 @@ describe('EntityEditor', () => {
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
try {
const component = editor({ kind: 'show', value: show });
component.appearances.REGULAR = 1;
component.preferredDayParts = 1;
await component.save();
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('Summer'));
expect(api.saveShow).not.toHaveBeenCalled();
component.appearances.REGULAR = 2;
component.preferredDayParts = 2;
confirm.mockClear();
component.name = 'Renamed';
await component.save();
@@ -147,7 +144,7 @@ describe('EntityEditor', () => {
}
});
it('displays one disabled appearance field for every type with one daypart', async () => {
it('displays one editable x field with a default of one for a one-daypart show', async () => {
// jsdom does not implement native dialog opening.
const descriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, 'showModal');
Object.defineProperty(HTMLDialogElement.prototype, 'showModal', {
@@ -163,8 +160,8 @@ describe('EntityEditor', () => {
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const inputs = Array.from(element.querySelectorAll<HTMLInputElement>('input[type="number"]'));
expect(inputs).toHaveLength(5);
expect(inputs.every((input) => input.disabled && input.value === '1')).toBe(true);
expect(inputs).toHaveLength(1);
expect(inputs.every((input) => !input.disabled && input.value === '1')).toBe(true);
} finally {
if (descriptor) Object.defineProperty(HTMLDialogElement.prototype, 'showModal', descriptor);
else Reflect.deleteProperty(HTMLDialogElement.prototype, 'showModal');

View File

@@ -130,33 +130,22 @@ import { DialogFrame } from './dialog-frame';
</button>
</fieldset>
@if (dayParts.length) {
<fieldset>
<legend>Optredens per groepstype</legend>
@for (option of types; track option.value) {
<label
>{{ option.label
}}<input
type="number"
[name]="'count' + option.value"
[ngModel]="dayParts.length === 1 ? 1 : appearances[option.value]"
(ngModelChange)="appearances[option.value] = $event"
required
min="1"
[max]="dayParts.length"
step="1"
[disabled]="dayParts.length === 1"
/></label>
}
<p class="form-hint">
Bestaande aantallen blijven behouden bij wijzigingen. Corrigeer te hoge aantallen
vóór opslaan.
</p>
@if (dayParts.length === 1 && !validCounts()) {
<button type="button" class="button secondary" (click)="setSingleCounts()">
Alle aantallen op één zetten
</button>
}
</fieldset>
<label
>Gewenst aantal dagdelen (x)
<input
type="number"
name="preferredDayParts"
[(ngModel)]="preferredDayParts"
min="1"
max="6"
step="1"
required
/>
</label>
<p class="form-hint">
Gewone groepen dansen in precies x dagdelen. Kleuters eenmaal; showcases ieder
dagdeel. Is x groter dan het aantal dagdelen, corrigeer x vóór inplannen.
</p>
} @else {
<p class="form-hint">Zonder dagdelen blijft deze show ongepland.</p>
}
@@ -196,8 +185,7 @@ export class EntityEditor implements OnInit {
readonly today = new Date().toLocaleDateString('en-CA');
private readonly api = inject(SchoolApi);
dayParts: DayPart[] = [];
appearances: Partial<Record<GroupType, number>> = {};
private countsInitialized = false;
preferredDayParts = 2;
name = '';
date = '';
type: GroupType = 'REGULAR';
@@ -223,44 +211,23 @@ export class EntityEditor implements OnInit {
if (target.kind === 'dancer') this.date = target.value.birthDate;
if (target.kind === 'show') {
this.dayParts = target.value.dayParts.map((p) => ({ ...p }));
this.appearances = { ...target.value.appearances };
this.countsInitialized = Object.keys(this.appearances).length > 0;
this.preferredDayParts = target.value.preferredDayParts;
}
}
}
addPart(): void {
if (this.dayParts.length >= 6) return;
const previous = this.dayParts.length;
this.dayParts.push({ date: '', type: 'AFTERNOON', danceGroupIds: [] });
if (!this.countsInitialized) {
for (const { value } of this.types) {
const defaultCount = (n: number) =>
value === 'PRE_SCHOOLER'
? 1
: value === 'SHOW_CASE' || value === 'PRE_EDUCATION'
? n
: Math.min(2, n);
if (
this.appearances[value] === undefined ||
this.appearances[value] === defaultCount(previous)
)
this.appearances[value] = defaultCount(this.dayParts.length);
}
}
if (!this.target().value) this.preferredDayParts = Math.min(2, this.dayParts.length);
}
removePart(index: number): void {
this.dayParts.splice(index, 1);
if (!this.countsInitialized && !this.dayParts.length) this.appearances = {};
}
setSingleCounts(): void {
for (const option of this.types) this.appearances[option.value] = 1;
}
validCounts(): boolean {
return this.types.every(
(o) =>
Number.isInteger(this.appearances[o.value]) &&
this.appearances[o.value]! >= 1 &&
this.appearances[o.value]! <= this.dayParts.length,
return (
Number.isInteger(this.preferredDayParts) &&
this.preferredDayParts >= 1 &&
this.preferredDayParts <= 6
);
}
toggleStyle(style: GroupStyle, checked: boolean): void {
@@ -300,7 +267,7 @@ export class EntityEditor implements OnInit {
current.value &&
(JSON.stringify(current.value.dayParts.map((p) => ({ date: p.date, type: p.type }))) !==
JSON.stringify(this.dayParts.map((p) => ({ date: p.date, type: p.type }))) ||
JSON.stringify(current.value.appearances) !== JSON.stringify(this.appearances))
current.value.preferredDayParts !== this.preferredDayParts)
)
affected = [current.value];
const names = affected
@@ -349,7 +316,7 @@ export class EntityEditor implements OnInit {
{
name: this.name.trim(),
dayParts: this.dayParts.map((p) => ({ date: p.date, type: p.type })),
appearances: this.appearances,
preferredDayParts: this.preferredDayParts,
},
id,
);

View File

@@ -0,0 +1,88 @@
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { HttpErrorResponse } from '@angular/common/http';
import { Show, DanceGroup } from '../core/api-models';
import { SchoolApi } from '../core/school-api';
import { ScheduleEditor } from './schedule-editor';
const groups: DanceGroup[] = [1, 2, 3].map((id) => ({
id,
category: `Group ${id}`,
type: 'REGULAR',
startAge: 0,
endAge: 99,
styles: [],
dancerIds: [id],
}));
const show: Show = {
id: 4,
name: 'Show',
preferredDayParts: 1,
finalized: true,
violations: [],
appearances: {},
dayParts: [
{ date: '2027-01-01', type: 'MORNING', danceGroupIds: [1, 2] },
{ date: '2027-01-02', type: 'MORNING', danceGroupIds: [3] },
],
};
describe('ScheduleEditor', () => {
const api = { saveAssignments: vi.fn(), finalize: vi.fn() };
beforeEach(() => {
vi.resetAllMocks();
TestBed.configureTestingModule({ providers: [{ provide: SchoolApi, useValue: api }] });
});
function editor() {
const fixture = TestBed.createComponent(ScheduleEditor);
fixture.componentRef.setInput('show', show);
fixture.componentRef.setInput('groups', groups);
fixture.componentRef.setInput('schoolId', 7);
fixture.componentInstance.ngOnInit();
return fixture.componentInstance;
}
it('edits order and dayparts without changing the saved input', () => {
const component = editor();
component.reorder(0, 1, -1);
component.move(0, 0, 1);
component.add(0, 3);
component.remove(1, 0);
expect(component.assignments).toEqual([[1, 3], [2]]);
expect(show.dayParts[0].danceGroupIds).toEqual([1, 2]);
expect(component.dirty()).toBe(true);
});
it('saves invalid drafts and displays server violations without finalizing', async () => {
const component = editor();
component.remove(0, 0);
const draft = { ...show, finalized: false, violations: ['Group 1 requires 1 dayparts.'] };
api.saveAssignments.mockReturnValue(of(draft));
await component.save();
expect(api.saveAssignments).toHaveBeenCalledWith(7, 4, [[2], [3]]);
expect(component.current()).toEqual(draft);
expect(component.dirty()).toBe(false);
expect(api.finalize).not.toHaveBeenCalled();
});
it('keeps unsaved edits when saving fails', async () => {
const component = editor();
component.remove(0, 0);
api.saveAssignments.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 0 })));
await component.save();
expect(component.assignments).toEqual([[2], [3]]);
expect(component.dirty()).toBe(true);
expect(component.error()).toContain('niet bereikbaar');
expect(component.busy()).toBe(false);
});
it('finalizes through the server and reports a stale validation failure', async () => {
const component = editor();
api.finalize.mockReturnValue(of(show));
await component.finalize();
expect(component.current()?.finalized).toBe(true);
api.finalize.mockReturnValue(
throwError(
() => new HttpErrorResponse({ status: 400, error: { message: 'Changing gap violated' } }),
),
);
await component.finalize();
expect(component.error()).toContain('Changing gap violated');
});
});

View File

@@ -0,0 +1,178 @@
import { Component, inject, input, OnInit, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { DanceGroup, Show } from '../core/api-models';
import { SchoolApi } from '../core/school-api';
import { apiError } from '../core/api-error';
import { DialogFrame } from './dialog-frame';
@Component({
selector: 'app-schedule-editor',
imports: [FormsModule, DialogFrame],
template: `
<app-dialog-frame
[title]="show().name + ' — indeling'"
[busy]="busy()"
(dismissed)="closed.emit()"
>
<p>
{{ !dirty() && current()?.finalized ? 'Definitief' : 'Concept' }} · x =
{{ show().preferredDayParts }}
</p>
<p>
Tussen twee dansen van dezelfde danser zijn twee andere optredens nodig. Je kunt een
onvolledige indeling als concept opslaan.
</p>
@if (error()) {
<p role="alert" class="alert error">{{ error() }}</p>
}
@if (dirty()) {
<p role="status">
Niet-opgeslagen wijzigingen. Sla het concept op om alle regels te controleren.
</p>
}
@for (violation of current()?.violations ?? []; track $index) {
<p class="alert error">{{ violation }}</p>
}
<fieldset [disabled]="busy()">
@for (part of show().dayParts; track $index; let p = $index) {
<fieldset>
<legend>
{{ part.date }} · {{ part.type }} ({{ assignments[p].length }} optredens)
</legend>
<ol>
@for (id of assignments[p]; track $index; let i = $index) {
<li>
{{ name(id) }}
<button
type="button"
[disabled]="i === 0"
(click)="reorder(p, i, -1)"
[attr.aria-label]="name(id) + ' omhoog'"
>
↑
</button>
<button
type="button"
[disabled]="i === assignments[p].length - 1"
(click)="reorder(p, i, 1)"
[attr.aria-label]="name(id) + ' omlaag'"
>
↓
</button>
<label
>Verplaatsen naar
<select [ngModel]="p" (ngModelChange)="move(p, i, +$event)">
@for (target of show().dayParts; track $index; let t = $index) {
<option [ngValue]="t">{{ target.date }} · {{ target.type }}</option>
}
</select>
</label>
<button type="button" (click)="remove(p, i)">Optreden verwijderen</button>
</li>
}
</ol>
<label
>Groep toevoegen
<select [ngModel]="null" (ngModelChange)="add(p, +$event)">
<option [ngValue]="null">Kies een groep</option>
@for (group of groups(); track group.id) {
@if (group.dancerIds.length && !assignments[p].includes(group.id)) {
<option [ngValue]="group.id">{{ group.category }}</option>
}
}
</select>
</label>
</fieldset>
}
</fieldset>
<div class="dialog-actions">
<button type="button" class="button secondary" [disabled]="busy()" (click)="closed.emit()">
Gereed
</button>
<button
type="button"
class="button primary"
[disabled]="busy() || !dirty()"
(click)="save()"
>
Concept opslaan
</button>
<button
type="button"
class="button primary"
[disabled]="busy() || dirty() || current()?.finalized || !!current()?.violations?.length"
(click)="finalize()"
>
Definitief maken
</button>
</div>
</app-dialog-frame>
`,
})
export class ScheduleEditor implements OnInit {
readonly show = input.required<Show>();
readonly groups = input.required<DanceGroup[]>();
readonly schoolId = input.required<number>();
readonly closed = output<void>();
readonly saved = output<void>();
readonly busy = signal(false);
readonly dirty = signal(false);
readonly error = signal('');
readonly current = signal<Show | null>(null);
private readonly api = inject(SchoolApi);
assignments: number[][] = [];
ngOnInit(): void {
this.current.set(this.show());
this.assignments = this.show().dayParts.map((p) => [...p.danceGroupIds]);
}
name(id: number): string {
return this.groups().find((g) => g.id === id)?.category ?? `Groep ${id}`;
}
reorder(p: number, i: number, delta: number): void {
const list = this.assignments[p];
[list[i], list[i + delta]] = [list[i + delta], list[i]];
this.dirty.set(true);
}
move(p: number, i: number, target: number): void {
if (target === p || this.assignments[target].includes(this.assignments[p][i])) return;
this.assignments[target].push(this.assignments[p].splice(i, 1)[0]);
this.dirty.set(true);
}
add(p: number, id: number): void {
if (!id || this.assignments[p].includes(id)) return;
this.assignments[p].push(id);
this.dirty.set(true);
}
remove(p: number, i: number): void {
this.assignments[p].splice(i, 1);
this.dirty.set(true);
}
async save(): Promise<void> {
await this.perform(false);
}
async finalize(): Promise<void> {
await this.perform(true);
}
private async perform(finalize: boolean): Promise<void> {
if (this.busy()) return;
this.busy.set(true);
this.error.set('');
try {
const result = await firstValueFrom(
finalize
? this.api.finalize(this.schoolId(), this.show().id)
: this.api.saveAssignments(this.schoolId(), this.show().id, this.assignments),
);
this.current.set(result);
this.assignments = result.dayParts.map((p) => [...p.danceGroupIds]);
this.dirty.set(false);
this.saved.emit();
} catch (error: unknown) {
this.error.set(apiError(error));
} finally {
this.busy.set(false);
}
}
}