diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 59dfd01..7790719 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -228,6 +228,7 @@ Keep docs synced with code. When you change services/MQTT/API/UTC/env or dev/pro
Documentation maintenance: keep this file aligned with real patterns; update when routes/session/UTC rules change. Avoid long prose; link exact paths.
## Frontend patterns (dashboard)
+- **UI design rules**: Component choices, layout structure, button variants, badge colors, dialog patterns, toast conventions, and tab structure are defined in [`FRONTEND_DESIGN_RULES.md`](../FRONTEND_DESIGN_RULES.md). Follow that file for all dashboard work.
- 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.
@@ -235,7 +236,7 @@ Keep docs synced with code. When you change services/MQTT/API/UTC/env or dev/pro
- **UTC Time Parsing**: API returns ISO strings without 'Z' suffix. Frontend appends 'Z' before parsing to ensure UTC interpretation: `const utcString = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'; new Date(utcString);`. Display uses `toLocaleTimeString('de-DE')` for German format.
- Dev Container: When adding frontend deps, prefer `npm ci` and, if using named volumes, recreate dashboard `node_modules` volume so installs occur inside the container.
-- Theming: Syncfusion Material 3 theme is used. All component CSS is imported centrally in `dashboard/src/main.tsx` (base, navigations, buttons, inputs, dropdowns, popups, kanban, grids, schedule, filemanager, notifications, layouts, lists, calendars, splitbuttons, icons). Tailwind CSS has been removed.
+- Theming: All Syncfusion component CSS is imported centrally in `dashboard/src/main.tsx`. Theme conventions, component defaults, the full CSS import list, and Tailwind removal are documented in `FRONTEND_DESIGN_RULES.md`.
- 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)
diff --git a/ACADEMIC_PERIODS_CRUD_BUILD_PLAN.md b/ACADEMIC_PERIODS_CRUD_BUILD_PLAN.md
new file mode 100644
index 0000000..25fc0f3
--- /dev/null
+++ b/ACADEMIC_PERIODS_CRUD_BUILD_PLAN.md
@@ -0,0 +1,361 @@
+# Academic Periods CRUD Build Plan
+
+## Goal
+
+Add full academic period lifecycle management to the settings page and backend, including safe archive and hard-delete behavior, recurrence spillover blockers, and a UI restructuring where `Perioden` becomes the first sub-tab under `Akademischer Kalender`.
+
+## Frontend Design Rules
+
+All UI implementation for this build must follow the project-wide frontend design rules:
+
+→ **[FRONTEND_DESIGN_RULES.md](FRONTEND_DESIGN_RULES.md)**
+
+Key points relevant to this build:
+- Syncfusion Material3 components are the default for every UI element
+- Use `DialogComponent` for all confirmations — never `window.confirm()`
+- Follow the established card structure, button variants, badge colors, and tab patterns
+- German strings only in all user-facing text
+- No Tailwind classes
+
+## Agreed Rules
+
+### Permissions
+
+- Create: admin or higher
+- Edit: admin or higher
+- Archive: admin or higher
+- Restore: admin or higher
+- Hard delete: admin or higher
+- Activate: admin or higher
+- Editors do not activate periods by default because activation changes global system state
+
+### Lifecycle
+
+- Active: exactly one period at a time
+- Inactive: saved period, not currently active
+- Archived: retired period, hidden from normal operational selection
+- Deleted: physically removed only when delete preconditions are satisfied
+
+### Validation
+
+- `name` is required, trimmed, and unique among non-archived periods
+- `startDate` must be less than or equal to `endDate`
+- `periodType` must be one of `schuljahr`, `semester`, `trimester`
+- Overlaps are disallowed within the same `periodType`
+- Overlaps across different `periodType` values are allowed
+- Exactly one period may be active at a time
+
+### Archive Rules
+
+- Active periods cannot be archived
+- A period cannot be archived if it still has operational dependencies
+- Operational dependencies include recurring master events assigned to that period that still generate current or future occurrences
+
+### Restore Rules
+
+- Archived periods can be restored by admin or higher
+- Restored periods return as inactive by default
+
+### Hard Delete Rules
+
+- Only archived and inactive periods can be hard-deleted
+- Hard delete is blocked if linked events exist
+- Hard delete is blocked if linked media exist
+- Hard delete is blocked if recurring master events assigned to the period still have current or future scheduling relevance
+
+### Recurrence Spillover Rule
+
+- If a recurring master event belongs to an older period but still creates occurrences in the current or future timeframe, that older period is not eligible for archive or hard delete
+- Admin must resolve the recurrence by ending, splitting, or reassigning the series before the period can be retired or deleted
+
+## Build-Oriented Task Plan
+
+### Phase 1: Lock The Contract
+
+Files:
+
+- `server/routes/academic_periods.py`
+- `models/models.py`
+- `dashboard/src/settings.tsx`
+
+Work:
+
+- Freeze lifecycle rules, validation rules, and blocker rules
+- Freeze the settings tab order so `Perioden` comes before `Import & Liste`
+- Confirm response shape for new endpoints
+
+Deliverable:
+
+- Stable implementation contract for backend and frontend work
+
+### Phase 2: Extend The Data Model
+
+Files:
+
+- `models/models.py`
+
+Work:
+
+- Add archive lifecycle fields to academic periods
+- Recommended fields: `is_archived`, `archived_at`, `archived_by`
+
+Deliverable:
+
+- Academic periods can be retired safely and restored later
+
+### Phase 3: Add The Database Migration
+
+Files:
+
+- `server/alembic.ini`
+- `server/alembic/`
+- `server/initialize_database.py`
+
+Work:
+
+- Add Alembic migration for archive-related fields and any supporting indexes
+- Ensure existing periods default to non-archived
+
+Deliverable:
+
+- Schema upgrade path for current installations
+
+### Phase 4: Expand The Backend API
+
+Files:
+
+- `server/routes/academic_periods.py`
+
+Work:
+
+- Implement full lifecycle endpoints:
+ - `GET /api/academic_periods`
+ - `GET /api/academic_periods/:id`
+ - `POST /api/academic_periods`
+ - `PUT /api/academic_periods/:id`
+ - `POST /api/academic_periods/:id/activate`
+ - `POST /api/academic_periods/:id/archive`
+ - `POST /api/academic_periods/:id/restore`
+ - `GET /api/academic_periods/:id/usage`
+ - `DELETE /api/academic_periods/:id`
+
+Deliverable:
+
+- Academic periods become a fully managed backend resource
+
+### Phase 5: Add Backend Validation And Guardrails
+
+Files:
+
+- `server/routes/academic_periods.py`
+- `models/models.py`
+
+Work:
+
+- Enforce required fields, type checks, date checks, overlap checks, and one-active-period behavior
+- Block archive and delete when dependency rules fail
+
+Deliverable:
+
+- Backend owns all business-critical safeguards
+
+### Phase 6: Implement Recurrence Spillover Detection
+
+Files:
+
+- `server/routes/academic_periods.py`
+- `server/routes/events.py`
+- `models/models.py`
+
+Work:
+
+- Detect recurring master events assigned to a period that still generate present or future occurrences
+- Treat them as blockers for archive and hard delete
+
+Deliverable:
+
+- Old periods cannot be retired while they still affect the active schedule
+
+### Phase 7: Normalize API Serialization
+
+Files:
+
+- `server/routes/academic_periods.py`
+- `server/serializers.py`
+
+Work:
+
+- Return academic period responses in camelCase consistently with the rest of the API
+
+Deliverable:
+
+- Frontend receives normalized API payloads without special-case mapping
+
+### Phase 8: Expand The Frontend API Client
+
+Files:
+
+- `dashboard/src/apiAcademicPeriods.ts`
+
+Work:
+
+- Add frontend client methods for create, update, activate, archive, restore, usage lookup, and hard delete
+
+Deliverable:
+
+- The settings page can manage academic periods through one dedicated API module
+
+### Phase 9: Reorder The Akademischer Kalender Sub-Tabs
+
+Files:
+
+- `dashboard/src/settings.tsx`
+
+Work:
+
+- Move `Perioden` to the first sub-tab
+- Move `Import & Liste` to the second sub-tab
+- Preserve controlled tab state behavior
+
+Deliverable:
+
+- The settings flow reflects setup before import work
+
+### Phase 10: Replace The Current Period Selector With A Management UI
+
+Files:
+
+- `dashboard/src/settings.tsx`
+
+Work:
+
+- Replace the selector-only period card with a proper management surface
+- Show period metadata, active state, archived state, and available actions
+
+Deliverable:
+
+- The periods tab becomes a real administration UI
+
+### Phase 11: Add Create And Edit Flows
+
+Files:
+
+- `dashboard/src/settings.tsx`
+
+Work:
+
+- Add create and edit dialogs or form panels
+- Validate input before save and surface backend errors clearly
+
+Deliverable:
+
+- Admins can maintain periods directly in settings
+
+### Phase 12: Add Archive, Restore, And Hard Delete UX
+
+Files:
+
+- `dashboard/src/settings.tsx`
+
+Work:
+
+- Fetch usage or preflight data before destructive actions
+- Show exact blockers for linked events, linked media, and recurrence spillover
+- Use explicit confirmation dialogs for archive and hard delete
+
+Deliverable:
+
+- Destructive actions are safe and understandable
+
+### Phase 13: Add Archived Visibility Controls
+
+Files:
+
+- `dashboard/src/settings.tsx`
+
+Work:
+
+- Hide archived periods by default or group them behind a toggle
+
+Deliverable:
+
+- Normal operational periods stay easy to manage while retired periods remain accessible
+
+### Phase 14: Add Backend Tests
+
+Files:
+
+- Backend academic period test targets to be identified during implementation
+
+Work:
+
+- Cover create, edit, activate, archive, restore, hard delete, overlap rejection, dependency blockers, and recurrence spillover blockers
+
+Deliverable:
+
+- Lifecycle rules are regression-safe
+
+### Phase 15: Add Frontend Verification
+
+Files:
+
+- `dashboard/src/settings.tsx`
+- Frontend test targets to be identified during implementation
+
+Work:
+
+- Verify sub-tab order, CRUD refresh behavior, blocked action messaging, and activation behavior
+
+Deliverable:
+
+- Settings UX remains stable after the management upgrade
+
+### Phase 16: Update Documentation
+
+Files:
+
+- `.github/copilot-instructions.md`
+- `README.md`
+- `TECH-CHANGELOG.md`
+
+Work:
+
+- Document academic period lifecycle behavior, blocker rules, and updated settings tab order as appropriate
+
+Deliverable:
+
+- Repo guidance stays aligned with implemented behavior
+
+## Suggested Build Sequence
+
+1. Freeze rules and response shape
+2. Change the model
+3. Add the migration
+4. Build backend endpoints
+5. Add blocker logic and recurrence checks
+6. Expand the frontend API client
+7. Reorder sub-tabs
+8. Build period management UI
+9. Add destructive-action preflight UX
+10. Add tests
+11. Update documentation
+
+## Recommended Delivery Split
+
+1. Backend foundation
+ - Model
+ - Migration
+ - Routes
+ - Validation
+ - Blocker logic
+
+2. Frontend management
+ - API client
+ - Tab reorder
+ - Management UI
+ - Dialogs
+ - Usage messaging
+
+3. Verification and docs
+ - Tests
+ - Documentation
diff --git a/FRONTEND_DESIGN_RULES.md b/FRONTEND_DESIGN_RULES.md
new file mode 100644
index 0000000..ce4661c
--- /dev/null
+++ b/FRONTEND_DESIGN_RULES.md
@@ -0,0 +1,328 @@
+# Frontend Design Rules
+
+This file is the single source of truth for UI implementation conventions in the dashboard (`dashboard/src/`).
+It applies to all feature work, including new pages, settings tabs, dialogs, and management surfaces.
+
+When proposing or implementing frontend changes, follow these rules unless a specific exception is documented below.
+This file should be updated whenever a new Syncfusion component is adopted, a color or pattern changes, or an exception is ratified.
+
+---
+
+## 1. Component Library — Syncfusion First
+
+Use Syncfusion components as the default choice for every UI element.
+The project uses the Syncfusion Material3 theme, registered globally in `dashboard/src/main.tsx`.
+
+The following CSS packages are imported there and cover all components currently in use:
+`base`, `navigations`, `buttons`, `inputs`, `dropdowns`, `popups`, `kanban`, `grids`, `schedule`, `filemanager`, `notifications`, `layouts`, `lists`, `calendars`, `splitbuttons`, `icons`.
+When adding a new Syncfusion component, add its CSS import here — and add the new npm package to `optimizeDeps.include` in `vite.config.ts` to avoid Vite import-analysis errors in development.
+
+Use non-Syncfusion elements only when:
+- The Syncfusion equivalent does not exist (e.g., native `` for file upload)
+- The Syncfusion component would require significantly more code than a simple HTML element for purely read-only or structural content (e.g., `
/
` for plain lists)
+- A layout-only structure is needed (a wrapper `
` for spacing is fine)
+
+**Never** use `window.confirm()` for destructive action confirmations — use `DialogComponent` instead.
+`window.confirm()` exists in one place in `dashboard.tsx` (bulk restart) and is considered a deprecated pattern to avoid.
+
+Do not introduce Tailwind utility classes — Tailwind has been removed from the project.
+
+---
+
+## 2. Component Defaults by Purpose
+
+| Purpose | Component | Notes |
+|---|---|---|
+| Navigation tabs | `TabComponent` + `TabItemDirective` | `heightAdjustMode="Auto"`, controlled with `selectedItem` state |
+| Data list or table | `GridComponent` | `allowPaging`, `allowSorting`, custom `template` for status/actions |
+| Paginated list | `PagerComponent` | When a full grid is too heavy; default page size 5 or 10 |
+| Text input | `TextBoxComponent` | Use `cssClass="e-outline"` on form-heavy sections |
+| Numeric input | `NumericTextBoxComponent` | Always set `min`, `max`, `step`, `format` |
+| Single select | `DropDownListComponent` | Always set `fields={{ text, value }}` |
+| Boolean toggle | `CheckBoxComponent` | Use `label` prop, handle via `change` callback |
+| Buttons | `ButtonComponent` | See section 4 |
+| Modal dialogs | `DialogComponent` | `isModal={true}`, `showCloseIcon={true}`, footer with Cancel + primary |
+| Notifications | `ToastComponent` | Positioned `{ X: 'Right', Y: 'Top' }`, 3000ms timeout by default |
+| Inline info/error | `MessageComponent` | Use `severity` prop: `'Error'`, `'Warning'`, `'Info'`, `'Success'` |
+| Status/role badges | Plain `` with inline style | See section 6 for convention |
+| Timeline/schedule | `ScheduleComponent` | Used for resource timeline views; see `ressourcen.tsx` |
+| File management | `FileManagerComponent` | Used on the Media page for upload and organisation |
+| Drag-drop board | `KanbanComponent` | Used on the Groups page; retain for drag-drop boards |
+| User action menu | `DropDownButtonComponent` (`@syncfusion/ej2-react-splitbuttons`) | Used for header user menu; add to `optimizeDeps.include` in `vite.config.ts` |
+| File upload | Native `` | No Syncfusion equivalent for raw file input |
+
+---
+
+## 3. Layout and Card Structure
+
+Every settings tab section starts with a `
+```
+
+Multiple cards in the same tab section use `style={{ marginBottom: 20 }}` between them.
+
+For full-page views (not inside a settings tab), the top section follows this pattern:
+
+```jsx
+
+
+
Page title
+
Subtitle or description
+
+ New item
+
+```
+
+---
+
+## 4. Buttons
+
+| Variant | `cssClass` | When to use |
+|---|---|---|
+| Primary action (save, confirm) | `e-primary` | Main save or confirm in forms and dialogs |
+| Create / add new | `e-success` + `iconCss="e-icons e-plus"` | Top-level create action in page header |
+| Destructive (delete, archive) | `e-flat e-danger` | Row actions and destructive dialog confirm |
+| Secondary / cancel | `e-flat` | Cancel in dialog footer, low-priority options |
+| Info / edit | `e-flat e-primary` or `e-flat e-info` | Row-level edit and info actions |
+| Outline secondary | `e-outline` | Secondary actions needing a visible border (e.g., preview URL) |
+
+All async action buttons must be `disabled` during the in-flight operation: `disabled={isBusy}`.
+Button text must change to indicate the pending state: `Speichere…`, `Erstelle...`, `Archiviere…`, `Lösche...`.
+
+---
+
+## 5. Dialogs
+
+All create, edit, and destructive action dialogs use `DialogComponent`:
+- `isModal={true}`
+- `showCloseIcon={true}`
+- `width="500px"` for forms (wider if tabular data is shown inside)
+- `header` prop with specific context text (include item name where applicable)
+- `footerTemplate` always has at minimum: Cancel (`e-flat`) + primary action (`e-primary`)
+- Dialog body wrapped in `
`
+- All fields disabled when `formBusy` is true
+
+For destructive confirmations (archive, delete), the dialog body must clearly explain what will happen and whether it is reversible.
+
+For blocked actions, use `MessageComponent` with `severity="Warning"` or `severity="Error"` inside the dialog body to show exact blocker details (e.g., linked event count, recurrence spillover).
+
+---
+
+## 6. Status and Type Badges
+
+Plain `` badges with inline style — no external CSS classes needed:
+
+```jsx
+
+ Label
+
+```
+
+See section 12 for the fixed color palette.
+
+**Icon conventions**: Use inline SVG or icon font classes for small visual indicators next to text. Established precedents:
+- Skip-holidays events render a TentTree icon immediately to the left of the main event-type icon; **always black** (`color: 'black'` or no color override).
+- Recurring events rely on Syncfusion's native lower-right recurrence badge — do not add a custom recurrence icon.
+
+**Role badge color mapping** (established in `users.tsx`; apply consistently for any role display):
+
+| Role | Color |
+|---|---|
+| user | `#6c757d` (neutral gray) |
+| editor | `#0d6efd` (info blue) |
+| admin | `#28a745` (success green) |
+| superadmin | `#dc3545` (danger red) |
+
+---
+
+## 7. Toast Notifications
+
+Use a component-local `ToastComponent` with a `ref`:
+
+```jsx
+const toastRef = React.useRef(null);
+// ...
+
+```
+
+Default `timeOut: 3000`. Use `4000` for messages that need more reading time.
+
+CSS class conventions:
+- `e-toast-success` — successful operations
+- `e-toast-danger` — errors
+- `e-toast-warning` — non-critical issues or partial results
+- `e-toast-info` — neutral informational messages
+
+---
+
+## 8. Form Fields
+
+All form labels:
+
+```jsx
+
+```
+
+Help/hint text below a field:
+
+```jsx
+
+ Hint text here.
+
+```
+
+Empty state inside a card:
+
+```jsx
+
Keine Einträge vorhanden.
+```
+
+Vertical spacing between field groups: `marginBottom: 16`.
+
+---
+
+## 9. Tab Structure
+
+Top-level and nested tabs use controlled `selectedItem` state with separate index variables per tab level.
+This prevents sub-tab resets when parent state changes.
+
+```jsx
+const [academicTabIndex, setAcademicTabIndex] = React.useState(0);
+
+ setAcademicTabIndex(e.selectedIndex ?? 0)}
+>
+
+
+
+
+
+```
+
+Tab header text uses an emoji prefix followed by a German label, consistent with all existing tabs.
+Each nested tab level has its own separate index state variable.
+
+---
+
+## 10. Statistics Summary Cards
+
+Used above grids and lists to show aggregate counts:
+
+```jsx
+
+
+
Label
+
42
+
+
+```
+
+---
+
+## 11. Inline Warning Messages
+
+For important warnings inside forms or dialogs:
+
+```jsx
+
+ ⚠️ Warning message text here.
+
+```
+
+For structured in-page errors or access-denied states, use `MessageComponent`:
+
+```jsx
+
+```
+
+---
+
+## 12. Color Palette
+
+Only the following colors are used in status and UI elements across the dashboard.
+Do not introduce new colors for new components.
+
+| Use | Color |
+|---|---|
+| Success / active / online | `#28a745` |
+| Danger / delete / offline | `#dc3545` |
+| Warning / partial | `#f39c12` |
+| Info / edit blue | `#0d6efd` |
+| Neutral / archived / subtitle | `#6c757d` |
+| Help / secondary text | `#666` |
+| Inactive/muted | `#868e96` |
+| Warning background | `#fff3cd` |
+| Warning border | `#ffc107` |
+
+---
+
+## 13. Dedicated CSS Files
+
+Use inline styles for settings tab sections and simpler pages.
+Only create a dedicated `.css` file if the component requires complex layout, custom animations, or selector-based styles that are not feasible with inline styles.
+
+Existing precedents: `monitoring.css`, `ressourcen.css`.
+
+Do not use Tailwind — it has been removed from the project.
+
+---
+
+## 14. Loading States
+
+For full-page loading, use a simple centered placeholder:
+
+```jsx
+
+
Lade Daten...
+
+```
+
+Do not use spinners or animated components unless a Syncfusion component provides them natively (e.g., busy state on `ButtonComponent`).
+
+---
+
+## 15. Locale and Language
+
+All user-facing strings are in German.
+Date formatting uses `toLocaleString('de-DE')` or `toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })`.
+Never use English strings in labels, buttons, tooltips, or dialog headers visible to the end user.
+
+**UTC time parsing**: The API returns ISO timestamps **without** a `Z` suffix (e.g., `"2025-11-27T20:03:00"`). Always append `Z` before constructing a `Date` to ensure correct UTC interpretation:
+
+```tsx
+const utcStr = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z';
+const date = new Date(utcStr);
+```
+
+When sending dates back to the API, use `date.toISOString()` (already UTC with `Z`).
diff --git a/README.md b/README.md
index 147c347..f2b16c9 100644
--- a/README.md
+++ b/README.md
@@ -388,6 +388,8 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
## 🎨 Frontend Features
+> UI implementation conventions (component choices, layout, buttons, dialogs, badge colors, toast patterns, locale) are documented in [FRONTEND_DESIGN_RULES.md](FRONTEND_DESIGN_RULES.md).
+
### API Response Format
- **JSON Convention**: All API endpoints return camelCase JSON (e.g., `startTime`, `endTime`, `groupId`). Frontend consumes camelCase directly.
- **UTC Time Parsing**: API returns ISO strings without 'Z' suffix. Frontend appends 'Z' before parsing to ensure UTC interpretation: `const utcString = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'; new Date(utcString);`. Display uses `toLocaleTimeString('de-DE')` for German format.
diff --git a/dashboard/src/components/CustomEventModal.tsx b/dashboard/src/components/CustomEventModal.tsx
index 037cba3..7027a1c 100644
--- a/dashboard/src/components/CustomEventModal.tsx
+++ b/dashboard/src/components/CustomEventModal.tsx
@@ -97,11 +97,6 @@ const CustomEventModal: React.FC = ({
const [media, setMedia] = React.useState<{ id: string; path: string; name: string } | null>(
initialData.media ?? null
);
- const [pendingMedia, setPendingMedia] = React.useState<{
- id: string;
- path: string;
- name: string;
- } | null>(null);
// General settings state for presentation
// Removed unused generalLoaded and setGeneralLoaded
// Removed unused generalLoaded/generalSlideshowInterval/generalPageProgress/generalAutoProgress
@@ -124,6 +119,7 @@ const CustomEventModal: React.FC = ({
const [volume, setVolume] = React.useState(initialData.volume ?? 0.8);
const [muted, setMuted] = React.useState(initialData.muted ?? false);
const [videoDefaultsLoaded, setVideoDefaultsLoaded] = React.useState(false);
+ const [isSaving, setIsSaving] = React.useState(false);
const [mediaModalOpen, setMediaModalOpen] = React.useState(false);
@@ -203,14 +199,11 @@ const CustomEventModal: React.FC = ({
}
}, [open, initialData, editMode]);
- React.useEffect(() => {
- if (!mediaModalOpen && pendingMedia) {
- setMedia(pendingMedia);
- setPendingMedia(null);
- }
- }, [mediaModalOpen, pendingMedia]);
-
const handleSave = async () => {
+ if (isSaving) {
+ return;
+ }
+
const newErrors: { [key: string]: string } = {};
if (!title.trim()) newErrors.title = 'Titel ist erforderlich';
if (!startDate) newErrors.startDate = 'Startdatum ist erforderlich';
@@ -253,6 +246,14 @@ const CustomEventModal: React.FC = ({
if (type === 'video') {
if (!media) newErrors.media = 'Bitte ein Video auswählen';
}
+
+ const parsedMediaId = media?.id ? Number(media.id) : null;
+ if (
+ (type === 'presentation' || type === 'video') &&
+ (!Number.isFinite(parsedMediaId) || (parsedMediaId as number) <= 0)
+ ) {
+ newErrors.media = 'Ausgewähltes Medium ist ungültig. Bitte Datei erneut auswählen.';
+ }
// Holiday blocking logic removed (blockHolidays, isHolidayRange no longer used)
if (Object.keys(newErrors).length > 0) {
@@ -260,6 +261,8 @@ const CustomEventModal: React.FC = ({
return;
}
+ setIsSaving(true);
+
const group_id = typeof groupName === 'object' && groupName !== null ? groupName.id : groupName;
// Build recurrence rule if repeat is enabled
@@ -323,7 +326,7 @@ const CustomEventModal: React.FC = ({
};
if (type === 'presentation') {
- payload.event_media_id = media?.id ? Number(media.id) : undefined;
+ payload.event_media_id = parsedMediaId as number;
payload.slideshow_interval = slideshowInterval;
payload.page_progress = pageProgress;
payload.auto_progress = autoProgress;
@@ -334,7 +337,7 @@ const CustomEventModal: React.FC = ({
}
if (type === 'video') {
- payload.event_media_id = media?.id ? Number(media.id) : undefined;
+ payload.event_media_id = parsedMediaId as number;
payload.autoplay = autoplay;
payload.loop = loop;
payload.volume = volume;
@@ -378,12 +381,29 @@ const CustomEventModal: React.FC = ({
}
} else {
// CREATE
- res = await fetch('/api/events', {
+ const createResponse = await fetch('/api/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
- res = await res.json();
+
+ let createData: { success?: boolean; error?: string } = {};
+ try {
+ createData = await createResponse.json();
+ } catch {
+ createData = { error: `HTTP ${createResponse.status}` };
+ }
+
+ if (!createResponse.ok) {
+ setErrors({
+ api:
+ createData.error ||
+ `Fehler beim Speichern (HTTP ${createResponse.status})`,
+ });
+ return;
+ }
+
+ res = createData;
}
if (res.success) {
@@ -394,6 +414,8 @@ const CustomEventModal: React.FC = ({
}
} catch {
setErrors({ api: 'Netzwerkfehler beim Speichern' });
+ } finally {
+ setIsSaving(false);
}
};
@@ -454,14 +476,29 @@ const CustomEventModal: React.FC = ({
)}
>
+ {errors.api && (
+
+ {errors.api}
+
+ )}
{/* ...Titel, Beschreibung, Datum, Zeit... */}
@@ -640,6 +677,10 @@ const CustomEventModal: React.FC = ({
Kein Medium ausgewählt
)}