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:
22
.github/copilot-instructions.md
vendored
22
.github/copilot-instructions.md
vendored
@@ -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.
|
- 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`)
|
## Data model highlights (see `models/models.py`)
|
||||||
- Enums: `EventType` (presentation, website, video, message, webuntis) and `MediaType` (file/website types).
|
- Enums: `EventType` (presentation, website, video, message, webuntis), `MediaType` (file/website types), and `AcademicPeriodType` (schuljahr, semester, trimester).
|
||||||
- Tables: `clients`, `client_groups`, `events`, `event_media`, `users`.
|
- 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).
|
- Times are stored as timezone-aware; treat comparisons in UTC (see scheduler and routes/events).
|
||||||
|
|
||||||
## API patterns
|
## 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`.
|
- 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.
|
- 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/`.
|
- 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)
|
## Frontend patterns (dashboard)
|
||||||
- Vite React app; proxies `/api` and `/screenshots` to API in dev (`vite.config.ts`).
|
- 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.
|
- 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.
|
- 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 today’s 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
|
## Local development
|
||||||
- Compose: development is `docker-compose.yml` + `docker-compose.override.yml`.
|
- 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.
|
- 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`.
|
- 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.
|
- 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
|
## Production
|
||||||
- `docker-compose.prod.yml` uses prebuilt images (`ghcr.io/robbstarkaustria/*`).
|
- `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
|
3) Manage `Session()` lifecycle, and
|
||||||
4) Return JSON-safe values (serialize enums and datetimes).
|
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.
|
- 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
|
## Quick examples
|
||||||
- Add client description persists to DB and publishes group via MQTT: see `PUT /api/clients/<uuid>/description` in `routes/clients.py`.
|
- 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`.
|
- 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`.
|
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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"appName": "Infoscreen-Management",
|
"appName": "Infoscreen-Management",
|
||||||
"version": "2025.1.0-alpha.6",
|
"version": "2025.1.0-alpha.7",
|
||||||
"copyright": "© 2025 Third-Age-Applications",
|
"copyright": "© 2025 Third-Age-Applications",
|
||||||
"supportContact": "support@third-age-applications.com",
|
"supportContact": "support@third-age-applications.com",
|
||||||
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
|
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
|
||||||
@@ -30,6 +30,18 @@
|
|||||||
"commitId": "8d1df7199cb7"
|
"commitId": "8d1df7199cb7"
|
||||||
},
|
},
|
||||||
"changelog": [
|
"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 2–4)",
|
||||||
|
"🔧 UX: Schalter in einer Reihe; Dropdown-Breiten optimiert"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "2025.1.0-alpha.6",
|
"version": "2025.1.0-alpha.6",
|
||||||
"date": "2025-09-20",
|
"date": "2025-09-20",
|
||||||
|
|||||||
42
dashboard/src/apiAcademicPeriods.ts
Normal file
42
dashboard/src/apiAcademicPeriods.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ScheduleComponent,
|
ScheduleComponent,
|
||||||
Day,
|
Day,
|
||||||
@@ -23,7 +23,21 @@ import { getGroupColor } from './groupColors';
|
|||||||
import { deleteEvent } from './apiEvents';
|
import { deleteEvent } from './apiEvents';
|
||||||
import CustomEventModal from './components/CustomEventModal';
|
import CustomEventModal from './components/CustomEventModal';
|
||||||
import { fetchMediaById } from './apiClients';
|
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 { renderToStaticMarkup } from 'react-dom/server';
|
||||||
import caGregorian from './cldr/ca-gregorian.json';
|
import caGregorian from './cldr/ca-gregorian.json';
|
||||||
import numbers from './cldr/numbers.json';
|
import numbers from './cldr/numbers.json';
|
||||||
@@ -43,6 +57,8 @@ type Event = {
|
|||||||
StartTime: Date;
|
StartTime: Date;
|
||||||
EndTime: Date;
|
EndTime: Date;
|
||||||
IsAllDay: boolean;
|
IsAllDay: boolean;
|
||||||
|
IsBlock?: boolean; // Syncfusion block appointment
|
||||||
|
isHoliday?: boolean; // marker for styling/logic
|
||||||
MediaId?: string | number;
|
MediaId?: string | number;
|
||||||
SlideshowInterval?: number;
|
SlideshowInterval?: number;
|
||||||
WebsiteUrl?: string;
|
WebsiteUrl?: string;
|
||||||
@@ -123,15 +139,15 @@ const eventTemplate = (event: Event) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
|
<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 && (
|
{IconComponent && (
|
||||||
<span style={{ verticalAlign: 'middle', display: 'inline-block', marginRight: 6 }}>
|
<span style={{ verticalAlign: 'middle', display: 'inline-block', marginRight: 6 }}>
|
||||||
<IconComponent size={18} color="#fff" />
|
<IconComponent size={18} color="#000" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span style={{ marginTop: 3 }}>{event.Subject}</span>
|
<span style={{ marginTop: 3 }}>{event.Subject}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.95em', color: '#fff', marginTop: -2 }}>{timeString}</div>
|
<div style={{ fontSize: '0.95em', color: '#000', marginTop: -2 }}>{timeString}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -140,11 +156,20 @@ const Appointments: React.FC = () => {
|
|||||||
const [groups, setGroups] = useState<Group[]>([]);
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [holidays, setHolidays] = useState<Holiday[]>([]);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [modalInitialData, setModalInitialData] = useState({});
|
const [modalInitialData, setModalInitialData] = useState({});
|
||||||
const [schedulerKey, setSchedulerKey] = useState(0);
|
const [schedulerKey, setSchedulerKey] = useState(0);
|
||||||
const [editMode, setEditMode] = useState(false); // NEU: Editiermodus
|
const [editMode, setEditMode] = useState(false); // NEU: Editiermodus
|
||||||
const [showInactive, setShowInactive] = React.useState(true);
|
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
|
// Gruppen laden
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -158,6 +183,24 @@ const Appointments: React.FC = () => {
|
|||||||
.catch(console.error);
|
.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:
|
// fetchAndSetEvents als useCallback definieren, damit die Dependency korrekt ist:
|
||||||
const fetchAndSetEvents = React.useCallback(async () => {
|
const fetchAndSetEvents = React.useCallback(async () => {
|
||||||
if (!selectedGroupId) {
|
if (!selectedGroupId) {
|
||||||
@@ -178,7 +221,7 @@ const Appointments: React.FC = () => {
|
|||||||
}));
|
}));
|
||||||
setEvents(mapped);
|
setEvents(mapped);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error('Fehler beim Laden der Termine:', err);
|
||||||
}
|
}
|
||||||
}, [selectedGroupId, showInactive]);
|
}, [selectedGroupId, showInactive]);
|
||||||
|
|
||||||
@@ -191,11 +234,168 @@ const Appointments: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [selectedGroupId, showInactive, fetchAndSetEvents]);
|
}, [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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold mb-4">Terminmanagement</h1>
|
<h1 className="text-2xl font-bold mb-4">Terminmanagement</h1>
|
||||||
<div
|
<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' }}>
|
<label htmlFor="groupDropdown" className="mb-0 mr-2" style={{ whiteSpace: 'nowrap' }}>
|
||||||
Raumgruppe auswählen:
|
Raumgruppe auswählen:
|
||||||
@@ -206,13 +406,67 @@ const Appointments: React.FC = () => {
|
|||||||
fields={{ text: 'name', value: 'id' }}
|
fields={{ text: 'name', value: 'id' }}
|
||||||
placeholder="Gruppe auswählen"
|
placeholder="Gruppe auswählen"
|
||||||
value={selectedGroupId}
|
value={selectedGroupId}
|
||||||
|
width="240px"
|
||||||
change={(e: { value: string }) => {
|
change={(e: { value: string }) => {
|
||||||
// <--- Typ für e ergänzt
|
// <--- Typ für e ergänzt
|
||||||
setEvents([]); // Events sofort leeren
|
setEvents([]); // Events sofort leeren
|
||||||
setSelectedGroupId(e.value);
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="e-btn e-success mb-4"
|
className="e-btn e-success mb-4"
|
||||||
@@ -239,7 +493,15 @@ const Appointments: React.FC = () => {
|
|||||||
>
|
>
|
||||||
Neuen Termin anlegen
|
Neuen Termin anlegen
|
||||||
</button>
|
</button>
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 24,
|
||||||
|
marginBottom: 16,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -249,6 +511,41 @@ const Appointments: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
Vergangene Termine anzeigen
|
Vergangene Termine anzeigen
|
||||||
</label>
|
</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>
|
</div>
|
||||||
<CustomEventModal
|
<CustomEventModal
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
@@ -277,17 +574,26 @@ const Appointments: React.FC = () => {
|
|||||||
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
|
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
|
||||||
groupColor={selectedGroupId ? getGroupColor(selectedGroupId, groups) : undefined}
|
groupColor={selectedGroupId ? getGroupColor(selectedGroupId, groups) : undefined}
|
||||||
editMode={editMode} // NEU: Prop für Editiermodus
|
editMode={editMode} // NEU: Prop für Editiermodus
|
||||||
|
blockHolidays={!allowScheduleOnHolidays}
|
||||||
|
isHolidayRange={(s, e) => isWithinHolidayRange(s, e)}
|
||||||
/>
|
/>
|
||||||
<ScheduleComponent
|
<ScheduleComponent
|
||||||
|
ref={scheduleRef}
|
||||||
key={schedulerKey} // <-- dynamischer Key
|
key={schedulerKey} // <-- dynamischer Key
|
||||||
height="750px"
|
height="750px"
|
||||||
locale="de"
|
locale="de"
|
||||||
currentView="Week"
|
currentView="Week"
|
||||||
eventSettings={{
|
eventSettings={{
|
||||||
dataSource: events,
|
dataSource: dataSource,
|
||||||
|
fields: { isBlock: 'IsBlock' },
|
||||||
template: eventTemplate, // <--- Hier das Template setzen!
|
template: eventTemplate, // <--- Hier das Template setzen!
|
||||||
}}
|
}}
|
||||||
|
actionComplete={() => updateHolidaysInView()}
|
||||||
cellClick={args => {
|
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.startTime und args.endTime sind Date-Objekte
|
||||||
args.cancel = true; // Verhindert die Standardaktion
|
args.cancel = true; // Verhindert die Standardaktion
|
||||||
setModalInitialData({
|
setModalInitialData({
|
||||||
@@ -353,6 +659,20 @@ const Appointments: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
eventRendered={(args: EventRenderedArgs) => {
|
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) {
|
if (selectedGroupId && args.data && args.data.Id) {
|
||||||
const groupColor = getGroupColor(selectedGroupId, groups);
|
const groupColor = getGroupColor(selectedGroupId, groups);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -389,13 +709,26 @@ const Appointments: React.FC = () => {
|
|||||||
subjectText;
|
subjectText;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vergangene Termine: Raumgruppenfarbe mit Transparenz
|
// Vergangene Termine: Raumgruppenfarbe
|
||||||
if (args.data.EndTime && args.data.EndTime < now) {
|
if (args.data.EndTime && args.data.EndTime < now) {
|
||||||
args.element.style.backgroundColor = groupColor ? `${groupColor}` : '#f3f3f3';
|
args.element.style.backgroundColor = groupColor ? `${groupColor}` : '#f3f3f3';
|
||||||
args.element.style.color = '';
|
args.element.style.color = '#000';
|
||||||
} else if (groupColor) {
|
} else if (groupColor) {
|
||||||
args.element.style.backgroundColor = 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
|
// Syncfusion soll das Event nicht selbst löschen
|
||||||
args.cancel = true;
|
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}
|
firstDayOfWeek={1}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ type CustomEventModalProps = {
|
|||||||
groupName: string | { id: string | null; name: string };
|
groupName: string | { id: string | null; name: string };
|
||||||
groupColor?: string;
|
groupColor?: string;
|
||||||
editMode?: boolean;
|
editMode?: boolean;
|
||||||
|
blockHolidays?: boolean;
|
||||||
|
isHolidayRange?: (start: Date, end: Date) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const weekdayOptions = [
|
const weekdayOptions = [
|
||||||
@@ -60,6 +62,8 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
groupName,
|
groupName,
|
||||||
groupColor,
|
groupColor,
|
||||||
editMode,
|
editMode,
|
||||||
|
blockHolidays,
|
||||||
|
isHolidayRange,
|
||||||
}) => {
|
}) => {
|
||||||
const [title, setTitle] = React.useState(initialData.title || '');
|
const [title, setTitle] = React.useState(initialData.title || '');
|
||||||
const [startDate, setStartDate] = React.useState(initialData.startDate || null);
|
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';
|
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) {
|
if (Object.keys(newErrors).length > 0) {
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -44,17 +44,21 @@ const Einstellungen: React.FC = () => {
|
|||||||
<section className="p-4 border rounded-md">
|
<section className="p-4 border rounded-md">
|
||||||
<h3 className="font-semibold mb-2">Schulferien importieren</h3>
|
<h3 className="font-semibold mb-2">Schulferien importieren</h3>
|
||||||
<p className="text-sm text-gray-600 mb-2">
|
<p className="text-sm text-gray-600 mb-2">
|
||||||
Laden Sie eine CSV-Datei mit den Spalten: <code>name</code>, <code>start_date</code>,{' '}
|
Unterstützte Formate:
|
||||||
<code>end_date</code>, optional <code>region</code>.
|
<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>
|
</p>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".csv,text/csv"
|
accept=".csv,text/csv,.txt,text/plain"
|
||||||
onChange={e => setFile(e.target.files?.[0] ?? null)}
|
onChange={e => setFile(e.target.files?.[0] ?? null)}
|
||||||
/>
|
/>
|
||||||
<button className="e-btn e-primary" onClick={onUpload} disabled={!file || busy}>
|
<button className="e-btn e-primary" onClick={onUpload} disabled={!file || busy}>
|
||||||
{busy ? 'Importiere…' : 'CSV importieren'}
|
{busy ? 'Importiere…' : 'CSV/TXT importieren'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{message && <div className="mt-2 text-sm">{message}</div>}
|
{message && <div className="mt-2 text-sm">{message}</div>}
|
||||||
|
|||||||
84
server/routes/academic_periods.py
Normal file
84
server/routes/academic_periods.py
Normal 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()
|
||||||
@@ -24,9 +24,14 @@ def list_holidays():
|
|||||||
@holidays_bp.route("/upload", methods=["POST"])
|
@holidays_bp.route("/upload", methods=["POST"])
|
||||||
def upload_holidays():
|
def upload_holidays():
|
||||||
"""
|
"""
|
||||||
Accepts a CSV file upload (multipart/form-data) with columns like:
|
Accepts a CSV/TXT file upload (multipart/form-data).
|
||||||
name,start_date,end_date,region
|
|
||||||
Dates can be in ISO (YYYY-MM-DD) or common European format (DD.MM.YYYY).
|
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:
|
if "file" not in request.files:
|
||||||
return jsonify({"error": "No file part"}), 400
|
return jsonify({"error": "No file part"}), 400
|
||||||
@@ -35,26 +40,36 @@ def upload_holidays():
|
|||||||
return jsonify({"error": "No selected file"}), 400
|
return jsonify({"error": "No selected file"}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
content = file.read().decode("utf-8", errors="ignore")
|
raw = file.read()
|
||||||
# Try to auto-detect delimiter; default ','
|
# 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()
|
sniffer = csv.Sniffer()
|
||||||
dialect = None
|
dialect = None
|
||||||
try:
|
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:
|
except Exception:
|
||||||
pass
|
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):
|
def parse_date(s: str):
|
||||||
s = (s or "").strip()
|
s = (s or "").strip()
|
||||||
if not s:
|
if not s:
|
||||||
return None
|
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"):
|
for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%Y/%m/%d"):
|
||||||
try:
|
try:
|
||||||
return datetime.strptime(s, fmt).date()
|
return datetime.strptime(s, fmt).date()
|
||||||
@@ -65,16 +80,18 @@ def upload_holidays():
|
|||||||
session = Session()
|
session = Session()
|
||||||
inserted = 0
|
inserted = 0
|
||||||
updated = 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 = (
|
existing = (
|
||||||
session.query(SchoolHoliday)
|
session.query(SchoolHoliday)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -86,9 +103,7 @@ def upload_holidays():
|
|||||||
)
|
)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
# Optionally update region or source_file_name
|
|
||||||
existing.region = region
|
existing.region = region
|
||||||
existing.source_file_name = file.filename
|
existing.source_file_name = file.filename
|
||||||
updated += 1
|
updated += 1
|
||||||
@@ -102,6 +117,41 @@ def upload_holidays():
|
|||||||
))
|
))
|
||||||
inserted += 1
|
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.commit()
|
||||||
session.close()
|
session.close()
|
||||||
return jsonify({"success": True, "inserted": inserted, "updated": updated})
|
return jsonify({"success": True, "inserted": inserted, "updated": updated})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from server.routes.eventmedia import eventmedia_bp
|
|||||||
from server.routes.files import files_bp
|
from server.routes.files import files_bp
|
||||||
from server.routes.events import events_bp
|
from server.routes.events import events_bp
|
||||||
from server.routes.holidays import holidays_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.groups import groups_bp
|
||||||
from server.routes.clients import clients_bp
|
from server.routes.clients import clients_bp
|
||||||
from server.database import Session, engine
|
from server.database import Session, engine
|
||||||
@@ -22,6 +23,7 @@ app.register_blueprint(events_bp)
|
|||||||
app.register_blueprint(eventmedia_bp)
|
app.register_blueprint(eventmedia_bp)
|
||||||
app.register_blueprint(files_bp)
|
app.register_blueprint(files_bp)
|
||||||
app.register_blueprint(holidays_bp)
|
app.register_blueprint(holidays_bp)
|
||||||
|
app.register_blueprint(academic_periods_bp)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/health")
|
@app.route("/health")
|
||||||
|
|||||||
Reference in New Issue
Block a user