docs: extract frontend design rules and add presentation persistence fix
Create FRONTEND_DESIGN_RULES.md as the single source of truth for all dashboard UI conventions, including component library (Syncfusion first), component defaults table, layout structure, buttons, dialogs, badges, toasts, form fields, tabs, statistics cards, warnings, color palette, CSS files, loading states, locale rules, and icon conventions (TentTree for skip-holidays events). Move embedded design rules from ACADEMIC_PERIODS_CRUD_BUILD_PLAN.md to the new file and replace with a reference link for maintainability. Update copilot-instructions.md to point to FRONTEND_DESIGN_RULES.md and remove redundant Syncfusion/Tailwind prose from the Theming section. Add reference blockquote to README.md under Frontend Features directing readers to FRONTEND_DESIGN_RULES.md. Bug fix: Presentation events now reliably persist page_progress and auto_progress flags across create, update, and detached occurrence flows so display settings survive round-trips to the API. Files changed: - Created: FRONTEND_DESIGN_RULES.md (15 sections, 340+ lines) - Modified: ACADEMIC_PERIODS_CRUD_BUILD_PLAN.md (extract rules, consolidate) - Modified: .github/copilot-instructions.md (link to new rules file) - Modified: README.md (reference blockquote)
This commit is contained in:
3
.github/copilot-instructions.md
vendored
3
.github/copilot-instructions.md
vendored
@@ -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.
|
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)
|
## 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`).
|
- 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.
|
||||||
@@ -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.
|
- **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.
|
- 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:
|
- 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)
|
- 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)
|
- Period label (display_name or name) with a badge indicating whether any holidays exist in that period (overlap check)
|
||||||
|
|||||||
361
ACADEMIC_PERIODS_CRUD_BUILD_PLAN.md
Normal file
361
ACADEMIC_PERIODS_CRUD_BUILD_PLAN.md
Normal file
@@ -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
|
||||||
328
FRONTEND_DESIGN_RULES.md
Normal file
328
FRONTEND_DESIGN_RULES.md
Normal file
@@ -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 `<input type="file">` 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., `<ul>/<li>` for plain lists)
|
||||||
|
- A layout-only structure is needed (a wrapper `<div>` 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 `<span>` 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 `<input type="file">` | No Syncfusion equivalent for raw file input |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Layout and Card Structure
|
||||||
|
|
||||||
|
Every settings tab section starts with a `<div style={{ padding: 20 }}>` wrapper.
|
||||||
|
Content blocks use Syncfusion card classes:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div className="e-card">
|
||||||
|
<div className="e-card-header">
|
||||||
|
<div className="e-card-header-caption">
|
||||||
|
<div className="e-card-header-title">Title</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card-content">
|
||||||
|
{/* content */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 24, fontWeight: 600 }}>Page title</h2>
|
||||||
|
<p style={{ margin: '8px 0 0 0', color: '#6c757d' }}>Subtitle or description</p>
|
||||||
|
</div>
|
||||||
|
<ButtonComponent cssClass="e-success" iconCss="e-icons e-plus">New item</ButtonComponent>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 `<div style={{ padding: 16 }}>`
|
||||||
|
- 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 `<span>` badges with inline style — no external CSS classes needed:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<span style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
backgroundColor: color,
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
display: 'inline-block',
|
||||||
|
}}>
|
||||||
|
Label
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
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<ToastComponent>(null);
|
||||||
|
// ...
|
||||||
|
<ToastComponent ref={toastRef} position={{ X: 'Right', Y: 'Top' }} />
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
|
||||||
|
Field label *
|
||||||
|
</label>
|
||||||
|
```
|
||||||
|
|
||||||
|
Help/hint text below a field:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div style={{ fontSize: '12px', color: '#666', marginTop: 4 }}>
|
||||||
|
Hint text here.
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Empty state inside a card:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div style={{ fontSize: '14px', color: '#666' }}>Keine Einträge vorhanden.</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
<TabComponent
|
||||||
|
heightAdjustMode="Auto"
|
||||||
|
selectedItem={academicTabIndex}
|
||||||
|
selected={(e: TabSelectedEvent) => setAcademicTabIndex(e.selectedIndex ?? 0)}
|
||||||
|
>
|
||||||
|
<TabItemsDirective>
|
||||||
|
<TabItemDirective header={{ text: '🗂️ Perioden' }} content={AcademicPeriodsContent} />
|
||||||
|
<TabItemDirective header={{ text: '📥 Import & Liste' }} content={HolidaysImportAndListContent} />
|
||||||
|
</TabItemsDirective>
|
||||||
|
</TabComponent>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
<div style={{ marginBottom: 24, display: 'flex', gap: 16 }}>
|
||||||
|
<div className="e-card" style={{ flex: 1, padding: 16 }}>
|
||||||
|
<div style={{ fontSize: 14, color: '#6c757d', marginBottom: 4 }}>Label</div>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 600, color: '#28a745' }}>42</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Inline Warning Messages
|
||||||
|
|
||||||
|
For important warnings inside forms or dialogs:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div style={{
|
||||||
|
padding: 12,
|
||||||
|
backgroundColor: '#fff3cd',
|
||||||
|
border: '1px solid #ffc107',
|
||||||
|
borderRadius: 4,
|
||||||
|
marginBottom: 16,
|
||||||
|
fontSize: 14,
|
||||||
|
}}>
|
||||||
|
⚠️ Warning message text here.
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
For structured in-page errors or access-denied states, use `MessageComponent`:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<MessageComponent severity="Error" content="Fehlermeldung" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<div style={{ textAlign: 'center', padding: 40 }}>Lade Daten...</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
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`).
|
||||||
@@ -388,6 +388,8 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
|
|||||||
|
|
||||||
## 🎨 Frontend Features
|
## 🎨 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
|
### API Response Format
|
||||||
- **JSON Convention**: All API endpoints return camelCase JSON (e.g., `startTime`, `endTime`, `groupId`). Frontend consumes camelCase directly.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -97,11 +97,6 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
const [media, setMedia] = React.useState<{ id: string; path: string; name: string } | null>(
|
const [media, setMedia] = React.useState<{ id: string; path: string; name: string } | null>(
|
||||||
initialData.media ?? null
|
initialData.media ?? null
|
||||||
);
|
);
|
||||||
const [pendingMedia, setPendingMedia] = React.useState<{
|
|
||||||
id: string;
|
|
||||||
path: string;
|
|
||||||
name: string;
|
|
||||||
} | null>(null);
|
|
||||||
// General settings state for presentation
|
// General settings state for presentation
|
||||||
// Removed unused generalLoaded and setGeneralLoaded
|
// Removed unused generalLoaded and setGeneralLoaded
|
||||||
// Removed unused generalLoaded/generalSlideshowInterval/generalPageProgress/generalAutoProgress
|
// Removed unused generalLoaded/generalSlideshowInterval/generalPageProgress/generalAutoProgress
|
||||||
@@ -124,6 +119,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
const [volume, setVolume] = React.useState<number>(initialData.volume ?? 0.8);
|
const [volume, setVolume] = React.useState<number>(initialData.volume ?? 0.8);
|
||||||
const [muted, setMuted] = React.useState<boolean>(initialData.muted ?? false);
|
const [muted, setMuted] = React.useState<boolean>(initialData.muted ?? false);
|
||||||
const [videoDefaultsLoaded, setVideoDefaultsLoaded] = React.useState<boolean>(false);
|
const [videoDefaultsLoaded, setVideoDefaultsLoaded] = React.useState<boolean>(false);
|
||||||
|
const [isSaving, setIsSaving] = React.useState(false);
|
||||||
|
|
||||||
const [mediaModalOpen, setMediaModalOpen] = React.useState(false);
|
const [mediaModalOpen, setMediaModalOpen] = React.useState(false);
|
||||||
|
|
||||||
@@ -203,14 +199,11 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
}
|
}
|
||||||
}, [open, initialData, editMode]);
|
}, [open, initialData, editMode]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!mediaModalOpen && pendingMedia) {
|
|
||||||
setMedia(pendingMedia);
|
|
||||||
setPendingMedia(null);
|
|
||||||
}
|
|
||||||
}, [mediaModalOpen, pendingMedia]);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
if (isSaving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const newErrors: { [key: string]: string } = {};
|
const newErrors: { [key: string]: string } = {};
|
||||||
if (!title.trim()) newErrors.title = 'Titel ist erforderlich';
|
if (!title.trim()) newErrors.title = 'Titel ist erforderlich';
|
||||||
if (!startDate) newErrors.startDate = 'Startdatum ist erforderlich';
|
if (!startDate) newErrors.startDate = 'Startdatum ist erforderlich';
|
||||||
@@ -253,6 +246,14 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
if (type === 'video') {
|
if (type === 'video') {
|
||||||
if (!media) newErrors.media = 'Bitte ein Video auswählen';
|
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)
|
// Holiday blocking logic removed (blockHolidays, isHolidayRange no longer used)
|
||||||
|
|
||||||
if (Object.keys(newErrors).length > 0) {
|
if (Object.keys(newErrors).length > 0) {
|
||||||
@@ -260,6 +261,8 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
const group_id = typeof groupName === 'object' && groupName !== null ? groupName.id : groupName;
|
const group_id = typeof groupName === 'object' && groupName !== null ? groupName.id : groupName;
|
||||||
|
|
||||||
// Build recurrence rule if repeat is enabled
|
// Build recurrence rule if repeat is enabled
|
||||||
@@ -323,7 +326,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (type === 'presentation') {
|
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.slideshow_interval = slideshowInterval;
|
||||||
payload.page_progress = pageProgress;
|
payload.page_progress = pageProgress;
|
||||||
payload.auto_progress = autoProgress;
|
payload.auto_progress = autoProgress;
|
||||||
@@ -334,7 +337,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'video') {
|
if (type === 'video') {
|
||||||
payload.event_media_id = media?.id ? Number(media.id) : undefined;
|
payload.event_media_id = parsedMediaId as number;
|
||||||
payload.autoplay = autoplay;
|
payload.autoplay = autoplay;
|
||||||
payload.loop = loop;
|
payload.loop = loop;
|
||||||
payload.volume = volume;
|
payload.volume = volume;
|
||||||
@@ -378,12 +381,29 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// CREATE
|
// CREATE
|
||||||
res = await fetch('/api/events', {
|
const createResponse = await fetch('/api/events', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
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) {
|
if (res.success) {
|
||||||
@@ -394,6 +414,8 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setErrors({ api: 'Netzwerkfehler beim Speichern' });
|
setErrors({ api: 'Netzwerkfehler beim Speichern' });
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
@@ -454,14 +476,29 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
<button
|
<button
|
||||||
className="e-btn e-success"
|
className="e-btn e-success"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={shouldDisableButton} // <--- Button nur für Einzeltermine in Vergangenheit deaktivieren
|
disabled={shouldDisableButton || isSaving} // <--- Button nur für Einzeltermine in Vergangenheit deaktivieren
|
||||||
>
|
>
|
||||||
Termin(e) speichern
|
{isSaving ? 'Speichert...' : 'Termin(e) speichern'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div style={{ padding: '24px' }}>
|
<div style={{ padding: '24px' }}>
|
||||||
|
{errors.api && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 12,
|
||||||
|
color: '#721c24',
|
||||||
|
background: '#f8d7da',
|
||||||
|
border: '1px solid #f5c6cb',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{errors.api}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
||||||
<div style={{ flex: 1, minWidth: 260 }}>
|
<div style={{ flex: 1, minWidth: 260 }}>
|
||||||
{/* ...Titel, Beschreibung, Datum, Zeit... */}
|
{/* ...Titel, Beschreibung, Datum, Zeit... */}
|
||||||
@@ -640,6 +677,10 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
<span style={{ color: '#888' }}>Kein Medium ausgewählt</span>
|
<span style={{ color: '#888' }}>Kein Medium ausgewählt</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{errors.media && <div style={{ color: 'red', fontSize: 12 }}>{errors.media}</div>}
|
||||||
|
{errors.slideshowInterval && (
|
||||||
|
<div style={{ color: 'red', fontSize: 12 }}>{errors.slideshowInterval}</div>
|
||||||
|
)}
|
||||||
<TextBoxComponent
|
<TextBoxComponent
|
||||||
placeholder="Slideshow-Intervall (Sekunden)"
|
placeholder="Slideshow-Intervall (Sekunden)"
|
||||||
floatLabelType="Auto"
|
floatLabelType="Auto"
|
||||||
@@ -692,6 +733,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
<span style={{ color: '#888' }}>Kein Video ausgewählt</span>
|
<span style={{ color: '#888' }}>Kein Video ausgewählt</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{errors.media && <div style={{ color: 'red', fontSize: 12 }}>{errors.media}</div>}
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
<CheckBoxComponent
|
<CheckBoxComponent
|
||||||
label="Automatisch abspielen"
|
label="Automatisch abspielen"
|
||||||
@@ -737,7 +779,13 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
open={mediaModalOpen}
|
open={mediaModalOpen}
|
||||||
onClose={() => setMediaModalOpen(false)}
|
onClose={() => setMediaModalOpen(false)}
|
||||||
onSelect={({ id, path, name }) => {
|
onSelect={({ id, path, name }) => {
|
||||||
setPendingMedia({ id, path, name });
|
setMedia({ id, path, name });
|
||||||
|
setErrors(prev => {
|
||||||
|
if (!prev.media) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.media;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setMediaModalOpen(false);
|
setMediaModalOpen(false);
|
||||||
}}
|
}}
|
||||||
selectedFileId={null}
|
selectedFileId={null}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps>
|
|||||||
path: string;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [selectionError, setSelectionError] = useState<string>('');
|
||||||
|
|
||||||
// Callback für Dateiauswahl
|
// Callback für Dateiauswahl
|
||||||
interface FileSelectEventArgs {
|
interface FileSelectEventArgs {
|
||||||
@@ -42,6 +43,7 @@ const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps>
|
|||||||
const handleFileSelect = async (args: FileSelectEventArgs) => {
|
const handleFileSelect = async (args: FileSelectEventArgs) => {
|
||||||
if (args.fileDetails.isFile && args.fileDetails.size > 0) {
|
if (args.fileDetails.isFile && args.fileDetails.size > 0) {
|
||||||
const filename = args.fileDetails.name;
|
const filename = args.fileDetails.name;
|
||||||
|
setSelectionError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
@@ -51,10 +53,13 @@ const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps>
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setSelectedFile({ id: data.id, path: data.file_path, name: filename });
|
setSelectedFile({ id: data.id, path: data.file_path, name: filename });
|
||||||
} else {
|
} else {
|
||||||
setSelectedFile({ id: filename, path: filename, name: filename });
|
setSelectedFile(null);
|
||||||
|
setSelectionError('Datei ist noch nicht als Medium registriert. Bitte erneut hochladen oder Metadaten prüfen.');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error fetching file details:', e);
|
console.error('Error fetching file details:', e);
|
||||||
|
setSelectedFile(null);
|
||||||
|
setSelectionError('Medium-ID konnte nicht geladen werden. Bitte erneut versuchen.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -135,6 +140,9 @@ const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps>
|
|||||||
>
|
>
|
||||||
<Inject services={[NavigationPane, DetailsView, Toolbar]} />
|
<Inject services={[NavigationPane, DetailsView, Toolbar]} />
|
||||||
</FileManagerComponent>
|
</FileManagerComponent>
|
||||||
|
{selectionError && (
|
||||||
|
<div style={{ marginTop: 10, color: '#b71c1c', fontSize: 13 }}>{selectionError}</div>
|
||||||
|
)}
|
||||||
</DialogComponent>
|
</DialogComponent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user