Dashboard (dashboard/src/dashboard.tsx, settings.tsx, apiAcademicPeriods.ts): - Refactor loadHolidayStatus to useCallback with stable empty-deps reference; removes location.pathname dependency that caused overlapping API calls at mount and left the banner unresolved via request-sequence cancellation - Add key prop derived from severity:text to Syncfusion MessageComponent to force remount on state change, fixing stale banner that ignored React prop/children updates - Correct German transliterated text to proper Umlauts throughout visible UI strings (fuer -> für, oe -> ö, ae -> ä etc. across dashboard and settings views) Backend (server/init_academic_periods.py): - Refactor to idempotent two-phase flow: seed default periods only when table is empty; on every run activate exactly the non-archived period covering date.today() - Enforces single-active invariant by deactivating all periods before promoting match - Emits explicit warning when no period covers current date instead of doing nothing Deployment (docker-compose.prod.yml): - Add init_academic_periods.py to server startup chain after migrations and defaults; eliminates manual post-deploy step to set an active academic period Release docs: - program-info.json: bump to 2026.1.0-alpha.16; fix JSON parse error caused by typographic curly quotes in the new changelog entry - TECH-CHANGELOG.md: detailed alpha.16 section with root-cause motivation for both dashboard refactoring decisions (unstable callback ref + Syncfusion stale render) - DEV-CHANGELOG.md: document dashboard refactor, Syncfusion key fix, Umlaut changes, and program-info JSON regression and fix - README.md: add Latest Release Highlights section for alpha.16 - .github/copilot-instructions.md: sync file map, prod bootstrap note, backend and frontend pattern additions for academic period init and Syncfusion remount pattern
157 lines
5.0 KiB
TypeScript
157 lines
5.0 KiB
TypeScript
export type AcademicPeriod = {
|
|
id: number;
|
|
name: string;
|
|
displayName?: string | null;
|
|
startDate: string; // YYYY-MM-DD
|
|
endDate: string; // YYYY-MM-DD
|
|
periodType: 'schuljahr' | 'semester' | 'trimester';
|
|
isActive: boolean;
|
|
isArchived: boolean;
|
|
archivedAt?: string | null;
|
|
archivedBy?: number | null;
|
|
createdAt?: string;
|
|
updatedAt?: string;
|
|
};
|
|
|
|
export type PeriodUsage = {
|
|
linked_events: number;
|
|
has_active_recurrence: boolean;
|
|
blockers: string[];
|
|
};
|
|
|
|
function normalizeAcademicPeriod(period: any): AcademicPeriod {
|
|
return {
|
|
id: Number(period.id),
|
|
name: period.name,
|
|
displayName: period.displayName ?? period.display_name ?? null,
|
|
startDate: period.startDate ?? period.start_date,
|
|
endDate: period.endDate ?? period.end_date,
|
|
periodType: period.periodType ?? period.period_type,
|
|
isActive: Boolean(period.isActive ?? period.is_active),
|
|
isArchived: Boolean(period.isArchived ?? period.is_archived),
|
|
archivedAt: period.archivedAt ?? period.archived_at ?? null,
|
|
archivedBy: period.archivedBy ?? period.archived_by ?? null,
|
|
createdAt: period.createdAt ?? period.created_at,
|
|
updatedAt: period.updatedAt ?? period.updated_at,
|
|
};
|
|
}
|
|
|
|
async function api<T>(url: string, init?: RequestInit): Promise<T> {
|
|
const res = await fetch(url, { credentials: 'include', cache: 'no-store', ...init });
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
try {
|
|
const err = JSON.parse(text);
|
|
throw new Error(err.error || `HTTP ${res.status}`);
|
|
} catch {
|
|
throw new Error(`HTTP ${res.status}: ${text}`);
|
|
}
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export async function getAcademicPeriodForDate(date: Date): Promise<AcademicPeriod | null> {
|
|
const iso = date.toISOString().slice(0, 10);
|
|
const { period } = await api<{ period: any | null }>(
|
|
`/api/academic_periods/for_date?date=${iso}`
|
|
);
|
|
return period ? normalizeAcademicPeriod(period) : null;
|
|
}
|
|
|
|
export async function listAcademicPeriods(options?: {
|
|
includeArchived?: boolean;
|
|
archivedOnly?: boolean;
|
|
}): Promise<AcademicPeriod[]> {
|
|
const params = new URLSearchParams();
|
|
if (options?.includeArchived) {
|
|
params.set('includeArchived', '1');
|
|
}
|
|
if (options?.archivedOnly) {
|
|
params.set('archivedOnly', '1');
|
|
}
|
|
const query = params.toString();
|
|
const { periods } = await api<{ periods: any[] }>(
|
|
`/api/academic_periods${query ? `?${query}` : ''}`
|
|
);
|
|
return Array.isArray(periods) ? periods.map(normalizeAcademicPeriod) : [];
|
|
}
|
|
|
|
export async function getAcademicPeriod(id: number): Promise<AcademicPeriod> {
|
|
const { period } = await api<{ period: any }>(`/api/academic_periods/${id}`);
|
|
return normalizeAcademicPeriod(period);
|
|
}
|
|
|
|
export async function getActiveAcademicPeriod(): Promise<AcademicPeriod | null> {
|
|
const { period } = await api<{ period: any | null }>(`/api/academic_periods/active`);
|
|
return period ? normalizeAcademicPeriod(period) : null;
|
|
}
|
|
|
|
export async function createAcademicPeriod(payload: {
|
|
name: string;
|
|
displayName?: string;
|
|
startDate: string;
|
|
endDate: string;
|
|
periodType: 'schuljahr' | 'semester' | 'trimester';
|
|
}): Promise<AcademicPeriod> {
|
|
const { period } = await api<{ period: any }>(`/api/academic_periods`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
return normalizeAcademicPeriod(period);
|
|
}
|
|
|
|
export async function updateAcademicPeriod(
|
|
id: number,
|
|
payload: Partial<{
|
|
name: string;
|
|
displayName: string | null;
|
|
startDate: string;
|
|
endDate: string;
|
|
periodType: 'schuljahr' | 'semester' | 'trimester';
|
|
}>
|
|
): Promise<AcademicPeriod> {
|
|
const { period } = await api<{ period: any }>(`/api/academic_periods/${id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
return normalizeAcademicPeriod(period);
|
|
}
|
|
|
|
export async function setActiveAcademicPeriod(id: number): Promise<AcademicPeriod> {
|
|
const { period } = await api<{ period: any }>(`/api/academic_periods/${id}/activate`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
return normalizeAcademicPeriod(period);
|
|
}
|
|
|
|
export async function archiveAcademicPeriod(id: number): Promise<AcademicPeriod> {
|
|
const { period } = await api<{ period: any }>(`/api/academic_periods/${id}/archive`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
return normalizeAcademicPeriod(period);
|
|
}
|
|
|
|
export async function restoreAcademicPeriod(id: number): Promise<AcademicPeriod> {
|
|
const { period } = await api<{ period: any }>(`/api/academic_periods/${id}/restore`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
return normalizeAcademicPeriod(period);
|
|
}
|
|
|
|
export async function getAcademicPeriodUsage(id: number): Promise<PeriodUsage> {
|
|
const { usage } = await api<{ usage: PeriodUsage }>(`/api/academic_periods/${id}/usage`);
|
|
return usage;
|
|
}
|
|
|
|
export async function deleteAcademicPeriod(id: number): Promise<void> {
|
|
await api(`/api/academic_periods/${id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|