feat: period-scoped holiday management, archive lifecycle, and docs/release sync
- add period-scoped holiday architecture end-to-end - model: scope `SchoolHoliday` to `academic_period_id` - migrations: add holiday-period scoping, academic-period archive lifecycle, and merge migration head - API: extend holidays with manual CRUD, period validation, duplicate prevention, and overlap merge/conflict handling - recurrence: regenerate holiday exceptions using period-scoped holiday sets - improve frontend settings and holiday workflows - bind holiday import/list/manual CRUD to selected academic period - show detailed import outcomes (inserted/updated/merged/skipped/conflicts) - fix file-picker UX (visible selected filename) - align settings controls/dialogs with defined frontend design rules - scope appointments/dashboard holiday loading to active period - add shared date formatting utility - strengthen academic period lifecycle handling - add archive/restore/delete flow and backend validations/blocker checks - extend API client support for lifecycle operations - release/docs updates and cleanup - bump user-facing version to `2026.1.0-alpha.15` with new changelog entry - add tech changelog entry for alpha.15 backend changes - refactor README to concise index and archive historical implementation docs - fix Copilot instruction link diagnostics via local `.github` design-rules reference
This commit is contained in:
@@ -1,16 +1,35 @@
|
||||
export type AcademicPeriod = {
|
||||
id: number;
|
||||
name: string;
|
||||
display_name?: string | null;
|
||||
start_date: string; // YYYY-MM-DD
|
||||
end_date: string; // YYYY-MM-DD
|
||||
period_type: 'schuljahr' | 'semester' | 'trimester';
|
||||
is_active: boolean;
|
||||
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[];
|
||||
};
|
||||
|
||||
async function api<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, { credentials: 'include', ...init });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -22,21 +41,99 @@ export async function getAcademicPeriodForDate(date: Date): Promise<AcademicPeri
|
||||
return period ?? null;
|
||||
}
|
||||
|
||||
export async function listAcademicPeriods(): Promise<AcademicPeriod[]> {
|
||||
const { periods } = await api<{ periods: AcademicPeriod[] }>(`/api/academic_periods`);
|
||||
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: AcademicPeriod[] }>(
|
||||
`/api/academic_periods${query ? `?${query}` : ''}`
|
||||
);
|
||||
return Array.isArray(periods) ? periods : [];
|
||||
}
|
||||
|
||||
export async function getAcademicPeriod(id: number): Promise<AcademicPeriod> {
|
||||
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}`);
|
||||
return period;
|
||||
}
|
||||
|
||||
export async function getActiveAcademicPeriod(): Promise<AcademicPeriod | null> {
|
||||
const { period } = await api<{ period: AcademicPeriod | null }>(`/api/academic_periods/active`);
|
||||
return period ?? null;
|
||||
}
|
||||
|
||||
export async function setActiveAcademicPeriod(id: number): Promise<AcademicPeriod> {
|
||||
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/active`, {
|
||||
export async function createAcademicPeriod(payload: {
|
||||
name: string;
|
||||
displayName?: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
periodType: 'schuljahr' | 'semester' | 'trimester';
|
||||
}): Promise<AcademicPeriod> {
|
||||
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return 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: AcademicPeriod }>(`/api/academic_periods/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return period;
|
||||
}
|
||||
|
||||
export async function setActiveAcademicPeriod(id: number): Promise<AcademicPeriod> {
|
||||
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}/activate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
return period;
|
||||
}
|
||||
|
||||
export async function archiveAcademicPeriod(id: number): Promise<AcademicPeriod> {
|
||||
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}/archive`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
return period;
|
||||
}
|
||||
|
||||
export async function restoreAcademicPeriod(id: number): Promise<AcademicPeriod> {
|
||||
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}/restore`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
return 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' },
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user