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

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