feat(academic-periods): period selector, active period

API, holiday indicators; UI polish; bump version

Dashboard:

Add Syncfusion academic period dropdown next to group selector
Navigate scheduler to today's month/day within selected period year on change
Show adjacent holiday plan badge; keep "holidays in view" counter on the right
Compact dropdown widths for a tighter toolbar
Default blocking of scheduling on holidays; block entries styled like all-day; black text styling
API:

Add academic periods routes: list, get active, set active (POST), for_date
Register blueprint in wsgi
Holidays:

Support TXT/CSV upload; headerless TXT uses columns 2-4; region remains null
Docs:

Update shared Copilot instructions with academic periods endpoints and dashboard integration details
This commit is contained in:
2025-09-21 14:35:38 +00:00
parent 41194000a4
commit eaf6e32446
9 changed files with 640 additions and 44 deletions

View File

@@ -24,8 +24,9 @@ Use this as your shared context when proposing changes. Keep edits minimal and m
- Screenshots: server-side folders `server/received_screenshots/` and `server/screenshots/`; Nginx exposes `/screenshots/{uuid}.jpg` via `server/wsgi.py` route.
## Data model highlights (see `models/models.py`)
- Enums: `EventType` (presentation, website, video, message, webuntis) and `MediaType` (file/website types).
- Tables: `clients`, `client_groups`, `events`, `event_media`, `users`.
- Enums: `EventType` (presentation, website, video, message, webuntis), `MediaType` (file/website types), and `AcademicPeriodType` (schuljahr, semester, trimester).
- Tables: `clients`, `client_groups`, `events`, `event_media`, `users`, `academic_periods`, `school_holidays`.
- Academic periods: `academic_periods` table supports educational institution cycles (school years, semesters). Events and media can be optionally linked via `academic_period_id` (nullable for backward compatibility).
- Times are stored as timezone-aware; treat comparisons in UTC (see scheduler and routes/events).
## API patterns
@@ -36,11 +37,19 @@ Use this as your shared context when proposing changes. Keep edits minimal and m
- Groups: `server/routes/groups.py` computes “alive” using a grace period that varies by `ENV`.
- Events: `server/routes/events.py` serializes enum values to strings and normalizes times to UTC.
- Media: `server/routes/eventmedia.py` implements a simple file manager API rooted at `server/media/`.
- Academic periods: `server/routes/academic_periods.py` exposes:
- `GET /api/academic_periods` — list all periods
- `GET /api/academic_periods/active` — currently active period
- `POST /api/academic_periods/active` — set active period (deactivates others)
- `GET /api/academic_periods/for_date?date=YYYY-MM-DD` — period covering given date
## Frontend patterns (dashboard)
- Vite React app; proxies `/api` and `/screenshots` to API in dev (`vite.config.ts`).
- Uses Syncfusion components; Vite config pre-bundles specific packages to avoid alias issues.
- Environment: `VITE_API_URL` provided at build/run; in dev compose, proxy handles `/api` so local fetches can use relative `/api/...` paths.
- Scheduler (appointments page): top bar includes Group and Academic Period selectors (Syncfusion DropDownList). Selecting a period calls `POST /api/academic_periods/active`, moves the calendar to todays month/day within the period year, and refreshes a right-aligned indicator row showing:
- Holidays present in the current view (count)
- Period label (display_name or name) with a badge indicating whether any holidays exist in that period (overlap check)
## Local development
- Compose: development is `docker-compose.yml` + `docker-compose.override.yml`.
@@ -49,6 +58,7 @@ Use this as your shared context when proposing changes. Keep edits minimal and m
- Mosquitto: allows anonymous in dev; WebSocket on :9001.
- Common env vars: `DB_CONN`, `DB_USER`, `DB_PASSWORD`, `DB_HOST=db`, `DB_NAME`, `ENV`, `MQTT_USER`, `MQTT_PASSWORD`.
- Alembic: prod compose runs `alembic ... upgrade head` and `server/init_defaults.py` before gunicorn.
- Use `server/init_academic_periods.py` to populate default Austrian school years after migration.
## Production
- `docker-compose.prod.yml` uses prebuilt images (`ghcr.io/robbstarkaustria/*`).
@@ -74,6 +84,7 @@ Use this as your shared context when proposing changes. Keep edits minimal and m
3) Manage `Session()` lifecycle, and
4) Return JSON-safe values (serialize enums and datetimes).
- When extending media types, update `MediaType` and any logic in `eventmedia` and dashboard that depends on it.
- Academic periods: Events/media can be optionally associated with periods for educational organization. Only one period should be active at a time (`is_active=True`).
## Quick examples
- Add client description persists to DB and publishes group via MQTT: see `PUT /api/clients/<uuid>/description` in `routes/clients.py`.
@@ -81,3 +92,10 @@ Use this as your shared context when proposing changes. Keep edits minimal and m
- Listener heartbeat path: `infoscreen/<uuid>/heartbeat` → sets `clients.last_alive`.
Questions or unclear areas? Tell us if you need: exact devcontainer debugging steps, stricter Alembic workflow, or a seed dataset beyond `init_defaults.py`.
## Academic Periods System
- **Purpose**: Organize events and media by educational cycles (school years, semesters, trimesters).
- **Design**: Fully backward compatible - existing events/media continue to work without period assignment.
- **Usage**: New events/media can optionally reference `academic_period_id` for better organization and filtering.
- **Constraints**: Only one period can be active at a time; use `init_academic_periods.py` for Austrian school year setup.
- **UI Integration**: The dashboard highlights the currently selected period and whether a holiday plan exists within that date range. Holiday linkage currently uses date overlap with `school_holidays`; an explicit `academic_period_id` on `school_holidays` can be added later if tighter association is required.

View File

@@ -1,6 +1,6 @@
{
"appName": "Infoscreen-Management",
"version": "2025.1.0-alpha.6",
"version": "2025.1.0-alpha.7",
"copyright": "© 2025 Third-Age-Applications",
"supportContact": "support@third-age-applications.com",
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
@@ -30,6 +30,18 @@
"commitId": "8d1df7199cb7"
},
"changelog": [
{
"version": "2025.1.0-alpha.7",
"date": "2025-09-21",
"changes": [
"🧭 UI: Periode-Auswahl (Syncfusion) neben Gruppenauswahl; kompaktes Layout",
"✅ Anzeige: Abzeichen für vorhandenen Ferienplan + Zähler Ferien im Blick",
"🛠️ API: Endpunkte für akademische Perioden (list, active GET/POST, for_date)",
"📅 Scheduler: Standardmäßig keine Terminierung in Ferien; Block-Darstellung wie Ganztagesereignis; schwarze Textfarbe",
"📤 Ferien: Upload von TXT/CSV (headless TXT nutzt Spalten 24)",
"🔧 UX: Schalter in einer Reihe; Dropdown-Breiten optimiert"
]
},
{
"version": "2025.1.0-alpha.6",
"date": "2025-09-20",

View File

@@ -0,0 +1,42 @@
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;
};
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}`);
return res.json();
}
export async function getAcademicPeriodForDate(date: Date): Promise<AcademicPeriod | null> {
const iso = date.toISOString().slice(0, 10);
const { period } = await api<{ period: AcademicPeriod | null }>(
`/api/academic_periods/for_date?date=${iso}`
);
return period ?? null;
}
export async function listAcademicPeriods(): Promise<AcademicPeriod[]> {
const { periods } = await api<{ periods: AcademicPeriod[] }>(`/api/academic_periods`);
return Array.isArray(periods) ? periods : [];
}
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`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
return period;
}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import {
ScheduleComponent,
Day,
@@ -23,7 +23,21 @@ import { getGroupColor } from './groupColors';
import { deleteEvent } from './apiEvents';
import CustomEventModal from './components/CustomEventModal';
import { fetchMediaById } from './apiClients';
import { Presentation, Globe, Video, MessageSquare, School } from 'lucide-react';
import { listHolidays, type Holiday } from './apiHolidays';
import {
getAcademicPeriodForDate,
listAcademicPeriods,
setActiveAcademicPeriod,
} from './apiAcademicPeriods';
import {
Presentation,
Globe,
Video,
MessageSquare,
School,
CheckCircle,
AlertCircle,
} from 'lucide-react';
import { renderToStaticMarkup } from 'react-dom/server';
import caGregorian from './cldr/ca-gregorian.json';
import numbers from './cldr/numbers.json';
@@ -43,6 +57,8 @@ type Event = {
StartTime: Date;
EndTime: Date;
IsAllDay: boolean;
IsBlock?: boolean; // Syncfusion block appointment
isHoliday?: boolean; // marker for styling/logic
MediaId?: string | number;
SlideshowInterval?: number;
WebsiteUrl?: string;
@@ -123,15 +139,15 @@ const eventTemplate = (event: Event) => {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<div style={{ display: 'flex', alignItems: 'center', color: '#fff', marginBottom: 2 }}>
<div style={{ display: 'flex', alignItems: 'center', color: '#000', marginBottom: 2 }}>
{IconComponent && (
<span style={{ verticalAlign: 'middle', display: 'inline-block', marginRight: 6 }}>
<IconComponent size={18} color="#fff" />
<IconComponent size={18} color="#000" />
</span>
)}
<span style={{ marginTop: 3 }}>{event.Subject}</span>
</div>
<div style={{ fontSize: '0.95em', color: '#fff', marginTop: -2 }}>{timeString}</div>
<div style={{ fontSize: '0.95em', color: '#000', marginTop: -2 }}>{timeString}</div>
</div>
);
};
@@ -140,11 +156,20 @@ const Appointments: React.FC = () => {
const [groups, setGroups] = useState<Group[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [events, setEvents] = useState<Event[]>([]);
const [holidays, setHolidays] = useState<Holiday[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const [modalInitialData, setModalInitialData] = useState({});
const [schedulerKey, setSchedulerKey] = useState(0);
const [editMode, setEditMode] = useState(false); // NEU: Editiermodus
const [showInactive, setShowInactive] = React.useState(true);
const [allowScheduleOnHolidays, setAllowScheduleOnHolidays] = React.useState(false);
const [showHolidayList, setShowHolidayList] = React.useState(true);
const scheduleRef = React.useRef<ScheduleComponent | null>(null);
const [holidaysInView, setHolidaysInView] = React.useState<number>(0);
const [schoolYearLabel, setSchoolYearLabel] = React.useState<string>('');
const [hasSchoolYearPlan, setHasSchoolYearPlan] = React.useState<boolean>(false);
const [periods, setPeriods] = React.useState<{ id: number; label: string }[]>([]);
const [activePeriodId, setActivePeriodId] = React.useState<number | null>(null);
// Gruppen laden
useEffect(() => {
@@ -158,6 +183,24 @@ const Appointments: React.FC = () => {
.catch(console.error);
}, []);
// Holidays laden
useEffect(() => {
listHolidays()
.then(res => setHolidays(res.holidays || []))
.catch(err => console.error('Ferien laden fehlgeschlagen:', err));
}, []);
// Perioden laden (Dropdown)
useEffect(() => {
listAcademicPeriods()
.then(all => {
setPeriods(all.map(p => ({ id: p.id, label: p.display_name || p.name })));
const active = all.find(p => p.is_active);
setActivePeriodId(active ? active.id : null);
})
.catch(err => console.error('Akademische Perioden laden fehlgeschlagen:', err));
}, []);
// fetchAndSetEvents als useCallback definieren, damit die Dependency korrekt ist:
const fetchAndSetEvents = React.useCallback(async () => {
if (!selectedGroupId) {
@@ -178,7 +221,7 @@ const Appointments: React.FC = () => {
}));
setEvents(mapped);
} catch (err) {
console.error(err);
console.error('Fehler beim Laden der Termine:', err);
}
}, [selectedGroupId, showInactive]);
@@ -191,11 +234,168 @@ const Appointments: React.FC = () => {
}
}, [selectedGroupId, showInactive, fetchAndSetEvents]);
// Helper: prüfe, ob Zeitraum einen Feiertag/Ferienbereich schneidet
const isWithinHolidayRange = React.useCallback(
(start: Date, end: Date) => {
// normalisiere Endzeit minimal (Syncfusion nutzt exklusive Enden in einigen Fällen)
const adjEnd = new Date(end);
// keine Änderung nötig unsere eigenen Events sind präzise
for (const h of holidays) {
// Holiday dates are strings YYYY-MM-DD (local date)
const hs = new Date(h.start_date + 'T00:00:00');
const he = new Date(h.end_date + 'T23:59:59');
if (
(start >= hs && start <= he) ||
(adjEnd >= hs && adjEnd <= he) ||
(start <= hs && adjEnd >= he)
) {
return true;
}
}
return false;
},
[holidays]
);
// Baue Holiday-Anzeige-Events und Block-Events
const holidayDisplayEvents: Event[] = useMemo(() => {
if (!showHolidayList) return [];
const out: Event[] = [];
for (const h of holidays) {
const start = new Date(h.start_date + 'T00:00:00');
const end = new Date(h.end_date + 'T23:59:59');
out.push({
Id: `holiday-${h.id}-display`,
Subject: h.name,
StartTime: start,
EndTime: end,
IsAllDay: true,
isHoliday: true,
});
}
return out;
}, [holidays, showHolidayList]);
const holidayBlockEvents: Event[] = useMemo(() => {
if (allowScheduleOnHolidays) return [];
const out: Event[] = [];
for (const h of holidays) {
const start = new Date(h.start_date + 'T00:00:00');
const end = new Date(h.end_date + 'T23:59:59');
out.push({
Id: `holiday-${h.id}-block`,
Subject: h.name,
StartTime: start,
EndTime: end,
IsAllDay: true,
IsBlock: true,
isHoliday: true,
});
}
return out;
}, [holidays, allowScheduleOnHolidays]);
const dataSource = useMemo(() => {
return [...events, ...holidayDisplayEvents, ...holidayBlockEvents];
}, [events, holidayDisplayEvents, holidayBlockEvents]);
// Aktive akademische Periode für Datum aus dem Backend ermitteln
const refreshAcademicPeriodFor = React.useCallback(
async (baseDate: Date) => {
try {
const p = await getAcademicPeriodForDate(baseDate);
if (!p) {
setSchoolYearLabel('');
setHasSchoolYearPlan(false);
return;
}
// Anzeige: bevorzugt display_name, sonst name
const label = p.display_name ? p.display_name : p.name;
setSchoolYearLabel(label);
// Existiert ein Ferienplan innerhalb der Periode?
const start = new Date(p.start_date + 'T00:00:00');
const end = new Date(p.end_date + 'T23:59:59');
let exists = false;
for (const h of holidays) {
const hs = new Date(h.start_date + 'T00:00:00');
const he = new Date(h.end_date + 'T23:59:59');
if (hs <= end && he >= start) {
exists = true;
break;
}
}
setHasSchoolYearPlan(exists);
} catch (e) {
console.error('Akademische Periode laden fehlgeschlagen:', e);
setSchoolYearLabel('');
setHasSchoolYearPlan(false);
}
},
[holidays]
);
// Anzahl an Ferienzeiträumen in aktueller Ansicht ermitteln + Perioden-Indikator setzen
const updateHolidaysInView = React.useCallback(() => {
const inst = scheduleRef.current;
if (!inst) {
setHolidaysInView(0);
return;
}
const view = inst.currentView as 'Day' | 'Week' | 'WorkWeek' | 'Month' | 'Agenda';
const baseDate = inst.selectedDate as Date;
if (!baseDate) {
setHolidaysInView(0);
return;
}
let rangeStart = new Date(baseDate);
let rangeEnd = new Date(baseDate);
if (view === 'Day' || view === 'Agenda') {
rangeStart.setHours(0, 0, 0, 0);
rangeEnd.setHours(23, 59, 59, 999);
} else if (view === 'Week' || view === 'WorkWeek') {
const day = baseDate.getDay();
const diffToMonday = (day + 6) % 7; // Monday=0
rangeStart = new Date(baseDate);
rangeStart.setDate(baseDate.getDate() - diffToMonday);
rangeStart.setHours(0, 0, 0, 0);
rangeEnd = new Date(rangeStart);
rangeEnd.setDate(rangeStart.getDate() + 6);
rangeEnd.setHours(23, 59, 59, 999);
} else if (view === 'Month') {
rangeStart = new Date(baseDate.getFullYear(), baseDate.getMonth(), 1, 0, 0, 0, 0);
rangeEnd = new Date(baseDate.getFullYear(), baseDate.getMonth() + 1, 0, 23, 59, 59, 999);
}
let count = 0;
for (const h of holidays) {
const hs = new Date(h.start_date + 'T00:00:00');
const he = new Date(h.end_date + 'T23:59:59');
const overlaps =
(hs >= rangeStart && hs <= rangeEnd) ||
(he >= rangeStart && he <= rangeEnd) ||
(hs <= rangeStart && he >= rangeEnd);
if (overlaps) count += 1;
}
setHolidaysInView(count);
// Perioden-Indikator über Backend prüfen
refreshAcademicPeriodFor(baseDate);
}, [holidays, refreshAcademicPeriodFor]);
// Aktualisiere Indikator wenn Ferien oder Ansicht (Key) wechseln
React.useEffect(() => {
updateHolidaysInView();
}, [holidays, updateHolidaysInView, schedulerKey]);
return (
<div>
<h1 className="text-2xl font-bold mb-4">Terminmanagement</h1>
<div
style={{ marginBottom: 16, maxWidth: 500, display: 'flex', alignItems: 'center', gap: 12 }}
style={{
marginBottom: 16,
display: 'flex',
alignItems: 'center',
gap: 16,
flexWrap: 'wrap',
}}
>
<label htmlFor="groupDropdown" className="mb-0 mr-2" style={{ whiteSpace: 'nowrap' }}>
Raumgruppe auswählen:
@@ -206,13 +406,67 @@ const Appointments: React.FC = () => {
fields={{ text: 'name', value: 'id' }}
placeholder="Gruppe auswählen"
value={selectedGroupId}
width="240px"
change={(e: { value: string }) => {
// <--- Typ für e ergänzt
setEvents([]); // Events sofort leeren
setSelectedGroupId(e.value);
}}
style={{ flex: 1 }}
style={{}}
/>
{/* Akademische Periode Selector + Plan-Badge */}
<span style={{ marginLeft: 8, whiteSpace: 'nowrap' }}>Periode:</span>
<DropDownListComponent
id="periodDropdown"
dataSource={periods}
fields={{ text: 'label', value: 'id' }}
placeholder="Periode wählen"
value={activePeriodId ?? undefined}
width="260px"
change={async (e: { value: number }) => {
const id = Number(e.value);
if (!id) return;
try {
const updated = await setActiveAcademicPeriod(id);
setActivePeriodId(updated.id);
// Zum gleichen Tag/Monat (heute) innerhalb der gewählten Periode springen
const today = new Date();
const targetYear = new Date(updated.start_date).getFullYear();
const target = new Date(targetYear, today.getMonth(), today.getDate(), 12, 0, 0);
if (scheduleRef.current) {
scheduleRef.current.selectedDate = target;
scheduleRef.current.dataBind?.();
}
updateHolidaysInView();
} catch (err) {
console.error('Aktive Periode setzen fehlgeschlagen:', err);
}
}}
style={{}}
/>
{/* School-year/period plan badge (adjacent) */}
<span
title={hasSchoolYearPlan ? 'Ferienplan ist hinterlegt' : 'Kein Ferienplan hinterlegt'}
style={{
background: hasSchoolYearPlan ? '#dcfce7' : '#f3f4f6',
border: hasSchoolYearPlan ? '1px solid #86efac' : '1px solid #e5e7eb',
color: '#000',
padding: '4px 10px',
borderRadius: 16,
fontSize: 12,
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
{hasSchoolYearPlan ? (
<CheckCircle size={14} color="#166534" />
) : (
<AlertCircle size={14} color="#6b7280" />
)}
{schoolYearLabel || 'Periode'}
</span>
</div>
<button
className="e-btn e-success mb-4"
@@ -239,7 +493,15 @@ const Appointments: React.FC = () => {
>
Neuen Termin anlegen
</button>
<div style={{ marginBottom: 16 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 24,
marginBottom: 16,
flexWrap: 'wrap',
}}
>
<label>
<input
type="checkbox"
@@ -249,6 +511,41 @@ const Appointments: React.FC = () => {
/>
Vergangene Termine anzeigen
</label>
<label>
<input
type="checkbox"
checked={allowScheduleOnHolidays}
onChange={e => setAllowScheduleOnHolidays(e.target.checked)}
style={{ marginRight: 8 }}
/>
Termine an Ferientagen erlauben
</label>
<label>
<input
type="checkbox"
checked={showHolidayList}
onChange={e => setShowHolidayList(e.target.checked)}
style={{ marginRight: 8 }}
/>
Ferien im Kalender anzeigen
</label>
{/* Right-aligned indicators */}
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}>
{/* Holidays-in-view badge */}
<span
title="Anzahl der Ferientage/-zeiträume in der aktuellen Ansicht"
style={{
background: holidaysInView > 0 ? '#ffe8cc' : '#f3f4f6',
border: holidaysInView > 0 ? '1px solid #ffcf99' : '1px solid #e5e7eb',
color: '#000',
padding: '4px 10px',
borderRadius: 16,
fontSize: 12,
}}
>
{holidaysInView > 0 ? `Ferien im Blick: ${holidaysInView}` : 'Keine Ferien in Ansicht'}
</span>
</div>
</div>
<CustomEventModal
open={modalOpen}
@@ -277,17 +574,26 @@ const Appointments: React.FC = () => {
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
groupColor={selectedGroupId ? getGroupColor(selectedGroupId, groups) : undefined}
editMode={editMode} // NEU: Prop für Editiermodus
blockHolidays={!allowScheduleOnHolidays}
isHolidayRange={(s, e) => isWithinHolidayRange(s, e)}
/>
<ScheduleComponent
ref={scheduleRef}
key={schedulerKey} // <-- dynamischer Key
height="750px"
locale="de"
currentView="Week"
eventSettings={{
dataSource: events,
dataSource: dataSource,
fields: { isBlock: 'IsBlock' },
template: eventTemplate, // <--- Hier das Template setzen!
}}
actionComplete={() => updateHolidaysInView()}
cellClick={args => {
if (!allowScheduleOnHolidays && isWithinHolidayRange(args.startTime, args.endTime)) {
args.cancel = true;
return; // block creation on holidays
}
// args.startTime und args.endTime sind Date-Objekte
args.cancel = true; // Verhindert die Standardaktion
setModalInitialData({
@@ -353,6 +659,20 @@ const Appointments: React.FC = () => {
}
}}
eventRendered={(args: EventRenderedArgs) => {
// Blende Nicht-Ferien-Events aus, falls sie in Ferien fallen und Terminieren nicht erlaubt ist
if (!allowScheduleOnHolidays && args.data && !args.data.isHoliday) {
const s =
args.data.StartTime instanceof Date
? args.data.StartTime
: new Date(args.data.StartTime);
const e =
args.data.EndTime instanceof Date ? args.data.EndTime : new Date(args.data.EndTime);
if (isWithinHolidayRange(s, e)) {
args.cancel = true;
return;
}
}
if (selectedGroupId && args.data && args.data.Id) {
const groupColor = getGroupColor(selectedGroupId, groups);
const now = new Date();
@@ -389,13 +709,26 @@ const Appointments: React.FC = () => {
subjectText;
}
// Vergangene Termine: Raumgruppenfarbe mit Transparenz
// Vergangene Termine: Raumgruppenfarbe
if (args.data.EndTime && args.data.EndTime < now) {
args.element.style.backgroundColor = groupColor ? `${groupColor}` : '#f3f3f3';
args.element.style.color = '';
args.element.style.color = '#000';
} else if (groupColor) {
args.element.style.backgroundColor = groupColor;
args.element.style.color = '';
args.element.style.color = '#000';
}
// Spezielle Darstellung für Ferienanzeige-Events
if (args.data.isHoliday && !args.data.IsBlock) {
args.element.style.backgroundColor = '#ffe8cc'; // sanftes Orange
args.element.style.border = '1px solid #ffcf99';
args.element.style.color = '#000';
}
// Gleiche Darstellung für Ferien-Block-Events
if (args.data.isHoliday && args.data.IsBlock) {
args.element.style.backgroundColor = '#ffe8cc';
args.element.style.border = '1px solid #ffcf99';
args.element.style.color = '#000';
}
}
}}
@@ -429,6 +762,25 @@ const Appointments: React.FC = () => {
}
// Syncfusion soll das Event nicht selbst löschen
args.cancel = true;
} else if (
(args.requestType === 'eventCreate' || args.requestType === 'eventChange') &&
!allowScheduleOnHolidays
) {
// Verhindere Erstellen/Ändern in Ferienbereichen (Failsafe, falls eingebauter Editor genutzt wird)
type PartialEventLike = { StartTime?: Date | string; EndTime?: Date | string };
const raw = (args as ActionEventArgs).data as
| PartialEventLike
| PartialEventLike[]
| undefined;
const data = Array.isArray(raw) ? raw[0] : raw;
if (data && data.StartTime && data.EndTime) {
const s = data.StartTime instanceof Date ? data.StartTime : new Date(data.StartTime);
const e = data.EndTime instanceof Date ? data.EndTime : new Date(data.EndTime);
if (isWithinHolidayRange(s, e)) {
args.cancel = true;
return;
}
}
}
}}
firstDayOfWeek={1}

View File

@@ -32,6 +32,8 @@ type CustomEventModalProps = {
groupName: string | { id: string | null; name: string };
groupColor?: string;
editMode?: boolean;
blockHolidays?: boolean;
isHolidayRange?: (start: Date, end: Date) => boolean;
};
const weekdayOptions = [
@@ -60,6 +62,8 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
groupName,
groupColor,
editMode,
blockHolidays,
isHolidayRange,
}) => {
const [title, setTitle] = React.useState(initialData.title || '');
const [startDate, setStartDate] = React.useState(initialData.startDate || null);
@@ -149,6 +153,34 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
if (!websiteUrl.trim()) newErrors.websiteUrl = 'Webseiten-URL ist erforderlich';
}
// Holiday blocking: prevent creating when range overlaps
if (
!editMode &&
blockHolidays &&
startDate &&
startTime &&
endTime &&
typeof isHolidayRange === 'function'
) {
const s = new Date(
startDate.getFullYear(),
startDate.getMonth(),
startDate.getDate(),
startTime.getHours(),
startTime.getMinutes()
);
const e = new Date(
startDate.getFullYear(),
startDate.getMonth(),
startDate.getDate(),
endTime.getHours(),
endTime.getMinutes()
);
if (isHolidayRange(s, e)) {
newErrors.startDate = 'Dieser Zeitraum liegt in den Ferien und ist gesperrt.';
}
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;

View File

@@ -44,17 +44,21 @@ const Einstellungen: React.FC = () => {
<section className="p-4 border rounded-md">
<h3 className="font-semibold mb-2">Schulferien importieren</h3>
<p className="text-sm text-gray-600 mb-2">
Laden Sie eine CSV-Datei mit den Spalten: <code>name</code>, <code>start_date</code>,{' '}
<code>end_date</code>, optional <code>region</code>.
Unterstützte Formate:
<br /> CSV mit Kopfzeile: <code>name</code>, <code>start_date</code>,{' '}
<code>end_date</code>, optional <code>region</code>
<br /> TXT/CSV ohne Kopfzeile mit Spalten: interner Name, <strong>Name</strong>,{' '}
<strong>Start (YYYYMMDD)</strong>, <strong>Ende (YYYYMMDD)</strong>, optional interne
Info (ignoriert)
</p>
<div className="flex items-center gap-3">
<input
type="file"
accept=".csv,text/csv"
accept=".csv,text/csv,.txt,text/plain"
onChange={e => setFile(e.target.files?.[0] ?? null)}
/>
<button className="e-btn e-primary" onClick={onUpload} disabled={!file || busy}>
{busy ? 'Importiere…' : 'CSV importieren'}
{busy ? 'Importiere…' : 'CSV/TXT importieren'}
</button>
</div>
{message && <div className="mt-2 text-sm">{message}</div>}

View File

@@ -0,0 +1,84 @@
from flask import Blueprint, jsonify, request
from server.database import Session
from models.models import AcademicPeriod
from datetime import datetime
academic_periods_bp = Blueprint(
'academic_periods', __name__, url_prefix='/api/academic_periods')
@academic_periods_bp.route('', methods=['GET'])
def list_academic_periods():
session = Session()
try:
periods = session.query(AcademicPeriod).order_by(
AcademicPeriod.start_date.asc()).all()
return jsonify({
'periods': [p.to_dict() for p in periods]
})
finally:
session.close()
@academic_periods_bp.route('/active', methods=['GET'])
def get_active_academic_period():
session = Session()
try:
period = session.query(AcademicPeriod).filter(
AcademicPeriod.is_active == True).first()
if not period:
return jsonify({'period': None}), 200
return jsonify({'period': period.to_dict()}), 200
finally:
session.close()
@academic_periods_bp.route('/for_date', methods=['GET'])
def get_period_for_date():
"""
Returns the academic period that covers the provided date (YYYY-MM-DD).
If multiple match, prefer the one with the latest start_date.
"""
date_str = request.args.get('date')
if not date_str:
return jsonify({'error': 'Missing required query param: date (YYYY-MM-DD)'}), 400
try:
target = datetime.strptime(date_str, '%Y-%m-%d').date()
except ValueError:
return jsonify({'error': 'Invalid date format. Expected YYYY-MM-DD'}), 400
session = Session()
try:
period = (
session.query(AcademicPeriod)
.filter(AcademicPeriod.start_date <= target, AcademicPeriod.end_date >= target)
.order_by(AcademicPeriod.start_date.desc())
.first()
)
return jsonify({'period': period.to_dict() if period else None}), 200
finally:
session.close()
@academic_periods_bp.route('/active', methods=['POST'])
def set_active_academic_period():
data = request.get_json(silent=True) or {}
period_id = data.get('id')
if period_id is None:
return jsonify({'error': 'Missing required field: id'}), 400
session = Session()
try:
target = session.query(AcademicPeriod).get(period_id)
if not target:
return jsonify({'error': 'AcademicPeriod not found'}), 404
# Deactivate all, then activate target
session.query(AcademicPeriod).filter(AcademicPeriod.is_active == True).update(
{AcademicPeriod.is_active: False}
)
target.is_active = True
session.commit()
session.refresh(target)
return jsonify({'period': target.to_dict()}), 200
finally:
session.close()

View File

@@ -24,9 +24,14 @@ def list_holidays():
@holidays_bp.route("/upload", methods=["POST"])
def upload_holidays():
"""
Accepts a CSV file upload (multipart/form-data) with columns like:
name,start_date,end_date,region
Dates can be in ISO (YYYY-MM-DD) or common European format (DD.MM.YYYY).
Accepts a CSV/TXT file upload (multipart/form-data).
Supported formats:
1) Headered CSV with columns (case-insensitive): name, start_date, end_date[, region]
- Dates: YYYY-MM-DD, DD.MM.YYYY, YYYY/MM/DD, or YYYYMMDD
2) Headerless CSV/TXT lines with columns:
[internal, name, start_yyyymmdd, end_yyyymmdd, optional_internal]
- Only columns 2-4 are used; 1 and 5 are ignored.
"""
if "file" not in request.files:
return jsonify({"error": "No file part"}), 400
@@ -35,26 +40,36 @@ def upload_holidays():
return jsonify({"error": "No selected file"}), 400
try:
content = file.read().decode("utf-8", errors="ignore")
# Try to auto-detect delimiter; default ','
raw = file.read()
# Try UTF-8 first (strict), then cp1252, then latin-1 as last resort
try:
content = raw.decode("utf-8")
except UnicodeDecodeError:
try:
content = raw.decode("cp1252")
except UnicodeDecodeError:
content = raw.decode("latin-1", errors="replace")
sniffer = csv.Sniffer()
dialect = None
try:
dialect = sniffer.sniff(content[:1024])
sample = content[:2048]
# Some files may contain a lot of quotes; allow Sniffer to guess delimiter
dialect = sniffer.sniff(sample)
except Exception:
pass
reader = csv.DictReader(io.StringIO(
content), dialect=dialect) if dialect else csv.DictReader(io.StringIO(content))
required = {"name", "start_date", "end_date"}
if not required.issubset(set(h.lower() for h in reader.fieldnames or [])):
return jsonify({"error": "CSV must contain headers: name, start_date, end_date"}), 400
def parse_date(s: str):
s = (s or "").strip()
if not s:
return None
# Try ISO first
# Numeric YYYYMMDD
if s.isdigit() and len(s) == 8:
try:
return datetime.strptime(s, "%Y%m%d").date()
except ValueError:
pass
# Common formats
for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%Y/%m/%d"):
try:
return datetime.strptime(s, fmt).date()
@@ -65,16 +80,18 @@ def upload_holidays():
session = Session()
inserted = 0
updated = 0
for row in reader:
# Normalize headers to lower-case keys
norm = {k.lower(): (v or "").strip() for k, v in row.items()}
name = norm.get("name")
start_date = parse_date(norm.get("start_date"))
end_date = parse_date(norm.get("end_date"))
region = norm.get("region") or None
if not name or not start_date or not end_date:
continue
# First, try headered CSV via DictReader
dict_reader = csv.DictReader(io.StringIO(
content), dialect=dialect) if dialect else csv.DictReader(io.StringIO(content))
fieldnames_lower = [h.lower() for h in (dict_reader.fieldnames or [])]
has_required_headers = {"name", "start_date",
"end_date"}.issubset(set(fieldnames_lower))
def upsert(name: str, start_date, end_date, region=None):
nonlocal inserted, updated
if not name or not start_date or not end_date:
return
existing = (
session.query(SchoolHoliday)
.filter(
@@ -86,9 +103,7 @@ def upload_holidays():
)
.first()
)
if existing:
# Optionally update region or source_file_name
existing.region = region
existing.source_file_name = file.filename
updated += 1
@@ -102,6 +117,41 @@ def upload_holidays():
))
inserted += 1
if has_required_headers:
for row in dict_reader:
norm = {k.lower(): (v or "").strip() for k, v in row.items()}
name = norm.get("name")
try:
start_date = parse_date(norm.get("start_date"))
end_date = parse_date(norm.get("end_date"))
except ValueError:
# Skip rows with unparseable dates
continue
region = (norm.get("region")
or None) if "region" in norm else None
upsert(name, start_date, end_date, region)
else:
# Fallback: headerless rows -> use columns [1]=name, [2]=start, [3]=end
reader = csv.reader(io.StringIO(
content), dialect=dialect) if dialect else csv.reader(io.StringIO(content))
for row in reader:
if not row:
continue
# tolerate varying column counts (4 or 5); ignore first and optional last
cols = [c.strip() for c in row]
if len(cols) < 4:
# Not enough data
continue
name = cols[1].strip().strip('"')
start_raw = cols[2]
end_raw = cols[3]
try:
start_date = parse_date(start_raw)
end_date = parse_date(end_raw)
except ValueError:
continue
upsert(name, start_date, end_date, None)
session.commit()
session.close()
return jsonify({"success": True, "inserted": inserted, "updated": updated})

View File

@@ -3,6 +3,7 @@ from server.routes.eventmedia import eventmedia_bp
from server.routes.files import files_bp
from server.routes.events import events_bp
from server.routes.holidays import holidays_bp
from server.routes.academic_periods import academic_periods_bp
from server.routes.groups import groups_bp
from server.routes.clients import clients_bp
from server.database import Session, engine
@@ -22,6 +23,7 @@ app.register_blueprint(events_bp)
app.register_blueprint(eventmedia_bp)
app.register_blueprint(files_bp)
app.register_blueprint(holidays_bp)
app.register_blueprint(academic_periods_bp)
@app.route("/health")