S9 OR Tools basic scheduling
All checks were successful
Build, test and publish / verify (push) Successful in 1m48s

This commit is contained in:
2026-10-30 15:00:45 +01:00
parent f64aa9123d
commit 1b4c7f7f2f
43 changed files with 1042 additions and 118 deletions

View File

@@ -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 {