All checks were successful
Build, test and publish / verify (push) Successful in 7m31s
277 lines
8.6 KiB
TypeScript
277 lines
8.6 KiB
TypeScript
import { SchoolTransfer } from '../shared/school-transfer';
|
||
import { Component, computed, DestroyRef, effect, inject, OnInit, signal } from '@angular/core';
|
||
import { DatePipe } from '@angular/common';
|
||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||
import { ActivatedRoute, Router, RouterLink, RouterLinkActive } from '@angular/router';
|
||
import { catchError, EMPTY, firstValueFrom, switchMap } from 'rxjs';
|
||
import {
|
||
DanceGroup,
|
||
EditorTarget,
|
||
GROUP_STYLES,
|
||
GROUP_TYPES,
|
||
ResourceKind,
|
||
SchoolData,
|
||
Show,
|
||
} from '../core/api-models';
|
||
import { apiError } from '../core/api-error';
|
||
import { SchedulingStatus } from '../core/scheduling-status';
|
||
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 {
|
||
kind: ResourceKind;
|
||
id: number;
|
||
name: string;
|
||
explanation: string;
|
||
}
|
||
|
||
@Component({
|
||
selector: 'app-school-page',
|
||
imports: [
|
||
ScheduleEditor,
|
||
CoachEditor,
|
||
SchoolTransfer,
|
||
DatePipe,
|
||
RouterLink,
|
||
RouterLinkActive,
|
||
EntityEditor,
|
||
ConfirmDialog,
|
||
GroupMembers,
|
||
],
|
||
templateUrl: './school-page.html',
|
||
})
|
||
export class SchoolPage implements OnInit {
|
||
private readonly api = inject(SchoolApi);
|
||
private readonly route = inject(ActivatedRoute);
|
||
private readonly router = inject(Router);
|
||
private readonly destroyRef = inject(DestroyRef);
|
||
readonly data = signal<SchoolData | null>(null);
|
||
readonly section = signal('overview');
|
||
readonly loading = signal(true);
|
||
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 schedulingStatus = inject(SchedulingStatus);
|
||
private readonly starting = signal<number[]>([]);
|
||
readonly scheduling = computed(() => [
|
||
...this.starting(),
|
||
...this.schedulingStatus
|
||
.runs()
|
||
.filter((run) => run.schoolId === this.data()?.school.id && run.state === 'RUNNING')
|
||
.map((run) => run.showId),
|
||
]);
|
||
private readonly completed = new Set<string>();
|
||
|
||
constructor() {
|
||
effect(() => {
|
||
const schoolId = this.data()?.school.id;
|
||
let changed = false;
|
||
for (const run of this.schedulingStatus.runs()) {
|
||
if (run.schoolId !== schoolId || run.state === 'RUNNING' || this.completed.has(run.runId))
|
||
continue;
|
||
this.completed.add(run.runId);
|
||
changed = true;
|
||
}
|
||
if (changed) void this.reload();
|
||
});
|
||
}
|
||
readonly deleteBusy = signal(false);
|
||
readonly deleteError = signal('');
|
||
readonly search = signal('');
|
||
readonly dancers = computed(() =>
|
||
[...(this.data()?.dancers ?? [])]
|
||
.filter((d) => d.name.toLocaleLowerCase('nl').includes(this.search().toLocaleLowerCase('nl')))
|
||
.sort((a, b) => a.name.localeCompare(b.name, 'nl')),
|
||
);
|
||
readonly shows = computed(() =>
|
||
[...(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) => this.lastDate(show) >= this.today),
|
||
);
|
||
readonly tabs = [
|
||
{ value: 'overview', label: 'Overzicht' },
|
||
{ value: 'dancers', label: 'Dansers' },
|
||
{ value: 'groups', label: 'Dansgroepen' },
|
||
{ value: 'shows', label: 'Shows' },
|
||
];
|
||
|
||
ngOnInit(): void {
|
||
this.route.paramMap
|
||
.pipe(
|
||
switchMap((params) => {
|
||
this.loading.set(true);
|
||
this.error.set('');
|
||
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(
|
||
catchError((error) => {
|
||
this.data.set(null);
|
||
this.error.set(apiError(error));
|
||
this.loading.set(false);
|
||
return EMPTY;
|
||
}),
|
||
);
|
||
}),
|
||
takeUntilDestroyed(this.destroyRef),
|
||
)
|
||
.subscribe((data) => {
|
||
this.data.set(data);
|
||
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 {
|
||
return show.violations?.length === 0;
|
||
}
|
||
|
||
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.starting.update((ids) => [...ids, show.id]);
|
||
this.error.set('');
|
||
try {
|
||
const result = await firstValueFrom(this.api.schedule(schoolId, show.id));
|
||
this.schedulingStatus.started(result);
|
||
} catch (error: unknown) {
|
||
this.error.set(apiError(error));
|
||
} finally {
|
||
this.starting.update((ids) => ids.filter((id) => id !== show.id));
|
||
}
|
||
}
|
||
async reload(): Promise<void> {
|
||
const id = this.data()?.school.id;
|
||
if (id === undefined) return;
|
||
this.error.set('');
|
||
try {
|
||
const data = await firstValueFrom(this.api.schoolData(id));
|
||
if (this.data()?.school.id === id) this.data.set(data);
|
||
} catch (error: unknown) {
|
||
this.error.set(apiError(error));
|
||
}
|
||
}
|
||
retry(): void {
|
||
window.location.reload();
|
||
}
|
||
async saved(): Promise<void> {
|
||
this.editor.set(null);
|
||
this.notice.set('Wijzigingen opgeslagen.');
|
||
await this.reload();
|
||
}
|
||
groupType(value: string): string {
|
||
return GROUP_TYPES.find((option) => option.value === value)?.label ?? value;
|
||
}
|
||
groupStyle(value: string): string {
|
||
return GROUP_STYLES.find((option) => option.value === value)?.label ?? value;
|
||
}
|
||
groupNames(dancerId: number): string {
|
||
return (
|
||
this.data()
|
||
?.groups.filter((group) => group.dancerIds.includes(dancerId))
|
||
.map((group) => group.category)
|
||
.join(', ') || 'Nog geen groep'
|
||
);
|
||
}
|
||
private deletionRevision?: number;
|
||
|
||
askDelete(target: DeleteTarget): void {
|
||
this.deletionRevision = this.data()?.school.revision;
|
||
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 {
|
||
void this.reload();
|
||
const groupId = this.members()?.id;
|
||
this.data.update((data) =>
|
||
data
|
||
? {
|
||
...data,
|
||
groups: data.groups.map((group) =>
|
||
group.id === groupId ? { ...group, dancerIds: [...ids] } : group,
|
||
),
|
||
}
|
||
: data,
|
||
);
|
||
}
|
||
async confirmDelete(): Promise<void> {
|
||
const target = this.deleting();
|
||
const schoolId = this.data()?.school.id;
|
||
if (!target || schoolId === undefined || this.deleteBusy()) return;
|
||
this.deleteBusy.set(true);
|
||
this.deleteError.set('');
|
||
try {
|
||
await firstValueFrom(
|
||
this.api.delete(target.kind, schoolId, target.id, this.deletionRevision),
|
||
);
|
||
this.deleting.set(null);
|
||
if (target.kind === 'school') await this.router.navigate(['/schools']);
|
||
else {
|
||
this.notice.set(`${target.name} is verwijderd.`);
|
||
await this.reload();
|
||
}
|
||
} catch (error: unknown) {
|
||
this.deleteError.set(apiError(error));
|
||
} finally {
|
||
this.deleteBusy.set(false);
|
||
}
|
||
}
|
||
}
|