All checks were successful
Build, test and publish / verify (push) Successful in 7m31s
351 lines
11 KiB
TypeScript
351 lines
11 KiB
TypeScript
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,
|
|
DayPart,
|
|
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-entity-editor',
|
|
imports: [FormsModule, DialogFrame],
|
|
template: `
|
|
<app-dialog-frame [title]="title" [busy]="busy()" (dismissed)="closed.emit()">
|
|
<form #form="ngForm" (ngSubmit)="form.valid && save()">
|
|
@if (error()) {
|
|
<p class="alert error" role="alert">{{ error() }}</p>
|
|
}
|
|
@if (target().kind === 'group') {
|
|
<label
|
|
>Naam / categorie<input
|
|
autofocus
|
|
name="category"
|
|
[(ngModel)]="name"
|
|
required
|
|
pattern=".*\\S.*"
|
|
maxlength="160"
|
|
placeholder="Bijvoorbeeld: Hiphop tieners"
|
|
/></label>
|
|
<label for="group-type">Type</label
|
|
><select id="group-type" name="type" [(ngModel)]="type" required>
|
|
@for (option of types; track option.value) {
|
|
<option [ngValue]="option.value">{{ option.label }}</option>
|
|
}
|
|
</select>
|
|
<div class="form-grid">
|
|
<label
|
|
>Leeftijd vanaf<input
|
|
type="number"
|
|
name="startAge"
|
|
[(ngModel)]="startAge"
|
|
required
|
|
min="0"
|
|
step="1"
|
|
/></label>
|
|
<label
|
|
>Leeftijd tot en met<input
|
|
type="number"
|
|
name="endAge"
|
|
[(ngModel)]="endAge"
|
|
required
|
|
[min]="startAge ?? 0"
|
|
step="1"
|
|
/></label>
|
|
</div>
|
|
<fieldset>
|
|
<legend>Dansstijlen <span class="muted">(optioneel)</span></legend>
|
|
<div class="checkbox-grid">
|
|
@for (style of styles; track style.value) {
|
|
<label class="checkbox"
|
|
><input
|
|
type="checkbox"
|
|
[name]="style.value"
|
|
[ngModel]="selectedStyles.includes(style.value)"
|
|
(ngModelChange)="toggleStyle(style.value, $event)"
|
|
/>{{ style.label }}</label
|
|
>
|
|
}
|
|
</div>
|
|
</fieldset>
|
|
} @else {
|
|
<label
|
|
>Naam<input
|
|
autofocus
|
|
name="name"
|
|
[(ngModel)]="name"
|
|
required
|
|
pattern=".*\\S.*"
|
|
maxlength="160"
|
|
[placeholder]="target().kind === 'school' ? 'Bijvoorbeeld: 24 Dance Studio' : 'Naam'"
|
|
/></label>
|
|
@if (target().kind === 'dancer') {
|
|
<label
|
|
>Geboortedatum<input
|
|
type="date"
|
|
name="birthDate"
|
|
[(ngModel)]="date"
|
|
required
|
|
[max]="today"
|
|
/></label>
|
|
}
|
|
@if (target().kind === 'show') {
|
|
<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) {
|
|
<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>
|
|
}
|
|
}
|
|
}
|
|
<div class="dialog-actions">
|
|
<button
|
|
type="button"
|
|
class="button secondary"
|
|
[disabled]="busy()"
|
|
(click)="closed.emit()"
|
|
>
|
|
Annuleren
|
|
</button>
|
|
<button
|
|
class="button primary"
|
|
type="submit"
|
|
[disabled]="busy() || form.invalid || !validDetails()"
|
|
>
|
|
{{ busy() ? 'Opslaan…' : 'Opslaan' }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</app-dialog-frame>
|
|
`,
|
|
})
|
|
export class EntityEditor implements OnInit {
|
|
readonly target = input.required<EditorTarget>();
|
|
readonly revision = input<number>();
|
|
private originalRevision?: number;
|
|
readonly schoolId = input<number>();
|
|
readonly shows = input<Show[]>([]);
|
|
readonly closed = output<void>();
|
|
readonly saved = output<void>();
|
|
readonly busy = signal(false);
|
|
readonly error = signal('');
|
|
readonly types = GROUP_TYPES;
|
|
readonly styles = GROUP_STYLES;
|
|
readonly today = new Date().toLocaleDateString('en-CA');
|
|
private readonly api = inject(SchoolApi);
|
|
dayParts: DayPart[] = [];
|
|
private preferredDayPartsValue = 2;
|
|
private preferredDayPartsEdited = false;
|
|
|
|
get preferredDayParts(): number {
|
|
return this.preferredDayPartsValue;
|
|
}
|
|
set preferredDayParts(value: number) {
|
|
this.preferredDayPartsValue = value;
|
|
this.preferredDayPartsEdited = true;
|
|
}
|
|
name = '';
|
|
date = '';
|
|
type: GroupType = 'REGULAR';
|
|
startAge: number | null = 0;
|
|
endAge: number | null = 18;
|
|
selectedStyles: GroupStyle[] = [];
|
|
|
|
get title(): string {
|
|
const labels = { school: 'Dansschool', dancer: 'Danser', group: 'Dansgroep', show: 'Show' };
|
|
return `${labels[this.target().kind]} ${this.target().value ? 'bewerken' : 'toevoegen'}`;
|
|
}
|
|
ngOnInit(): void {
|
|
this.originalRevision = this.revision();
|
|
const target = this.target();
|
|
if (!target.value) return;
|
|
if (target.kind === 'group') {
|
|
this.name = target.value.category;
|
|
this.type = target.value.type;
|
|
this.startAge = target.value.startAge;
|
|
this.endAge = target.value.endAge;
|
|
this.selectedStyles = [...target.value.styles];
|
|
} else {
|
|
this.name = target.value.name;
|
|
if (target.kind === 'dancer') this.date = target.value.birthDate;
|
|
if (target.kind === 'show') {
|
|
this.dayParts = target.value.dayParts.map((p) => ({ ...p }));
|
|
this.preferredDayParts = target.value.preferredDayParts;
|
|
}
|
|
}
|
|
}
|
|
addPart(): void {
|
|
if (this.dayParts.length >= 6) return;
|
|
this.dayParts.push({ date: '', type: 'AFTERNOON', danceGroupIds: [] });
|
|
if (!this.target().value && !this.preferredDayPartsEdited)
|
|
this.preferredDayPartsValue = Math.min(2, this.dayParts.length);
|
|
}
|
|
removePart(index: number): void {
|
|
this.dayParts.splice(index, 1);
|
|
}
|
|
validCounts(): boolean {
|
|
return (
|
|
Number.isInteger(this.preferredDayParts) &&
|
|
this.preferredDayParts >= 1 &&
|
|
this.preferredDayParts <= 6
|
|
);
|
|
}
|
|
toggleStyle(style: GroupStyle, checked: boolean): void {
|
|
this.selectedStyles = checked
|
|
? [...this.selectedStyles, style]
|
|
: this.selectedStyles.filter((value) => value !== style);
|
|
}
|
|
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.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 &&
|
|
this.endAge !== null &&
|
|
Number.isInteger(this.startAge) &&
|
|
Number.isInteger(this.endAge) &&
|
|
this.startAge >= 0 &&
|
|
this.endAge >= this.startAge
|
|
);
|
|
return true;
|
|
}
|
|
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 }))) ||
|
|
current.value.preferredDayParts !== this.preferredDayParts)
|
|
)
|
|
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();
|
|
const id = target.value?.id;
|
|
const schoolId = this.schoolId();
|
|
let request: Observable<unknown>;
|
|
switch (target.kind) {
|
|
case 'school':
|
|
request = this.api.saveSchool({ name: this.name.trim() }, id, this.originalRevision);
|
|
break;
|
|
case 'dancer':
|
|
request = this.api.saveDancer(
|
|
schoolId!,
|
|
{ name: this.name.trim(), birthDate: this.date },
|
|
id,
|
|
this.originalRevision,
|
|
);
|
|
break;
|
|
case 'group':
|
|
request = this.api.saveGroup(
|
|
schoolId!,
|
|
{
|
|
category: this.name.trim(),
|
|
type: this.type,
|
|
startAge: this.startAge!,
|
|
endAge: this.endAge!,
|
|
styles: this.selectedStyles,
|
|
},
|
|
id,
|
|
this.originalRevision,
|
|
);
|
|
break;
|
|
case 'show':
|
|
request = this.api.saveShow(
|
|
schoolId!,
|
|
{
|
|
name: this.name.trim(),
|
|
dayParts: this.dayParts.map((p) => ({ date: p.date, type: p.type })),
|
|
preferredDayParts: this.preferredDayParts,
|
|
},
|
|
id,
|
|
this.originalRevision,
|
|
);
|
|
break;
|
|
}
|
|
try {
|
|
await firstValueFrom(request);
|
|
this.saved.emit();
|
|
} catch (error: unknown) {
|
|
this.error.set(apiError(error));
|
|
} finally {
|
|
this.busy.set(false);
|
|
}
|
|
}
|
|
}
|