S9 OR Tools basic scheduling
All checks were successful
Build, test and publish / verify (push) Successful in 1m48s
All checks were successful
Build, test and publish / verify (push) Successful in 1m48s
This commit is contained in:
@@ -110,15 +110,28 @@ test('manages a school, shared memberships and shows through the real REST API',
|
||||
await tab(page, 'Shows');
|
||||
await page.getByRole('button', { name: 'Show toevoegen' }).click();
|
||||
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Zomershow');
|
||||
await page.getByRole('button', { name: 'Dagdeel toevoegen', exact: true }).click();
|
||||
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 expect(page.getByText('2027-06-12 · AFTERNOON:', { exact: false })).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');
|
||||
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 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');
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toContain('Zomershow');
|
||||
await dialog.dismiss();
|
||||
});
|
||||
await page.getByRole('button', { name: 'Opslaan', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Annuleren', exact: true }).click();
|
||||
|
||||
await tab(page, 'Overzicht');
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
|
||||
@@ -5,6 +5,8 @@ server {
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_read_timeout 1860s;
|
||||
proxy_send_timeout 1860s;
|
||||
proxy_pass http://${BACKEND_UPSTREAM};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"/api/**": {
|
||||
"target": "http://127.0.0.1:8080",
|
||||
"secure": false,
|
||||
"timeout": 1860000,
|
||||
"proxyTimeout": 1860000,
|
||||
"changeOrigin": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,17 @@ export interface DanceGroup {
|
||||
styles: GroupStyle[];
|
||||
dancerIds: number[];
|
||||
}
|
||||
export type DayPartType = 'MORNING' | 'AFTERNOON' | 'EVENING';
|
||||
export interface DayPart {
|
||||
date: string;
|
||||
type: DayPartType;
|
||||
danceGroupIds: number[];
|
||||
}
|
||||
export interface Show {
|
||||
id: number;
|
||||
name: string;
|
||||
date: string;
|
||||
dayParts: { type: 'MORNING' | 'AFTERNOON' | 'EVENING'; danceGroupIds: number[] }[];
|
||||
dayParts: DayPart[];
|
||||
appearances: Partial<Record<GroupType, number>>;
|
||||
}
|
||||
export interface SchoolRequest {
|
||||
name: string;
|
||||
@@ -43,7 +49,8 @@ export interface GroupRequest {
|
||||
}
|
||||
export interface ShowRequest {
|
||||
name: string;
|
||||
date: string;
|
||||
dayParts: { date: string; type: DayPartType }[];
|
||||
appearances?: Partial<Record<GroupType, number>>;
|
||||
}
|
||||
export type ResourceKind = 'school' | 'dancer' | 'group' | 'show';
|
||||
export type EditorTarget =
|
||||
|
||||
@@ -66,4 +66,11 @@ describe('SchoolApi contract', () => {
|
||||
.flush({ message: 'Invalid data' }, { status: 400, statusText: 'Bad Request' });
|
||||
expect(failure).toHaveBeenCalledOnce();
|
||||
});
|
||||
it('schedules synchronously with room for the 30-minute solver limit', () => {
|
||||
api.schedule(7, 3).subscribe();
|
||||
const request = http.expectOne('/api/dance-schools/7/shows/3/schedule');
|
||||
expect(request.request.method).toBe('POST');
|
||||
expect(request.request.timeout).toBe(1860000);
|
||||
request.flush({ optimal: false, message: 'Valid result; optimal balance not proven' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,13 @@ export class SchoolApi {
|
||||
saveShow(schoolId: number, body: ShowRequest, id?: number): Observable<Show> {
|
||||
return this.save(`${this.base}/${schoolId}/shows`, body, id);
|
||||
}
|
||||
schedule(schoolId: number, showId: number): Observable<{ optimal: boolean; message: string }> {
|
||||
return this.http.post<{ optimal: boolean; message: string }>(
|
||||
`${this.base}/${schoolId}/shows/${showId}/schedule`,
|
||||
null,
|
||||
{ timeout: 1860000 },
|
||||
);
|
||||
}
|
||||
delete(kind: ResourceKind, schoolId: number, id: number): Observable<void> {
|
||||
const collection = { school: '', dancer: '/dancers', group: '/dance-groups', show: '/shows' }[
|
||||
kind
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
<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 class="spotlight-date">{{ dateRange(show) }}</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
|
||||
@@ -345,16 +345,39 @@
|
||||
@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>
|
||||
<strong>{{ firstDate(show) | date: 'dd' }}</strong
|
||||
><span>{{ firstDate(show) | 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>
|
||||
<p>{{ dateRange(show) }}</p>
|
||||
@for (part of show.dayParts; track $index) {
|
||||
<p>{{ part.date }} · {{ part.type }}: {{ groupNamesFor(part.danceGroupIds) }}</p>
|
||||
}
|
||||
<p role="status">
|
||||
{{
|
||||
scheduleValid(show)
|
||||
? 'Planning voldoet aan alle optredensaantallen.'
|
||||
: 'Geen geldige planning. Plan de groepen opnieuw in.'
|
||||
}}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="button primary"
|
||||
[disabled]="
|
||||
scheduling().includes(show.id) || !context.groups.length || !show.dayParts.length
|
||||
"
|
||||
(click)="schedule(show)"
|
||||
>
|
||||
{{
|
||||
scheduling().includes(show.id)
|
||||
? 'Bezig met inplannen…'
|
||||
: 'Schedule groups — vervangt huidige toewijzingen'
|
||||
}}
|
||||
</button>
|
||||
@if (!context.groups.length || !show.dayParts.length) {
|
||||
<p>Inplannen vereist minstens één dansgroep en één dagdeel.</p>
|
||||
}
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<button
|
||||
@@ -385,7 +408,7 @@
|
||||
<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>
|
||||
<p>Plan een show met maximaal zes gedateerde dagdelen.</p>
|
||||
<button class="button primary" type="button" (click)="editor.set({ kind: 'show' })">
|
||||
Eerste show plannen
|
||||
</button>
|
||||
@@ -398,6 +421,7 @@
|
||||
<app-entity-editor
|
||||
[target]="target"
|
||||
[schoolId]="context.school.id"
|
||||
[shows]="context.shows"
|
||||
(closed)="editor.set(null)"
|
||||
(saved)="saved()"
|
||||
/>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
GROUP_TYPES,
|
||||
ResourceKind,
|
||||
SchoolData,
|
||||
Show,
|
||||
} from '../core/api-models';
|
||||
import { apiError } from '../core/api-error';
|
||||
import { SchoolApi } from '../core/school-api';
|
||||
@@ -51,6 +52,7 @@ export class SchoolPage implements OnInit {
|
||||
readonly editor = signal<EditorTarget | null>(null);
|
||||
readonly members = signal<DanceGroup | null>(null);
|
||||
readonly deleting = signal<DeleteTarget | null>(null);
|
||||
readonly scheduling = signal<number[]>([]);
|
||||
readonly deleteBusy = signal(false);
|
||||
readonly deleteError = signal('');
|
||||
readonly search = signal('');
|
||||
@@ -60,10 +62,14 @@ export class SchoolPage implements OnInit {
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'nl')),
|
||||
);
|
||||
readonly shows = computed(() =>
|
||||
[...(this.data()?.shows ?? [])].sort((a, b) => a.date.localeCompare(b.date)),
|
||||
[...(this.data()?.shows ?? [])].sort((a, b) =>
|
||||
this.firstDate(a).localeCompare(this.firstDate(b)),
|
||||
),
|
||||
);
|
||||
readonly today = new Date().toLocaleDateString('en-CA');
|
||||
readonly upcoming = computed(() => this.shows().find((show) => show.date >= this.today));
|
||||
readonly upcoming = computed(() =>
|
||||
this.shows().find((show) => this.lastDate(show) >= this.today),
|
||||
);
|
||||
readonly tabs = [
|
||||
{ value: 'overview', label: 'Overzicht' },
|
||||
{ value: 'dancers', label: 'Dansers' },
|
||||
@@ -98,6 +104,74 @@ export class SchoolPage implements OnInit {
|
||||
this.loading.set(false);
|
||||
});
|
||||
}
|
||||
firstDate(show: Show): string {
|
||||
return show.dayParts.map((p) => p.date).sort()[0] ?? '';
|
||||
}
|
||||
lastDate(show: Show): string {
|
||||
return (
|
||||
show.dayParts
|
||||
.map((p) => p.date)
|
||||
.sort()
|
||||
.at(-1) ?? ''
|
||||
);
|
||||
}
|
||||
dateRange(show: Show): string {
|
||||
const first = this.firstDate(show),
|
||||
last = this.lastDate(show);
|
||||
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)),
|
||||
)
|
||||
);
|
||||
}
|
||||
groupNamesFor(ids: number[]): string {
|
||||
return (
|
||||
ids.map((id) => this.data()?.groups.find((g) => g.id === id)?.category ?? '?').join(', ') ||
|
||||
'Geen groepen'
|
||||
);
|
||||
}
|
||||
async schedule(show: Show): Promise<void> {
|
||||
const schoolId = this.data()?.school.id;
|
||||
if (
|
||||
schoolId === undefined ||
|
||||
this.scheduling().includes(show.id) ||
|
||||
!show.dayParts.length ||
|
||||
!this.data()?.groups.length
|
||||
)
|
||||
return;
|
||||
if (
|
||||
!window.confirm(
|
||||
`Groepen inplannen voor ${show.name}? Dit vervangt alle huidige toewijzingen.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
this.scheduling.update((ids) => [...ids, show.id]);
|
||||
this.error.set('');
|
||||
try {
|
||||
const result = await firstValueFrom(this.api.schedule(schoolId, show.id));
|
||||
this.notice.set(
|
||||
result.optimal ? 'Optimale balans.' : 'Geldige planning; optimale balans niet bewezen.',
|
||||
);
|
||||
await this.reload();
|
||||
} catch (error: unknown) {
|
||||
this.error.set(apiError(error));
|
||||
} finally {
|
||||
this.scheduling.update((ids) => ids.filter((id) => id !== show.id));
|
||||
}
|
||||
}
|
||||
async reload(): Promise<void> {
|
||||
const id = this.data()?.school.id;
|
||||
if (id === undefined) return;
|
||||
@@ -133,6 +207,17 @@ export class SchoolPage implements OnInit {
|
||||
}
|
||||
askDelete(target: DeleteTarget): void {
|
||||
this.deleteError.set('');
|
||||
if (target.kind === 'group') {
|
||||
const names =
|
||||
this.data()
|
||||
?.shows.filter((s) => s.dayParts.some((p) => p.danceGroupIds.length))
|
||||
.map((s) => s.name) ?? [];
|
||||
if (names.length)
|
||||
target = {
|
||||
...target,
|
||||
explanation: `${target.explanation} De planning kan ongeldig worden van: ${names.join(', ')}.`,
|
||||
};
|
||||
}
|
||||
this.deleting.set(target);
|
||||
}
|
||||
membershipChanged(ids: number[]): void {
|
||||
|
||||
@@ -3,7 +3,7 @@ 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';
|
||||
import { EditorTarget, Show } from '../core/api-models';
|
||||
|
||||
describe('EntityEditor', () => {
|
||||
const api = { saveSchool: vi.fn(), saveGroup: vi.fn(), saveDancer: vi.fn(), saveShow: vi.fn() };
|
||||
@@ -14,10 +14,11 @@ describe('EntityEditor', () => {
|
||||
providers: [{ provide: SchoolApi, useValue: api }],
|
||||
});
|
||||
});
|
||||
function editor(target: EditorTarget) {
|
||||
function editor(target: EditorTarget, shows: Show[] = []) {
|
||||
const fixture = TestBed.createComponent(EntityEditor);
|
||||
fixture.componentRef.setInput('target', target);
|
||||
fixture.componentRef.setInput('schoolId', 7);
|
||||
fixture.componentRef.setInput('shows', shows);
|
||||
fixture.componentInstance.ngOnInit();
|
||||
return fixture.componentInstance;
|
||||
}
|
||||
@@ -66,4 +67,107 @@ describe('EntityEditor', () => {
|
||||
expect(component.error()).toContain('niet bereikbaar');
|
||||
expect(component.busy()).toBe(false);
|
||||
});
|
||||
const show: Show = {
|
||||
id: 3,
|
||||
name: 'Summer',
|
||||
dayParts: [
|
||||
{ date: '2027-06-01', type: 'AFTERNOON', danceGroupIds: [4] },
|
||||
{ date: '2027-06-02', type: 'AFTERNOON', danceGroupIds: [4] },
|
||||
],
|
||||
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', () => {
|
||||
const component = editor({ kind: 'show' });
|
||||
component.name = 'Show';
|
||||
expect(component.validDetails()).toBe(true);
|
||||
component.addPart();
|
||||
component.dayParts[0].date = '2027-06-01';
|
||||
component.addPart();
|
||||
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.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;
|
||||
expect(component.validDetails()).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves saved counts and requires explicit correction when reducing to one daypart', () => {
|
||||
const component = editor({ kind: 'show', value: show });
|
||||
component.addPart();
|
||||
component.dayParts[2].date = '2027-06-03';
|
||||
expect(component.appearances.SHOW_CASE).toBe(2);
|
||||
component.removePart(2);
|
||||
component.removePart(1);
|
||||
expect(component.appearances.REGULAR).toBe(2);
|
||||
expect(component.validDetails()).toBe(false);
|
||||
component.setSingleCounts();
|
||||
expect(component.validDetails()).toBe(true);
|
||||
});
|
||||
|
||||
it('allows cancelling an invalidating edit and skips warnings for name-only edits', async () => {
|
||||
api.saveShow.mockReturnValue(of(show));
|
||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
try {
|
||||
const component = editor({ kind: 'show', value: show });
|
||||
component.appearances.REGULAR = 1;
|
||||
await component.save();
|
||||
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('Summer'));
|
||||
expect(api.saveShow).not.toHaveBeenCalled();
|
||||
component.appearances.REGULAR = 2;
|
||||
confirm.mockClear();
|
||||
component.name = 'Renamed';
|
||||
await component.save();
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(api.saveShow).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
confirm.mockRestore();
|
||||
}
|
||||
});
|
||||
it('names affected shows before adding a group and allows cancellation', async () => {
|
||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
try {
|
||||
const component = editor({ kind: 'group' }, [show]);
|
||||
component.name = 'New group';
|
||||
await component.save();
|
||||
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('Summer'));
|
||||
expect(api.saveGroup).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
confirm.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('displays one disabled appearance field for every type with one daypart', async () => {
|
||||
// jsdom does not implement native dialog opening.
|
||||
const descriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, 'showModal');
|
||||
Object.defineProperty(HTMLDialogElement.prototype, 'showModal', {
|
||||
configurable: true,
|
||||
value: () => undefined,
|
||||
});
|
||||
try {
|
||||
const fixture = TestBed.createComponent(EntityEditor);
|
||||
fixture.componentRef.setInput('target', { kind: 'show' });
|
||||
fixture.componentInstance.addPart();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
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);
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(HTMLDialogElement.prototype, 'showModal', descriptor);
|
||||
else Reflect.deleteProperty(HTMLDialogElement.prototype, 'showModal');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
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 {
|
||||
EditorTarget,
|
||||
GROUP_STYLES,
|
||||
GROUP_TYPES,
|
||||
GroupStyle,
|
||||
GroupType,
|
||||
DayPart,
|
||||
Show,
|
||||
} from '../core/api-models';
|
||||
import { SchoolApi } from '../core/school-api';
|
||||
import { apiError } from '../core/api-error';
|
||||
import { DialogFrame } from './dialog-frame';
|
||||
@@ -89,11 +97,69 @@ import { DialogFrame } from './dialog-frame';
|
||||
/></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>
|
||||
<fieldset>
|
||||
<legend>Gedateerde dagdelen (maximaal zes)</legend>
|
||||
@for (part of dayParts; track $index; let i = $index) {
|
||||
<div class="form-grid">
|
||||
<label
|
||||
>Datum<input
|
||||
type="date"
|
||||
[name]="'partDate' + i"
|
||||
[(ngModel)]="part.date"
|
||||
required
|
||||
/></label>
|
||||
<label
|
||||
>Dagdeel<select [name]="'partType' + i" [(ngModel)]="part.type" required>
|
||||
<option value="MORNING">Ochtend</option>
|
||||
<option value="AFTERNOON">Middag</option>
|
||||
<option value="EVENING">Avond</option>
|
||||
</select></label
|
||||
>
|
||||
<button type="button" class="text-button" (click)="removePart(i)">
|
||||
Dagdeel verwijderen
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="button secondary"
|
||||
[disabled]="dayParts.length >= 6"
|
||||
(click)="addPart()"
|
||||
>
|
||||
Dagdeel toevoegen
|
||||
</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>
|
||||
} @else {
|
||||
<p class="form-hint">Zonder dagdelen blijft deze show ongepland.</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
<div class="dialog-actions">
|
||||
@@ -120,6 +186,7 @@ import { DialogFrame } from './dialog-frame';
|
||||
export class EntityEditor implements OnInit {
|
||||
readonly target = input.required<EditorTarget>();
|
||||
readonly schoolId = input<number>();
|
||||
readonly shows = input<Show[]>([]);
|
||||
readonly closed = output<void>();
|
||||
readonly saved = output<void>();
|
||||
readonly busy = signal(false);
|
||||
@@ -128,6 +195,9 @@ export class EntityEditor implements OnInit {
|
||||
readonly styles = GROUP_STYLES;
|
||||
readonly today = new Date().toLocaleDateString('en-CA');
|
||||
private readonly api = inject(SchoolApi);
|
||||
dayParts: DayPart[] = [];
|
||||
appearances: Partial<Record<GroupType, number>> = {};
|
||||
private countsInitialized = false;
|
||||
name = '';
|
||||
date = '';
|
||||
type: GroupType = 'REGULAR';
|
||||
@@ -151,9 +221,48 @@ export class EntityEditor implements OnInit {
|
||||
} else {
|
||||
this.name = target.value.name;
|
||||
if (target.kind === 'dancer') this.date = target.value.birthDate;
|
||||
if (target.kind === 'show') this.date = target.value.date;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
);
|
||||
}
|
||||
toggleStyle(style: GroupStyle, checked: boolean): void {
|
||||
this.selectedStyles = checked
|
||||
? [...this.selectedStyles, style]
|
||||
@@ -162,7 +271,13 @@ export class EntityEditor implements OnInit {
|
||||
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 === 'show')
|
||||
return (
|
||||
this.dayParts.length <= 6 &&
|
||||
this.dayParts.every((p) => !!p.date && !!p.type) &&
|
||||
new Set(this.dayParts.map((p) => p.date + ':' + p.type)).size === this.dayParts.length &&
|
||||
(!this.dayParts.length || this.validCounts())
|
||||
);
|
||||
if (this.target().kind === 'group')
|
||||
return (
|
||||
this.startAge !== null &&
|
||||
@@ -176,6 +291,28 @@ export class EntityEditor implements OnInit {
|
||||
}
|
||||
async save(): Promise<void> {
|
||||
if (this.busy() || !this.validDetails()) return;
|
||||
const current = this.target();
|
||||
let affected: Show[] = [];
|
||||
if (current.kind === 'group' && (!current.value || current.value.type !== this.type))
|
||||
affected = this.shows();
|
||||
if (
|
||||
current.kind === 'show' &&
|
||||
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))
|
||||
)
|
||||
affected = [current.value];
|
||||
const names = affected
|
||||
.filter((s) => s.dayParts.some((p) => p.danceGroupIds.length))
|
||||
.map((s) => s.name);
|
||||
if (
|
||||
names.length &&
|
||||
!window.confirm(
|
||||
`Deze wijziging kan de planning ongeldig maken van: ${names.join(', ')}. Doorgaan?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
this.busy.set(true);
|
||||
this.error.set('');
|
||||
const target = this.target();
|
||||
@@ -207,7 +344,15 @@ export class EntityEditor implements OnInit {
|
||||
);
|
||||
break;
|
||||
case 'show':
|
||||
request = this.api.saveShow(schoolId!, { name: this.name.trim(), date: this.date }, id);
|
||||
request = this.api.saveShow(
|
||||
schoolId!,
|
||||
{
|
||||
name: this.name.trim(),
|
||||
dayParts: this.dayParts.map((p) => ({ date: p.date, type: p.type })),
|
||||
appearances: this.appearances,
|
||||
},
|
||||
id,
|
||||
);
|
||||
break;
|
||||
}
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user