S18 Added OR Tools rules for scheduling show
All checks were successful
Build, test and publish / verify (push) Successful in 6m12s
All checks were successful
Build, test and publish / verify (push) Successful in 6m12s
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
70
agiledancers-fe/src/app/shared/coach-editor.ts
Normal file
70
agiledancers-fe/src/app/shared/coach-editor.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
88
agiledancers-fe/src/app/shared/schedule-editor.spec.ts
Normal file
88
agiledancers-fe/src/app/shared/schedule-editor.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
178
agiledancers-fe/src/app/shared/schedule-editor.ts
Normal file
178
agiledancers-fe/src/app/shared/schedule-editor.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user