60 Commits

Author SHA1 Message Date
RobbStarkAustria
7746e26385 feat: add Ressourcen timeline view with group ordering (alpha.14)
- New timeline page showing all groups and active events in parallel
- Group order API endpoints with persistence (GET/POST /api/groups/order)
- Customizable group ordering with visual controls
- Fix CSS and TypeScript lint errors
- Update documentation and bump version to 2026.1.0-alpha.14
2026-01-28 18:59:11 +00:00
RobbStarkAustria
10f446dfb5 feat: Add organization name and scheduler refresh interval settings
- Superadmin-only organization name setting displayed in dashboard header
- Advanced Options tab with configurable scheduler refresh interval (0 = disabled)
- Make system settings GET endpoint public for frontend reads
- Scheduler reads refresh_seconds from DB dynamically each loop
- Seed default system settings in init_defaults.py
2026-01-10 08:33:18 +00:00
RobbStarkAustria
5a0c1bc686 feat: document user management system and RBAC implementation
- Update copilot-instructions.md with user model, API routes, and frontend patterns
- Update README.md with RBAC details, user management API, and security sections
- Add user management technical documentation to TECH-CHANGELOG.md
- Bump version to 2025.1.0-alpha.13 with user management changelog entries
2025-12-29 12:37:54 +00:00
RobbStarkAustria
c193209326 feat: dashboard screenshot upload & retention (last 20 per client)
- Listener: subscribe to dashboard topic, forward screenshots to API
- API: store latest + last 20 timestamped screenshots per client, auto-delete older files
- Docs: updated README, TECH-CHANGELOG, and copilot-instructions for screenshot upload and retention policy
2025-11-30 13:38:07 +00:00
RobbStarkAustria
df9f29bc6a docs/dev: sync backend rework, MQTT, and devcontainer hygiene
README: add Versioning (unified SemVer, pre-releases, build metadata); emphasize UTC handling and streaming endpoint; add Dev Container notes (UI-only Remote Containers, npm ci, idempotent aliases)
TECH-CHANGELOG: backend rework notes (serialization camelCase, UTC normalization, streaming metadata); add component build metadata template (image tags/SHAs)
Copilot instructions: integrate maintenance guardrails; reinforce UTC and camelCase conventions; document MQTT topics and scheduler retained payload behavior
Devcontainer: map Remote Containers to UI; remove in-container install; switch to npm ci; make aliases idempotent
2025-11-29 15:35:13 +00:00
RobbStarkAustria
6dcf93f0dd feat(dashboard+api): card-based dashboard, camelCase API, UTC fixes
Dashboard: new Syncfusion card layout, global stats, filters, health bars, active event display, client details, bulk restart, 15s auto-refresh, manual refresh toasts
API: standardized responses to camelCase; added serializers.py and updated events endpoints
Time: ensured UTC storage; frontend appends 'Z' for parsing and displays local time
Docs: updated copilot-instructions.md, README.md, TECH-CHANGELOG.md
Program Info: bumped to 2025.1.0-alpha.12 with user-facing changelog
BREAKING: external API consumers must migrate field names from PascalCase to camelCase.
2025-11-27 20:30:00 +00:00
RobbStarkAustria
452ba3033b feat(video, settings, docs): add muted playback, nested Settings tabs, merge holidays tab; bump 2025.1.0-alpha.11
API/DB: add Event.muted with full CRUD wiring (Alembic migration), persist/return with autoplay/loop/volume
Dashboard: per‑event video options (autoplay/loop/volume/muted) with system defaults; Settings → Events → Videos defaults
Settings UX: nested tabs with controlled selection; Academic Calendar: merge “Schulferien Import”+“Liste” into “📥 Import & Liste”
Docs: update README and copilot-instructions (video payload, streaming 206, defaults keys); update program-info.json changelog; bump version to 2025.1.0‑alpha.11
2025-11-05 19:30:10 +00:00
RobbStarkAustria
38800cec68 feat(video): add streamable video events & dashboard controls
Add end-to-end support for video events: server streaming, scheduler
metadata, API fields, and dashboard UI.

- Server: range-capable streaming endpoint with byte-range support.
- Scheduler: emits `video` object; best-effort HEAD probe adds
  `mime_type`, `size`, `accept_ranges`; placeholders for richer
  metadata (duration/resolution/bitrate/qualities/thumbnails).
- API/DB: accept and persist `event_media_id`, `autoplay`, `loop`,
  `volume` for video events.
- Frontend: Event modal supports video selection + playback options;
  FileManager increased upload size and client-side duration check
  (max 10 minutes).
- Docs/UX: bumped program-info, added UX-only changelog and updated
  Copilot instructions for contributors.
- Notes: metadata extraction (ffprobe), checksum persistence, and
  HLS/DASH transcoding are recommended follow-ups (separate changes).
2025-10-25 16:48:14 +00:00
RobbStarkAustria
e6c19c189f feat(events): add webuntis event, unify website payload, bump UI to alpha.13
- Add `webuntis` event type; event creation resolves URL from system `supplement_table_url`
- Consolidate settings: remove separate webuntis-url endpoints; use GET/POST /api/system-settings/supplement-table
- Scheduler: emit top-level `event_type` and unified `website` payload (`{ "type":"browser","url":"..." }`) for website/webuntis
- Preserve presentation payloads (page_progress/auto_progress) — presentation messages remain backwards-compatible
- Update defaults (`init_defaults.py`) and remove duplicate webuntis setting
- Docs & metadata: bump program-info to 2025.1.0-alpha.13; update README, copilot-instructions, DEV- and TECH-CHANGELOGs; add MQTT_EVENT_PAYLOAD_GUIDE.md and WEBUNTIS_EVENT_IMPLEMENTATION.md
2025-10-19 11:35:41 +00:00
RobbStarkAustria
c9cc535fc6 feat: presentation defaults + scheduler active-only
Add Settings → Events (Presentations) defaults (interval, page-progress,
auto-progress) persisted via /api/system-settings
Seed defaults in init_defaults.py (10/true/true)
Add Event.page_progress and Event.auto_progress (Alembic applied)
CustomEventModal applies defaults on create and saves fields
Scheduler publishes only currently active events per group, clears retained
topics when none, normalizes times to UTC; include flags in payloads
Docs: update README, copilot instructions, and DEV-CHANGELOG
If you can split the commit, even better

feat(dashboard): add presentation defaults UI
feat(api): seed presentation defaults in init_defaults.py
feat(model): add Event.page_progress and Event.auto_progress
feat(scheduler): publish only active events; clear retained topics; UTC
docs: update README and copilot-instructions
chore: update DEV-CHANGELOG
2025-10-18 15:34:52 +00:00
RobbStarkAustria
3487d33a2f feat: improve scheduler recurrence, DB config, and docs
- Broaden scheduler query window to next N days for proper recurring event expansion (scheduler.py)
- Update DB connection logic for consistent .env loading and fallback (database.py)
- Harden timezone handling and logging in scheduler and DB utils
- Stop auto-deactivating recurring events before recurrence_end (API/events)
- Update documentation to reflect new scheduler, API, and logging behavior
2025-10-18 06:18:06 +00:00
RobbStarkAustria
150937f2e2 docs(settings): Update README + Copilot instructions; bump Program Info to 2025.1.0-alpha.11
README: Add System Settings API endpoints; describe new tabbed Settings layout with role gating; add Vite dev proxy tip to use relative /api paths.
Copilot instructions: Note SystemSetting key–value store in data model; document system_settings.py (CRUD + supplement-table convenience endpoint); reference apiSystemSettings.ts; note defaults seeding via init_defaults.py.
Program Info: Bump version to 2025.1.0-alpha.11; changelog explicitly tied to the Settings page (Events tab: supplement-table URL moved; Academic Calendar: set active period; proxy note); README docs mention.
No functional changes to API or UI code in this commit; documentation and program info only.
2025-10-16 19:15:55 +00:00
RobbStarkAustria
7b38b49598 rename benutzer to users
add role management to media page
2025-10-16 17:57:06 +00:00
RobbStarkAustria
a7df3c2708 feat(dashboard): header user dropdown (Syncfusion) + proper logout; docs: clarify architecture; build: add splitbuttons; bump alpha.10
Dashboard

Add top-right user dropdown using Syncfusion DropDownButton: shows username + role; menu entries “Profil” and “Abmelden”.
Replace custom dropdown logic with Syncfusion component; position at header’s right edge.
Update /logout page to call backend logout and redirect to /login (reliable user switching).
Build/Config

Add @syncfusion/ej2-react-splitbuttons and @syncfusion/ej2-splitbuttons dependencies.
Update Vite optimizeDeps.include to pre-bundle splitbuttons and avoid import-analysis errors.
Docs

README: Rework Architecture Overview with clearer data flow:
Listener consumes MQTT (discovery/heartbeats) and updates API.
Scheduler reads from API and publishes events via MQTT to clients.
Clients send via MQTT and receive via MQTT.
Worker receives commands directly from API and reports results back (no MQTT).
Explicit note: MariaDB is accessed exclusively by the API Server; Dashboard never talks to DB directly.
README: Add SplitButtons to “Syncfusion Components Used”; add troubleshooting steps for @syncfusion/ej2-react-splitbuttons import issues (optimizeDeps + volume reset).
Copilot instructions: Document header user menu and splitbuttons technical notes (deps, optimizeDeps, dev-container node_modules volume).
Program info

Bump to 2025.1.0-alpha.10 with changelog:
UI: Header user menu (DropDownButton with username/role; Profil/Abmelden).
Frontend: Syncfusion SplitButtons integration + Vite pre-bundling config.
Fix: Added README guidance for splitbuttons import errors.
No breaking changes.
2025-10-15 16:33:35 +00:00
RobbStarkAustria
8676370fe2 docs: clarify event deletion flows and dialog handling for all event types
- Documented unified deletion process for single, single-in-series, and recurring series events
- Explained custom dialog interception of Syncfusion RecurrenceAlert and DeleteAlert
- Updated both README.md and .github/copilot-instructions.md to match current frontend logic
2025-10-14 19:10:38 +00:00
RobbStarkAustria
5f0972c79c Merge branch 'recurring_events_scheduler' 2025-10-14 05:55:12 +00:00
RobbStarkAustria
17c3452310 Scheduler: native recurrence handling, onActionCompleted persistence, EXDATE RFC5545, UI icon cleanup 2025-10-14 05:54:36 +00:00
RobbStarkAustria
e53cc619ec feat: implement comprehensive recurring event single occurrence editing
- Add detach functionality for individual occurrences of recurring events
- Create POST /api/events/<id>/occurrences/<date>/detach endpoint
- Implement EventException-based EXDATE generation for master events
- Add user confirmation dialog for single vs series editing choice
- Implement manual recurrence expansion with DST timezone tolerance
- Support FREQ=DAILY and FREQ=WEEKLY with BYDAY patterns and UNTIL dates
- Create standalone events from detached occurrences without affecting master series
- Add GET /api/events/<id> endpoint for fetching master event data
- Allow editing recurring series even when master event date is in the past
- Replace browser confirm dialogs with Syncfusion dialog components
- Remove debug logging while preserving error handling
- Update documentation for recurring event functionality

BREAKING: Frontend now manually expands recurring events instead of relying on Syncfusion's EXDATE handling

This enables users to edit individual occurrences of recurring events (creating standalone events)
or edit the entire series (updating all future occurrences) through an intuitive UI workflow.
The system properly handles timezone transitions, holiday exclusions, and complex recurrence patterns.
2025-10-12 20:04:23 +00:00
RobbStarkAustria
773628c324 feat(events): reliable holiday skipping for recurrences + UI badge; clean logs
Backend: generate EventException on create/update when skip_holidays or recurrence changes; emit RecurrenceException (EXDATE) with exact occurrence start time (UTC)
API: return master events with RecurrenceRule + RecurrenceException
Frontend: map RecurrenceException → recurrenceException; ensure SkipHolidays instances never render on holidays; place TentTree icon (black) next to main event icon via template
Docs: update README and Copilot instructions for recurrence/holiday behavior
Cleanup: remove dataSource and debug console logs
2025-10-12 12:00:43 +00:00
RobbStarkAustria
7ab4ea14c4 Polish up clients ui 2025-10-12 05:23:10 +00:00
RobbStarkAustria
4d807be6f8 UI: switch to Syncfusion M3, remove Tailwind;
paginate changelog; docs updated; bump to 2025.1.0-alpha.8
2025-10-11 12:10:12 +00:00
RobbStarkAustria
0601bac243 docs: update README and Copilot instructions for new DB init workflow
Replace placeholder clone URL with actual repo URL:
https://github.com/RobbStarkAustria/infoscreen_2025.git
Add first-run step to initialize the database using the one-shot script:
python server/initialize_database.py
Mention initialize_database.py in project structure
Add a one-shot DB init command to the “Database Management” section
Update Copilot instructions:
Prefer initialize_database.py for local dev (migrations + defaults + academic periods)
Note legacy init scripts were removed; use Alembic + initialize_database.py going forward
No runtime/code changes; documentation only
2025-10-11 07:01:19 +00:00
RobbStarkAustria
4a97ad4f1d Merge with remote repository and apply database cleanup
- Merged remote history with local workspace changes
- Removed obsolete database initialization scripts (init_database.py, init_db.py, init_mariadb.py, test_sql.py)
- Added new comprehensive database initialization script (initialize_database.py)
- Added documentation (DATABASE_GUIDE.md, CLEANUP_SUMMARY.md)
- Updated init_defaults.py comment
- Added GitHub connection helper script
2025-10-10 15:28:04 +00:00
RobbStarkAustria
1efe40a03b Initial commit - copied workspace after database cleanup 2025-10-10 15:20:14 +00:00
5627829617 Migration to physical server 2025-10-10 14:14:47 +00:00
fc9b3228c4 Add README.md 2025-10-07 20:50:24 +00:00
fcc0dfbb0f feat(conversions): end-to-end PPT/PPTX/ODP -> PDF pipeline with RQ worker + Gotenberg
DB/model

Add Conversion model + ConversionStatus enum (pending, processing, ready, failed)
Alembic migrations: create conversions table, indexes, unique (source_event_media_id, target_format, file_hash), and NOT NULL on file_hash
API

Enqueue on upload (ppt|pptx|odp) in routes/eventmedia.py: compute sha256, upsert Conversion, enqueue job
New routes:
POST /api/conversions/<media_id>/pdf — ensure/enqueue conversion
GET /api/conversions/<media_id>/status — latest status/details
GET /api/files/converted/<path> — serve converted PDFs
Register conversions blueprint in wsgi
Worker

server/worker.py: convert_event_media_to_pdf
Calls Gotenberg /forms/libreoffice/convert, writes to server/media/converted/
Updates Conversion status, timestamps, error messages
Fix media root resolution to /server/media
Prefer function enqueue over string path; expose server.worker in package init for RQ string compatibility
Queue/infra

server/task_queue.py: RQ queue helper (REDIS_URL, default redis://redis:6379/0)
docker-compose:
Add redis and gotenberg services
Add worker service (rq worker conversions)
Pass REDIS_URL and GOTENBERG_URL to server/worker
Mount shared media volume in prod for API/worker parity
docker-compose.override:
Add dev redis/gotenberg/worker services
Ensure PYTHONPATH + working_dir allow importing server.worker
Use rq CLI instead of python -m rq for worker
Dashboard dev: run as appropriate user/root and pre-create/chown caches to avoid EACCES
Dashboard dev UX

Vite: set cacheDir .vite to avoid EACCES in node_modules
Disable Node inspector by default to avoid port conflicts
Docs

Update copilot-instructions.md with conversion system: flow, services, env vars, endpoints, storage paths, and data model
2025-10-07 19:06:09 +00:00
80bf8bc58d Add deploymnent guides for Debian and Ubuntu 2025-10-06 19:26:13 +00:00
eaf6e32446 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
2025-09-21 14:35:38 +00:00
41194000a4 feat: Add academic periods system for educational institutions
- Add AcademicPeriod model with support for schuljahr/semester/trimester
- Extend Event and EventMedia models with optional academic_period_id
- Create Alembic migration (8d1df7199cb7) for academic periods system
- Add init script for Austrian school year defaults (2024/25-2026/27)
- Maintain full backward compatibility for existing events/media
- Update program-info.json to version 2025.1.0-alpha.6

Database changes:
- New academic_periods table with unique name constraint
- Foreign key relationships with proper indexing
- Support for multiple period types with single active period

This lays the foundation for period-based organization of events
and media content, specifically designed for school environments
with future extensibility for universities.
2025-09-20 11:16:56 +00:00
89d1748100 add nginx.dev.conf for development environment
add functionality of scheduler to send right event
data to the clients
added route for file download
2025-09-17 06:36:37 +00:00
c19f478f11 add copilot instructions for better use of AI-models 2025-09-14 06:36:37 +00:00
e8d71b8349 Complete Redesign of Backend Handling for Client Group Assignments 2025-09-14 05:20:49 +00:00
c5a8571e97 Adaption of dashboard to Vite and React 2025-09-13 10:03:50 +00:00
1d23b7591d Switched von pnpm to npm, adapt Dockerfiles 2025-09-12 16:16:16 +00:00
f3b72da9fe Migrate from tailwind-sidebar to syncfusion sidebar component 2025-09-12 16:09:19 +00:00
75c5622efe optimize filemanager 2025-09-12 08:46:40 +00:00
4c44b98d53 make listener robust to bad data 2025-09-05 08:47:31 +00:00
76629b8e30 additions and corrections for
deployment instructions
2025-09-04 16:45:29 +00:00
86b1bdbd91 continued try for deployment 2025-09-03 20:27:59 +00:00
e30723da0a Preparation for first deployment-test 2025-09-03 19:47:16 +00:00
4e74f72c9f multiple corrections on docker-compose and Dockerfile
robust start sequence
avoid scrolling of main content
2025-08-31 07:30:53 +00:00
2ca5f0060e add programminfo.tsx and program-info.json for
information and display of program details
add simple logout-page
2025-08-30 16:00:59 +00:00
270bad5980 introduce icons in events 2025-07-24 14:11:27 +00:00
49e9f9eade change color of work cells in the past to light
yellow
2025-07-24 10:12:25 +00:00
8bbda836b3 prevent saving past events
add function to show inactive events
2025-07-24 09:35:57 +00:00
4e6451ce80 group color shown in CustomEventModal
add functionality for edit of events
2025-07-24 07:43:32 +00:00
b0e933e895 UI polishing 2025-07-23 15:04:16 +00:00
7f4800496a implement functionality to delete clients in
clients and SetupMode components
2025-07-22 16:04:26 +00:00
c0202e5802 remove simclient and update setup mode 2025-07-20 09:41:36 +00:00
c9fbb38347 test entrypoint for git 2025-07-19 09:27:03 +00:00
2e9f22f5cc test communication scheduler<->simclient 2025-07-18 14:49:53 +00:00
a1d6d83488 Solve lint errors 2025-07-17 07:04:01 +00:00
4e525e4bae Automatic detect of clients 2025-07-17 06:31:50 +00:00
1a6faaa104 separation of production and development
environments
Adding new migrations for renaming and adding fields
to the database schema
persistent uuid for simclient
2025-07-16 08:50:42 +00:00
84a92ab9c2 functional system simclient<-> listener<->server 2025-07-15 15:37:16 +00:00
f37744b31e models.py moved to models/models.py
refactor all imports
2025-07-15 10:45:56 +00:00
661d25d70c Scheduler: Refactor database utilities and
scheduler logic
2025-07-15 05:05:09 +00:00
2fa84c1e2b Setup: Make Docker Compose, Scheduler, and Simclient fully operational 2025-07-14 18:41:28 +00:00
7c1f546af9 Remove legacy Dash frontend after React migration 2025-07-14 17:19:14 +00:00
202 changed files with 26321 additions and 8958 deletions

45
.env.example Normal file
View File

@@ -0,0 +1,45 @@
# Copy this file to .env and fill in values as needed for local development.
# NOTE: No secrets should be committed. Use placeholders below.
# General
ENV=development
# Flask
# IMPORTANT: Generate a secure random key for production
# e.g., python -c 'import secrets; print(secrets.token_hex(32))'
FLASK_SECRET_KEY=dev-secret-key-change-in-production
# Database (used if DB_CONN not provided)
DB_USER=your_user
DB_PASSWORD=your_password
DB_NAME=infoscreen_by_taa
DB_HOST=db
# Preferred connection string for services (overrides the above if set)
# DB_CONN=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}/${DB_NAME}
# MQTT
MQTT_BROKER_HOST=mqtt
MQTT_BROKER_PORT=1883
# MQTT_USER=your_mqtt_user
# MQTT_PASSWORD=your_mqtt_password
MQTT_KEEPALIVE=60
# Dashboard
# Used when building the production dashboard image
# VITE_API_URL=https://your.api.example.com/api
# Groups alive windows (seconds)
# Clients send heartbeats every ~65s. Allow 2 missed heartbeats + safety margin
# Dev: 65s * 2 + 50s margin = 180s
# Prod: 65s * 2 + 40s margin = 170s
HEARTBEAT_GRACE_PERIOD_DEV=180
HEARTBEAT_GRACE_PERIOD_PROD=170
# Scheduler
# Optional: force periodic republish even without changes
# REFRESH_SECONDS=0
# Default superadmin bootstrap (server/init_defaults.py)
# REQUIRED: Must be set for superadmin creation
DEFAULT_SUPERADMIN_USERNAME=superadmin
DEFAULT_SUPERADMIN_PASSWORD=your_secure_password_here

395
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,395 @@
# Copilot instructions for infoscreen_2025
# Purpose
These instructions tell Copilot Chat how to reason about this codebase.
Prefer explanations and refactors that align with these structures.
Use this as your shared context when proposing changes. Keep edits minimal and match existing patterns referenced below.
## TL;DR
Small multi-service digital signage app (Flask API, React dashboard, MQTT scheduler). Edit `server/` for API logic, `scheduler/` for event publishing, and `dashboard/` for UI. If you're asking Copilot for changes, prefer focused prompts that include the target file(s) and the desired behavior.
### How to ask Copilot
- "Add a new route `GET /api/events/summary` that returns counts per event_type — implement in `server/routes/events.py`."
- "Create an Alembic migration to add `duration` and `resolution` to `event_media` and update upload handler to populate them."
- "Refactor `scheduler/db_utils.py` to prefer precomputed EventMedia metadata and fall back to a HEAD probe."
- "Add an ffprobe-based worker that extracts duration/resolution/bitrate and stores them on `EventMedia`."
Keep docs synced with code. When you change services/MQTT/API/UTC/env or dev/prod run steps, update this file in the same commit (see `AI-INSTRUCTIONS-MAINTENANCE.md`).
### When not to change
- Avoid editing generated assets under `dashboard/dist/` and compiled bundles. Don't modify files produced by CI or Docker builds (unless intentionally updating build outputs).
### Contact / owner
- Primary maintainer: RobbStarkAustria (owner). For architecture questions, ping the repo owner or open an issue and tag `@RobbStarkAustria`.
### Important files (quick jump targets)
- `scheduler/db_utils.py` — event formatting and scheduler-facing logic
- `scheduler/scheduler.py` — scheduler main loop and MQTT publisher
- `server/routes/eventmedia.py` — file uploads, streaming endpoint
- `server/routes/events.py` — event CRUD and recurrence handling
- `server/routes/groups.py` — group management, alive status, display order persistence
- `dashboard/src/components/CustomEventModal.tsx` — event creation UI
- `dashboard/src/media.tsx` — FileManager / upload settings
- `dashboard/src/settings.tsx` — settings UI (nested tabs; system defaults for presentations and videos)
- `dashboard/src/ressourcen.tsx` — timeline view showing all groups' active events in parallel
- `dashboard/src/ressourcen.css` — timeline and resource view styling
## Big picture
- Multi-service app orchestrated by Docker Compose.
- API: Flask + SQLAlchemy (MariaDB), in `server/` exposed on :8000 (health: `/health`).
- Dashboard: React + Vite in `dashboard/`, dev on :5173, served via Nginx in prod.
- MQTT broker: Eclipse Mosquitto, config in `mosquitto/config/mosquitto.conf`.
- Listener: MQTT consumer handling discovery, heartbeats, and dashboard screenshot uploads in `listener/listener.py`.
- Scheduler: Publishes only currently active events (per group, at "now") to MQTT retained topics in `scheduler/scheduler.py`. It queries a future window (default: 7 days) to expand recurring events using RFC 5545 rules and applies event exceptions, but only publishes events that are active at the current time. When a group has no active events, the scheduler clears its retained topic by publishing an empty list. All time comparisons are UTC; any naive timestamps are normalized. Logging is concise; conversion lookups are cached and logged only once per media.
- Nginx: Reverse proxy routes `/api/*` and `/screenshots/*` to API; everything else to dashboard (`nginx.conf`).
- Dev Container (hygiene): UI-only `Dev Containers` extension runs on host UI via `remote.extensionKind`; do not install it in-container. Dashboard installs use `npm ci`; shell aliases in `postStartCommand` are appended idempotently.
### Screenshot retention
- Screenshots sent via dashboard MQTT are stored in `server/screenshots/`.
- For each client, only the latest and last 20 timestamped screenshots are kept; older files are deleted automatically on each upload.
## Recent changes since last commit
### Latest (January 2026)
- **Ressourcen Page (Timeline View)**:
- New 'Ressourcen' page with parallel timeline view showing active events for all room groups
- Compact timeline display with adjustable row height (65px per group)
- Real-time view of currently running events with type, title, and time window
- Customizable group ordering with visual reordering panel (drag up/down buttons)
- Group order persisted via `GET/POST /api/groups/order` endpoints
- Color-coded event bars matching group theme
- Timeline modes: Day and Week views (day view by default)
- Dynamic height calculation based on number of groups
- Syncfusion ScheduleComponent with TimelineViews, Resize, and DragAndDrop support
- Files: `dashboard/src/ressourcen.tsx` (page), `dashboard/src/ressourcen.css` (styles)
### Earlier (November 2025)
- **API Naming Convention Standardization (camelCase)**:
- Backend: Created `server/serializers.py` with `dict_to_camel_case()` utility for consistent JSON serialization
- Events API: `GET /api/events` and `GET /api/events/<id>` now return camelCase fields (`id`, `subject`, `startTime`, `endTime`, `type`, `groupId`, etc.) instead of PascalCase
- Frontend: Dashboard and appointments page updated to consume camelCase API responses
- Appointments page maintains internal PascalCase for Syncfusion scheduler compatibility with automatic mapping from API responses
- **Breaking**: External API consumers must update field names from PascalCase to camelCase
- **UTC Time Handling**:
- Database stores all timestamps in UTC (naive timestamps normalized by backend)
- API returns ISO strings without 'Z' suffix: `"2025-11-27T20:03:00"`
- Frontend: Dashboard and appointments automatically append 'Z' to parse as UTC and display in user's local timezone
- Time formatting functions use `toLocaleTimeString('de-DE')` for German locale display
- All time comparisons use UTC; `new Date().toISOString()` sends UTC back to API
- API returns ISO strings without `Z`; frontend must append `Z` before parsing to ensure UTC
- **Dashboard Enhancements**:
- New card-based design for Raumgruppen (room groups) with Syncfusion components
- Global statistics summary: total infoscreens, online/offline counts, warning groups
- Filter buttons: All, Online, Offline, Warnings with dynamic counts
- Active event display per group: shows currently playing content with type icon, title, date, and time
- Health visualization with color-coded progress bars per group
- Expandable client details with last alive timestamps
- Bulk restart functionality for offline clients per group
- Manual refresh button with toast notifications
- 15-second auto-refresh interval
### Earlier changes
- Scheduler: when formatting video events the scheduler now performs a best-effort HEAD probe of the streaming URL and includes basic metadata in the emitted payload (mime_type, size, accept_ranges). Placeholders for richer metadata (duration, resolution, bitrate, qualities, thumbnails, checksum) are included for later population by a background worker.
- Streaming endpoint: a range-capable streaming endpoint was added at `/api/eventmedia/stream/<media_id>/<filename>` that supports byte-range requests (206 Partial Content) to enable seeking from clients.
- Event model & API: `Event` gained video-related fields (`event_media_id`, `autoplay`, `loop`, `volume`) and the API accepts and persists these when creating/updating video events.
- Dashboard: UI updated to allow selecting uploaded videos for events and to specify autoplay/loop/volume. File upload settings were increased (maxFileSize raised) and the client now validates video duration (max 10 minutes) before upload.
- FileManager: uploads compute basic metadata and enqueue conversions for office formats as before; video uploads now surface size and are streamable via the new endpoint.
- Event model & API (new): Added `muted` (Boolean) for video events; create/update and GET endpoints accept, persist, and return `muted` alongside `autoplay`, `loop`, and `volume`.
- Dashboard — Settings: Settings page refactored to nested tabs; added Events → Videos defaults (autoplay, loop, volume, mute) backed by system settings keys (`video_autoplay`, `video_loop`, `video_volume`, `video_muted`).
- Dashboard — Events UI: CustomEventModal now exposes per-event video `muted` and initializes all video fields from system defaults when creating a new event.
- Dashboard — Academic Calendar: Merged “School Holidays Import” and “List” into a single “📥 Import & Liste” tab; nested tab selection is persisted with controlled `selectedItem` state to avoid jumps.
Note: these edits are intentionally backwards-compatible — if the probe fails, the scheduler still emits the stream URL and the client should fallback to a direct play attempt or request richer metadata when available.
Backend rework notes (no version bump):
- Dev container hygiene: UI-only Remote Containers; reproducible dashboard installs (`npm ci`); idempotent shell aliases.
- Serialization consistency: snake_case internal → camelCase external via `server/serializers.py` for all JSON.
- UTC normalization across routes/scheduler; enums and datetimes serialize consistently.
## Service boundaries & data flow
- Database connection string is passed as `DB_CONN` (mysql+pymysql) to Python services.
- API builds its engine in `server/database.py` (loads `.env` only in development).
- Scheduler loads `DB_CONN` in `scheduler/db_utils.py`. Recurring events are expanded for the next 7 days, and event exceptions (skipped dates, detached occurrences) are respected. Only recurring events with recurrence_end in the future remain active. The scheduler publishes only events that are active at the current time and clears retained topics (publishes `[]`) for groups without active events. Time comparisons are UTC and naive timestamps are normalized.
- Listener also creates its own engine for writes to `clients`.
- Scheduler queries a future window (default: 7 days) to expand recurring events using RFC 5545 rules, applies event exceptions (skipped dates, detached occurrences), and publishes only events that are active at the current time (UTC). When a group has no active events, the scheduler clears its retained topic by publishing an empty list. Time comparisons are UTC; naive timestamps are normalized. Logging is concise; conversion lookups are cached and logged only once per media.
- MQTT topics (paho-mqtt v2, use Callback API v2):
- Discovery: `infoscreen/discovery` (JSON includes `uuid`, hw/ip data). ACK to `infoscreen/{uuid}/discovery_ack`. See `listener/listener.py`.
- Heartbeat: `infoscreen/{uuid}/heartbeat` updates `Client.last_alive` (UTC).
- Event lists (retained): `infoscreen/events/{group_id}` from `scheduler/scheduler.py`.
- Per-client group assignment (retained): `infoscreen/{uuid}/group_id` via `server/mqtt_helper.py`.
- Screenshots: server-side folders `server/received_screenshots/` and `server/screenshots/`; Nginx exposes `/screenshots/{uuid}.jpg` via `server/wsgi.py` route.
- Dev Container guidance: If extensions reappear inside the container, remove UI-only extensions from `devcontainer.json` `extensions` and map them in `remote.extensionKind` as `"ui"`.
- Presentation conversion (PPT/PPTX/ODP → PDF):
- Trigger: on upload in `server/routes/eventmedia.py` for media types `ppt|pptx|odp` (compute sha256, upsert `Conversion`, enqueue job).
- Worker: RQ worker runs `server.worker.convert_event_media_to_pdf`, calls Gotenberg LibreOffice endpoint, writes to `server/media/converted/`.
- Services: Redis (queue) and Gotenberg added in compose; worker service consumes the `conversions` queue.
- Env: `REDIS_URL` (default `redis://redis:6379/0`), `GOTENBERG_URL` (default `http://gotenberg:3000`).
- Endpoints: `POST /api/conversions/<media_id>/pdf` (ensure/enqueue), `GET /api/conversions/<media_id>/status`, `GET /api/files/converted/<path>` (serve PDFs).
- Storage: originals under `server/media/…`, outputs under `server/media/converted/` (prod compose mounts a shared volume for this path).
## Data model highlights (see `models/models.py`)
- User model: Includes 7 new audit/security fields (migration: `4f0b8a3e5c20_add_user_audit_fields.py`):
- `last_login_at`, `last_password_change_at`: TIMESTAMP (UTC) tracking for auth events
- `failed_login_attempts`, `last_failed_login_at`: Security monitoring for brute-force detection
- `locked_until`: TIMESTAMP placeholder for account lockout (infrastructure in place, not yet enforced)
- `deactivated_at`, `deactivated_by`: Soft-delete audit trail (FK self-reference); soft deactivation is the default, hard delete superadmin-only
- Role hierarchy (privilege escalation enforced): `user` < `editor` < `admin` < `superadmin`
- System settings: `system_settings` keyvalue store via `SystemSetting` for global configuration (e.g., WebUntis/Vertretungsplan supplement-table). Managed through routes in `server/routes/system_settings.py`.
- Presentation defaults (system-wide):
- `presentation_interval` (seconds, default "10")
- `presentation_page_progress` ("true"/"false", default "true")
- `presentation_auto_progress` ("true"/"false", default "true")
Seeded in `server/init_defaults.py` if missing.
- Video defaults (system-wide):
- `video_autoplay` ("true"/"false", default "true")
- `video_loop` ("true"/"false", default "true")
- `video_volume` (0.01.0, default "0.8")
- `video_muted` ("true"/"false", default "false")
Used as initial values when creating new video events; editable per event.
- Events: Added `page_progress` (Boolean) and `auto_progress` (Boolean) for presentation behavior per event.
- Event (video fields): `event_media_id`, `autoplay`, `loop`, `volume`, `muted`.
- WebUntis URL: WebUntis uses the existing Vertretungsplan/Supplement-Table URL (`supplement_table_url`). There is no separate `webuntis_url` setting; use `GET/POST /api/system-settings/supplement-table`.
- Conversions:
- Enum `ConversionStatus`: `pending`, `processing`, `ready`, `failed`.
- Table `conversions`: `id`, `source_event_media_id` (FK→`event_media.id` ondelete CASCADE), `target_format`, `target_path`, `status`, `file_hash` (sha256), `started_at`, `completed_at`, `error_message`.
- Indexes: `(source_event_media_id, target_format)`, `(status, target_format)`; Unique: `(source_event_media_id, target_format, file_hash)`.
## API patterns
- Blueprints live in `server/routes/*` and are registered in `server/wsgi.py` with `/api/...` prefixes.
- Session usage: instantiate `Session()` per request, commit when mutating, and always `session.close()` before returning.
- Examples:
- Clients: `server/routes/clients.py` includes bulk group updates and MQTT sync (`publish_multiple_client_groups`).
- Groups: `server/routes/groups.py` computes “alive” using a grace period that varies by `ENV`. - `GET /api/groups/order` — retrieve saved group display order
- `POST /api/groups/order` — persist group display order (array of group IDs) - Events: `server/routes/events.py` serializes enum values to strings and normalizes times to UTC. Recurring events are only deactivated after their recurrence_end (UNTIL); non-recurring events deactivate after their end time. Event exceptions are respected and rendered in scheduler output.
- Media: `server/routes/eventmedia.py` implements a simple file manager API rooted at `server/media/`.
- System settings: `server/routes/system_settings.py` exposes keyvalue CRUD (`/api/system-settings`) and a convenience endpoint for WebUntis/Vertretungsplan supplement-table: `GET/POST /api/system-settings/supplement-table` (admin+).
- 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
- User management: `server/routes/users.py` exposes comprehensive CRUD for users (admin+):
- `GET /api/users` — list all users (role-filtered: admin sees user/editor/admin, superadmin sees all); includes audit fields in camelCase (lastLoginAt, lastPasswordChangeAt, failedLoginAttempts, deactivatedAt, deactivatedBy)
- `POST /api/users` — create user with username, password (min 6 chars), role, and status; admin cannot create superadmin; initializes audit fields
- `GET /api/users/<id>` — get detailed user record with all audit fields
- `PUT /api/users/<id>` — update user (cannot change own role/status; admin cannot modify superadmin accounts)
- `PUT /api/users/<id>/password` — admin password reset (requires backend check to reject self-reset for consistency)
- `DELETE /api/users/<id>` — hard delete (superadmin only, with self-deletion check)
- Auth routes (`server/routes/auth.py`): Enhanced to track login events (sets `last_login_at`, resets `failed_login_attempts` on success; increments `failed_login_attempts` and `last_failed_login_at` on failure). Self-service password change via `PUT /api/auth/change-password` requires current password verification.
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)
- 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.
- **API Response Format**: 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.
- 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.
- Scheduler (appointments page): top bar includes Group and Academic Period selectors (Syncfusion DropDownList). Selecting a period calls `POST /api/academic_periods/active`, moves the calendar to todays month/day within the period year, and refreshes a right-aligned indicator row showing:
- Holidays present in the current view (count)
- Period label (display_name or name) with a badge indicating whether any holidays exist in that period (overlap check)
- Recurrence & holidays (latest):
- Backend stores holiday skips in `EventException` and emits `RecurrenceException` (EXDATE) for master events in `GET /api/events`. EXDATE tokens are formatted in RFC 5545 compact form (`yyyyMMddTHHmmssZ`) and correspond to each occurrence start time (UTC). Syncfusion uses these to exclude holiday instances reliably.
- Frontend lets Syncfusion handle all recurrence patterns natively (no client-side expansion). Scheduler field mappings include `recurrenceID`, `recurrenceRule`, and `recurrenceException` so series and edited occurrences are recognized correctly.
- Event deletion: All event types (single, single-in-series, entire series) are handled with custom dialogs. The frontend intercepts Syncfusion's built-in RecurrenceAlert and DeleteAlert popups to provide a unified, user-friendly deletion flow:
- Single (non-recurring) event: deleted directly after confirmation.
- Single occurrence of a recurring series: user can delete just that instance.
- Entire recurring series: user can delete all occurrences after a final custom confirmation dialog.
- Detached occurrences (edited/broken out): treated as single events.
- Single occurrence editing: Users can detach individual occurrences from recurring series. The frontend hooks `actionComplete`/`onActionCompleted` with `requestType='eventChanged'` to persist changes: it calls `POST /api/events/<id>/occurrences/<date>/detach` for single-occurrence edits and `PUT /api/events/<id>` for series or single events as appropriate. The backend creates `EventException` and a standalone `Event` without modifying the master beyond EXDATEs.
- UI: Events with `SkipHolidays` render a TentTree icon next to the main event icon. The custom recurrence icon in the header was removed; rely on Syncfusions native lower-right recurrence badge.
- Website & WebUntis: Both event types display a website. WebUntis reads its URL from the system `supplement_table_url` and does not provide a per-event URL field.
- Program info page (`dashboard/src/programminfo.tsx`):
- Loads data from `dashboard/public/program-info.json` (app name, version, build info, tech stack, changelog).
- Uses Syncfusion card classes (`e-card`, `e-card-header`, `e-card-title`, `e-card-content`) for consistent styling.
- Changelog is paginated with `PagerComponent` (from `@syncfusion/ej2-react-grids`), default page size 5; adjust `pageSize` or add a selector as needed.
- Groups page (`dashboard/src/infoscreen_groups.tsx`):
- Migrated to Syncfusion inputs and popups: Buttons, TextBox, DropDownList, Dialog; Kanban remains for drag/drop.
- Unified toast/dialog wording; replaced legacy alerts with toasts; spacing handled via inline styles to avoid Tailwind dependency.
- Header user menu (top-right):
- Shows current username and role; click opens a menu with "Passwort ändern" (lock icon), "Profil", and "Abmelden".
- Implemented with Syncfusion DropDownButton (`@syncfusion/ej2-react-splitbuttons`).
- "Passwort ändern": Opens self-service password change dialog (available to all authenticated users); requires current password verification, new password min 6 chars, must match confirm field; calls `PUT /api/auth/change-password`
- "Abmelden" navigates to `/logout`; the page invokes backend logout and redirects to `/login`.
- User management page (`dashboard/src/users.tsx`):
- Full CRUD interface for managing users (admin+ only in menu); accessible via "Benutzer" sidebar entry
- Syncfusion GridComponent: 20 per page (configurable), sortable columns (ID, username, role), custom action button template with role-based visibility
- Statistics cards: total users, active (non-deactivated), inactive (deactivated) counts
- Dialogs: Create (username/password/role/status), Edit (with self-edit protections), Password Reset (admin only, no current password required), Delete (superadmin only, self-check), Details (read-only audit info with formatted timestamps)
- Role badges: Color-coded display (user: gray, editor: blue, admin: green, superadmin: red)
- Audit information displayed: last login, password change, last failed login, deactivation timestamps and deactivating user
- Role-based permissions (enforced backend + frontend):
- Admin: can manage user/editor/admin roles (not superadmin); soft-deactivate only; cannot see/edit superadmin accounts
- Superadmin: can manage all roles including other superadmins; can permanently hard-delete users
- Security rules enforced: cannot change own role, cannot deactivate own account, cannot delete self, cannot reset own password via admin route (must use self-service)
- API client in `dashboard/src/apiUsers.ts` for all user operations (listUsers, getUser, createUser, updateUser, resetUserPassword, deleteUser)
- Menu visibility: "Benutzer" menu item only visible to admin+ (role-gated in App.tsx)
- Settings page (`dashboard/src/settings.tsx`):
- Structure: Syncfusion TabComponent with role-gated tabs
- 📅 Academic Calendar (all users)
- 📥 Import & Liste: CSV/TXT import and list combined
- 🗂️ Perioden: select and set active period (uses `/api/academic_periods` routes)
- 🖥️ Display & Clients (admin+)
- Default Settings: placeholders for heartbeat, screenshots, defaults
- Client Configuration: quick links to Clients and Groups pages
- 🎬 Media & Files (admin+)
- Upload Settings: placeholders for limits and types
- Conversion Status: placeholder for conversions overview
- 🗓️ Events (admin+)
- WebUntis / Vertretungsplan: system-wide supplement table URL with enable/disable, save, and preview; persists via `/api/system-settings/supplement-table`
- Presentations: general defaults for slideshow interval, page-progress, and auto-progress; persisted via `/api/system-settings` keys (`presentation_interval`, `presentation_page_progress`, `presentation_auto_progress`). These defaults are applied when creating new presentation events (the custom event modal reads them and falls back to per-event values when editing).
- Videos: system-wide defaults for `autoplay`, `loop`, `volume`, and `muted`; persisted via `/api/system-settings` keys (`video_autoplay`, `video_loop`, `video_volume`, `video_muted`). These defaults are applied when creating new video events (the custom event modal reads them and falls back to per-event values when editing).
- Other event types (website, message, other): placeholders for defaults
- ⚙️ System (superadmin)
- Organization Info and Advanced Configuration placeholders
- Role gating: Admin/Superadmin tabs are hidden if the user lacks permission; System is superadmin-only
- API clients use relative `/api/...` URLs so Vite dev proxy handles requests without CORS issues. The settings UI calls are centralized in `dashboard/src/apiSystemSettings.ts`.
- Nested tabs: implemented as controlled components using `selectedItem` with stateful handlers to prevent sub-tab resets during updates.
- Dashboard page (`dashboard/src/dashboard.tsx`):
- Card-based overview of all Raumgruppen (room groups) with real-time status monitoring
- Global statistics: total infoscreens, online/offline counts, warning groups
- Filter buttons: All / Online / Offline / Warnings with dynamic counts
- Per-group cards show:
- Currently active event (title, type, date/time in local timezone)
- Health bar with online/offline ratio and color-coded status
- Expandable client list with last alive timestamps
- Bulk restart button for offline clients
- Uses Syncfusion ButtonComponent, ToastComponent, and card CSS classes
- Auto-refresh every 15 seconds; manual refresh button available
- "Nicht zugeordnet" group always appears last in sorted list
- Ressourcen page (`dashboard/src/ressourcen.tsx`):
- Timeline view showing all groups and their active events in parallel
- Uses Syncfusion ScheduleComponent with TimelineViews (day/week modes)
- Compact row display: 65px height per group, dynamically calculated total height
- Group ordering panel with drag up/down controls; order persisted to backend via `/api/groups/order`
- Filters out "Nicht zugeordnet" group from timeline display
- Fetches events per group for current date range; displays first active event per group
- Color-coded event bars using `getGroupColor()` from `groupColors.ts`
- Resource-based timeline: each group is a resource row, events mapped to `ResourceId`
- Real-time updates: loads events on mount and when view/date changes
- Custom CSS in `dashboard/src/ressourcen.css` for timeline styling and controls
- User dropdown technical notes:
- Dependencies: `@syncfusion/ej2-react-splitbuttons` and `@syncfusion/ej2-splitbuttons` must be installed.
- Vite: add both to `optimizeDeps.include` in `vite.config.ts` to avoid import-analysis errors.
- Dev containers: when `node_modules` is a named volume, recreate the dashboard node_modules volume after adding dependencies so `npm ci` runs inside the container.
Note: Syncfusion usage in the dashboard is already documented above; if a UI for conversion status/downloads is added later, link its routes and components here.
## Local development
- Compose: development is `docker-compose.yml` + `docker-compose.override.yml`.
- API (dev): `server/Dockerfile.dev` with debugpy on 5678, Flask app `wsgi:app` on :8000.
- Dashboard (dev): `dashboard/Dockerfile.dev` exposes :5173 and waits for API via `dashboard/wait-for-backend.sh`.
- Mosquitto: allows anonymous in dev; WebSocket on :9001.
- Common env vars: `DB_CONN`, `DB_USER`, `DB_PASSWORD`, `DB_HOST=db`, `DB_NAME`, `ENV`, `MQTT_USER`, `MQTT_PASSWORD`.
- Alembic: prod compose runs `alembic ... upgrade head` and `server/init_defaults.py` before gunicorn.
- Local dev: prefer `python server/initialize_database.py` for one-shot setup (migrations + defaults + academic periods).
- Defaults: `server/init_defaults.py` seeds initial system settings like `supplement_table_url` and `supplement_table_enabled` if missing.
- `server/init_academic_periods.py` remains available to (re)seed school years.
## Production
- `docker-compose.prod.yml` uses prebuilt images (`ghcr.io/robbstarkaustria/*`).
- Nginx serves dashboard and proxies API; TLS certs expected in `certs/` and mounted to `/etc/nginx/certs`.
## Environment variables (reference)
- DB_CONN — Preferred DB URL for services. Example: `mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}`
- DB_USER, DB_PASSWORD, DB_NAME, DB_HOST — Used to assemble DB_CONN in dev if missing; inside containers `DB_HOST=db`.
- ENV — `development` or `production`; in development, `server/database.py` loads `.env`.
- MQTT_BROKER_HOST, MQTT_BROKER_PORT — Defaults `mqtt` and `1883`; MQTT_USER/MQTT_PASSWORD optional (dev often anonymous per Mosquitto config).
- VITE_API_URL — Dashboard build-time base URL (prod); in dev the Vite proxy serves `/api` to `server:8000`.
- HEARTBEAT_GRACE_PERIOD_DEV / HEARTBEAT_GRACE_PERIOD_PROD — Groups "alive" window (defaults 180s dev / 170s prod). Clients send heartbeats every ~65s; grace periods allow 2 missed heartbeats plus safety margin.
- REFRESH_SECONDS — Optional scheduler republish interval; `0` disables periodic refresh.
## Conventions & gotchas
- **Datetime Handling**:
- Always compare datetimes in UTC; some DB values may be naive—normalize before comparing (see `routes/events.py`).
- Database stores timestamps in UTC (naive datetimes are normalized to UTC by backend)
- API returns ISO strings **without** 'Z' suffix: `"2025-11-27T20:03:00"`
- Frontend **must** append 'Z' before parsing: `const utcStr = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'; new Date(utcStr);`
- Display in local timezone using `toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })`
- When sending to API, use `date.toISOString()` which includes 'Z' and is UTC
- Frontend must append `Z` to API strings before parsing; backend compares in UTC and returns ISO without `Z`.
- **JSON Naming Convention**:
- Backend uses snake_case internally (Python convention)
- API returns camelCase JSON (web standard): `startTime`, `endTime`, `groupId`, etc.
- Use `dict_to_camel_case()` from `server/serializers.py` before `jsonify()`
- Frontend consumes camelCase directly; Syncfusion scheduler maintains internal PascalCase with field mappings
- Scheduler enforces UTC comparisons and normalizes naive timestamps. It publishes only currently active events and clears retained topics for groups with no active events. It also queries a future window (default: 7 days) and expands recurring events using RFC 5545 rules. Event exceptions are respected. Logging is concise and conversion lookups are cached.
- Use retained MQTT messages for state that clients must recover after reconnect (events per group, client group_id).
- Clients should parse `event_type` and then read the corresponding nested payload (`presentation`, `website`, `video`, etc.). `website` and `webuntis` use the same nested `website` payload with `type: browser` and a `url`. Video events include `autoplay`, `loop`, `volume`, and `muted`.
- In-container DB host is `db`; do not use `localhost` inside services.
- No separate dev vs prod secret conventions: use the same env var keys across environments (e.g., `DB_CONN`, `MQTT_USER`, `MQTT_PASSWORD`).
- When adding a new route:
1) Create a Blueprint in `server/routes/...`,
2) Register it in `server/wsgi.py`,
3) Manage `Session()` lifecycle,
4) Return JSON-safe values (serialize enums and datetimes), and
5) Use `dict_to_camel_case()` for camelCase JSON responses
Docs maintenance guardrails (solo-friendly): Update this file alongside code changes (services/MQTT/API/UTC/env). Keep it concise (2050 lines per section). Never include secrets.
- 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`).
- Initialization scripts: legacy DB init scripts were removed; use Alembic and `initialize_database.py` going forward.
### Recurrence & holidays: conventions
- Do not pre-expand recurrences on the backend. Always send master events with `RecurrenceRule` + `RecurrenceException`.
- Ensure EXDATE tokens are RFC 5545 timestamps (`yyyyMMddTHHmmssZ`) matching the occurrence start time (UTC) so Syncfusion can exclude them natively.
- When `skip_holidays` or recurrence changes, regenerate `EventException` rows so `RecurrenceException` stays in sync.
- Single occurrence detach: Use `POST /api/events/<id>/occurrences/<date>/detach` to create standalone events and add EXDATE entries without modifying master events. The frontend persists edits via `actionComplete` (`requestType='eventChanged'`).
## Quick examples
- Add client description persists to DB and publishes group via MQTT: see `PUT /api/clients/<uuid>/description` in `routes/clients.py`.
- Bulk group assignment emits retained messages for each client: `PUT /api/clients/group`.
- Listener heartbeat path: `infoscreen/<uuid>/heartbeat` → sets `clients.last_alive`.
## Scheduler payloads: presentation extras
- Presentation event payloads now include `page_progress` and `auto_progress` in addition to `slide_interval` and media files. These are sourced from per-event fields in the database (with system defaults applied on event creation).
## Scheduler payloads: website & webuntis
- For both `website` and `webuntis`, the scheduler emits a nested `website` object:
- `{ "type": "browser", "url": "https://..." }`
- The `event_type` remains `website` or `webuntis`. Clients should treat both identically for rendering.
- The WebUntis URL is set at event creation by reading the system `supplement_table_url`.
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.
## Changelog Style Guide (Program info)
- Source: `dashboard/public/program-info.json`; newest entry first
- Fields per release: `version`, `date` (YYYY-MM-DD), `changes` (array of short bullets)
- Tone: concise, user-facing; German wording; area prefixes allowed (e.g., “UI: …”, “API: …”)
- Categories via emoji or words: Added (🆕/✨), Changed (🛠️), Fixed (✅/🐛), Removed (🗑️), Security (🔒), Deprecated (⚠️)
- Breaking changes must be prefixed with `BREAKING:`
- Keep ≤ 810 bullets; summarize or group micro-changes
- JSON hygiene: valid JSON, no trailing commas, dont edit historical entries except typos

120
.gitignore vendored
View File

@@ -1,53 +1,7 @@
# Python-related
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.pdb
*.egg-info/
*.eggs/
*.env
.env
# Byte-compiled / optimized / DLL files
*.pyc
*.pyo
*.pyd
# Virtual environments
venv/
env/
.venv/
.env/
# Logs and databases
*.log
*.sqlite3
*.db
# Docker-related
*.pid
*.tar
docker-compose.override.yml
docker-compose.override.*.yml
docker-compose.override.*.yaml
# Node.js-related
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Dash and Flask cache
*.cache
*.pytest_cache/
instance/
*.mypy_cache/
*.hypothesis/
*.coverage
.coverage.*
# IDE and editor files
# OS/Editor
.DS_Store
Thumbs.db
desktop.ini
.vscode/
.idea/
*.swp
@@ -55,23 +9,69 @@ instance/
*.bak
*.tmp
# OS-generated files
.DS_Store
Thumbs.db
desktop.ini
# Python
__pycache__/
*.py[cod]
*.pyc
*.pyo
*.pyd
*.pdb
*.egg-info/
*.eggs/
.pytest_cache/
*.mypy_cache/
*.hypothesis/
*.coverage
.coverage.*
*.cache
instance/
# Devcontainer-related
# Virtual environments
venv/
env/
.venv/
.env/
# Environment files
.env
.env.local
# Logs and databases
*.log
*.log.1
*.sqlite3
*.db
# Node.js
node_modules/
dashboard/node_modules/
dashboard/.vite/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-store/
# Docker
*.pid
*.tar
docker-compose.override.yml
docker-compose.override.*.yml
docker-compose.override.*.yaml
# Devcontainer
.devcontainer/
# Project-specific
received_screenshots/
mosquitto/
alte/
screenshots/
media/
mosquitto/
certs/
alte/
sync.ffs_db
dashboard/manitine_test.py
dashboard/pages/test.py
.gitignore
dashboard/sidebar_test.py
dashboard/assets/responsive-sidebar.css
certs/
sync.ffs_db
dashboard/src/nested_tabs.js
scheduler/scheduler.log.2

View File

@@ -0,0 +1,100 @@
# Maintaining AI Assistant Instructions (copilot-instructions.md)
This repo uses `.github/copilot-instructions.md` to brief AI coding agents about your architecture, workflows, and conventions. Keep it concise, repo-specific, and always in sync with your code.
This guide explains when and how to update it, plus small guardrails to help—even for a solo developer.
## When to update
Update the instructions in the same commit as your change whenever you:
- Add/rename services, ports, or container wiring (docker-compose*.yml, Nginx, Mosquitto)
- Introduce/rename MQTT topics or change retained-message behavior
- Add/rename environment variables or change defaults (`.env.example`, `deployment.md`)
- Change DB models or time/UTC handling (e.g., `models/models.py`, UTC normalization in routes/scheduler)
- Add/modify API route patterns or session lifecycle (files in `server/routes/*`, `server/wsgi.py`)
- Adjust frontend dev proxy or build settings (`dashboard/vite.config.ts`, Dockerfiles)
## What to update (and where)
- `.github/copilot-instructions.md`
- Big picture: services and ports
- Service boundaries & data flow: DB connection rules, MQTT topics, retained messages, screenshots
- API patterns: Blueprints, Session per request, enum/datetime serialization
- Frontend patterns: Vite dev proxy and pre-bundled dependencies
- Environment variables (reference): names, purposes, example patterns
- Conventions & gotchas: UTC comparisons, retained MQTT, container hostnames
- `.env.example`
- Add new variable names with placeholders and comments (never secrets)
- Keep in-container defaults (e.g., `DB_HOST=db`, `MQTT_BROKER_HOST=mqtt`)
- `deployment.md`
- Update Quickstart URLs/ports/commands
- Document prod-specific env usage (e.g., `VITE_API_URL`, `DB_CONN`)
## How to write good updates
- Keep it short (approx. 2050 lines total). Link to code by path or route rather than long prose.
- Document real, present patterns—not plans.
- Use UTC consistently and call out any special handling.
- Include concrete examples from this repo when describing patterns (e.g., which route shows enum serialization).
- Never include secrets or real tokens; show only variable names and example formats.
## Solo-friendly workflow
- Update docs in the same commit as your change:
- Code changed → docs changed (copilot-instructions, `.env.example`, `deployment.md` as needed)
- Use a quick self-checklist before pushing:
- Services/ports changed? Update “Big picture”.
- MQTT topics/retained behavior changed? Update “Service boundaries & data flow”.
- API/Session/UTC rules changed? Update “API patterns” and “Conventions & gotchas”.
- Frontend proxy/build changed? Update “Frontend patterns”.
- Env vars changed? Update “Environment variables (reference)” + `.env.example`.
- Dev/prod run steps changed? Update `deployment.md` Quickstart.
- Keep commits readable by pairing code and doc changes:
- `feat(api): add events endpoint; docs: update routes and UTC note`
- `chore(compose): rename service; docs: update ports + nginx`
- `docs(env): add MQTT_USER to .env.example + instructions`
## Optional guardrails (even for solo)
- PR (or MR) template (useful even if you self-merge)
- Add `.github/pull_request_template.md` with:
```
Checklist
- [ ] Updated .github/copilot-instructions.md (services/MQTT/API/UTC/env)
- [ ] Synced .env.example (new/renamed vars)
- [ ] Adjusted deployment.md (dev/prod steps, URLs/ports)
- [ ] Verified referenced files/paths in the instructions exist
```
- Lightweight docs check (optional pre-commit hook)
- Non-blocking script that warns if referenced files/paths dont exist. Example sketch:
```
#!/usr/bin/env bash
set -e
FILE=.github/copilot-instructions.md
missing=0
for path in \
server/wsgi.py \
server/routes/clients.py \
server/routes/events.py \
server/routes/groups.py \
dashboard/vite.config.ts \
docker-compose.yml \
docker-compose.override.yml; do
if ! test -e "$path"; then
echo "[warn] referenced path not found: $path"; missing=1
fi
done
exit 0 # warn only; do not block commit
```
- Weekly 2-minute sweep
- Read `.github/copilot-instructions.md` top-to-bottom and remove anything stale.
## FAQ
- Where do the AI assistants look?
- `.github/copilot-instructions.md` + the code you have open. Keep this file synced with the codebase.
- Is it safe to commit this file?
- Yes—no secrets. It should contain only structure, patterns, and example formats.
- How detailed should it be?
- Concise and actionable; point to exact files for details. Avoid generic advice.
## Pointers to key files
- Compose & infra: `docker-compose*.yml`, `nginx.conf`, `mosquitto/config/mosquitto.conf`
- Backend: `server/database.py`, `server/wsgi.py`, `server/routes/*`, `models/models.py`
- MQTT workers: `listener/listener.py`, `scheduler/scheduler.py`, `server/mqtt_helper.py`
- Frontend: `dashboard/vite.config.ts`, `dashboard/package.json`, `dashboard/src/*`
- Dev/Prod docs: `deployment.md`, `.env.example`

264
AUTH_QUICKREF.md Normal file
View File

@@ -0,0 +1,264 @@
# Authentication Quick Reference
## For Backend Developers
### Protecting a Route
```python
from flask import Blueprint
from server.permissions import require_role, admin_or_higher, editor_or_higher
my_bp = Blueprint("myroute", __name__, url_prefix="/api/myroute")
# Specific role(s)
@my_bp.route("/admin")
@require_role('admin', 'superadmin')
def admin_only():
return {"message": "Admin only"}
# Convenience decorators
@my_bp.route("/settings")
@admin_or_higher
def settings():
return {"message": "Admin or superadmin"}
@my_bp.route("/create", methods=["POST"])
@editor_or_higher
def create():
return {"message": "Editor, admin, or superadmin"}
```
### Getting Current User in Route
```python
from flask import session
@my_bp.route("/profile")
@require_auth
def profile():
user_id = session.get('user_id')
username = session.get('username')
role = session.get('role')
return {
"user_id": user_id,
"username": username,
"role": role
}
```
## For Frontend Developers
### Using the Auth Hook
```typescript
import { useAuth } from './useAuth';
function MyComponent() {
const { user, isAuthenticated, login, logout, loading } = useAuth();
if (loading) return <div>Loading...</div>;
if (!isAuthenticated) {
return <button onClick={() => login('user', 'pass')}>Login</button>;
}
return (
<div>
<p>Welcome {user?.username}</p>
<p>Role: {user?.role}</p>
<button onClick={logout}>Logout</button>
</div>
);
}
```
### Conditional Rendering
```typescript
import { useCurrentUser } from './useAuth';
import { isAdminOrHigher, isEditorOrHigher } from './apiAuth';
function Navigation() {
const user = useCurrentUser();
return (
<nav>
<a href="/">Home</a>
{/* Show for all authenticated users */}
{user && <a href="/events">Events</a>}
{/* Show for editor+ */}
{isEditorOrHigher(user) && (
<a href="/events/new">Create Event</a>
)}
{/* Show for admin+ */}
{isAdminOrHigher(user) && (
<a href="/admin">Admin Panel</a>
)}
</nav>
);
}
```
### Making Authenticated API Calls
```typescript
// Always include credentials for session cookies
const response = await fetch('/api/protected-route', {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
// ... other options
});
```
## Role Hierarchy
```
superadmin > admin > editor > user
```
| Role | Can Do |
|------|--------|
| **user** | View events |
| **editor** | user + CRUD events/media |
| **admin** | editor + manage users/groups/settings |
| **superadmin** | admin + manage superadmins + system config |
## Environment Variables
```bash
# Required for sessions
FLASK_SECRET_KEY=your_secret_key_here
# Required for superadmin creation
DEFAULT_SUPERADMIN_USERNAME=superadmin
DEFAULT_SUPERADMIN_PASSWORD=your_password_here
```
Generate a secret key:
```bash
python -c 'import secrets; print(secrets.token_hex(32))'
```
## Testing Endpoints
```bash
# Login
curl -X POST http://localhost:8000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"superadmin","password":"your_password"}' \
-c cookies.txt
# Check current user
curl http://localhost:8000/api/auth/me -b cookies.txt
# Check auth status (lightweight)
curl http://localhost:8000/api/auth/check -b cookies.txt
# Logout
curl -X POST http://localhost:8000/api/auth/logout -b cookies.txt
# Test protected route
curl http://localhost:8000/api/protected -b cookies.txt
```
## Common Patterns
### Backend: Optional Auth
```python
from flask import session
@my_bp.route("/public-with-extras")
def public_route():
user_id = session.get('user_id')
if user_id:
# Show extra content for authenticated users
return {"data": "...", "extras": "..."}
else:
# Public content only
return {"data": "..."}
```
### Frontend: Redirect After Login
```typescript
const { login } = useAuth();
const handleLogin = async (username: string, password: string) => {
try {
await login(username, password);
window.location.href = '/dashboard';
} catch (err) {
console.error('Login failed:', err);
}
};
```
### Frontend: Protected Route Component
```typescript
import { useAuth } from './useAuth';
import { Navigate } from 'react-router-dom';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, loading } = useAuth();
if (loading) return <div>Loading...</div>;
if (!isAuthenticated) {
return <Navigate to="/login" />;
}
return <>{children}</>;
}
// Usage in routes:
<Route path="/admin" element={
<ProtectedRoute>
<AdminPanel />
</ProtectedRoute>
} />
```
## Troubleshooting
### "Authentication required" on /api/auth/me
**Normal** - User is not logged in. This is expected behavior.
### Session not persisting across requests
- Check `credentials: 'include'` in fetch calls
- Verify `FLASK_SECRET_KEY` is set
- Check browser cookies are enabled
### 403 Forbidden on decorated route
- Verify user is logged in
- Check user role matches required role
- Inspect response for `required_roles` and `your_role`
## Files Reference
| File | Purpose |
|------|---------|
| `server/routes/auth.py` | Auth endpoints (login, logout, /me) |
| `server/permissions.py` | Permission decorators |
| `dashboard/src/apiAuth.ts` | Frontend API client |
| `dashboard/src/useAuth.tsx` | React context/hooks |
| `models/models.py` | User model and UserRole enum |
## Full Documentation
See `AUTH_SYSTEM.md` for complete documentation including:
- Architecture details
- Security considerations
- API reference
- Testing guide
- Production checklist

522
AUTH_SYSTEM.md Normal file
View File

@@ -0,0 +1,522 @@
# Authentication System Documentation
This document describes the authentication and authorization system implemented in the infoscreen_2025 project.
## Overview
The system provides session-based authentication with role-based access control (RBAC). It includes:
- **Backend**: Flask session-based auth with bcrypt password hashing
- **Frontend**: React context/hooks for managing authentication state
- **Permissions**: Decorators for protecting routes based on user roles
- **Roles**: Four levels (user, editor, admin, superadmin)
## Architecture
### Backend Components
#### 1. Auth Routes (`server/routes/auth.py`)
Provides authentication endpoints:
- **`POST /api/auth/login`** - Authenticate user and create session
- **`POST /api/auth/logout`** - End user session
- **`GET /api/auth/me`** - Get current user info (protected)
- **`GET /api/auth/check`** - Quick auth status check
#### 2. Permission Decorators (`server/permissions.py`)
Decorators for protecting routes:
```python
from server.permissions import require_role, admin_or_higher, editor_or_higher
# Require specific role(s)
@app.route('/admin-settings')
@require_role('admin', 'superadmin')
def admin_settings():
return "Admin only"
# Convenience decorators
@app.route('/settings')
@admin_or_higher # admin or superadmin
def settings():
return "Settings"
@app.route('/events', methods=['POST'])
@editor_or_higher # editor, admin, or superadmin
def create_event():
return "Create event"
```
Available decorators:
- `@require_auth` - Just require authentication
- `@require_role(*roles)` - Require any of specified roles
- `@superadmin_only` - Superadmin only
- `@admin_or_higher` - Admin or superadmin
- `@editor_or_higher` - Editor, admin, or superadmin
#### 3. Session Configuration (`server/wsgi.py`)
Flask session configured with:
- Secret key from `FLASK_SECRET_KEY` environment variable
- HTTPOnly cookies (prevent XSS)
- SameSite=Lax (CSRF protection)
- Secure flag in production (HTTPS only)
### Frontend Components
#### 1. API Client (`dashboard/src/apiAuth.ts`)
TypeScript functions for auth operations:
```typescript
import { login, logout, fetchCurrentUser } from './apiAuth';
// Login
await login('username', 'password');
// Get current user
const user = await fetchCurrentUser();
// Logout
await logout();
// Check auth status (lightweight)
const { authenticated, role } = await checkAuth();
```
Helper functions:
```typescript
import { hasRole, hasAnyRole, isAdminOrHigher } from './apiAuth';
if (isAdminOrHigher(user)) {
// Show admin UI
}
```
#### 2. Auth Context/Hooks (`dashboard/src/useAuth.tsx`)
React context for managing auth state:
```typescript
import { useAuth, useCurrentUser, useIsAuthenticated } from './useAuth';
function MyComponent() {
// Full auth context
const { user, login, logout, loading, error, isAuthenticated } = useAuth();
// Or just what you need
const user = useCurrentUser();
const isAuth = useIsAuthenticated();
if (loading) return <div>Loading...</div>;
if (!isAuthenticated) {
return <LoginForm onLogin={login} />;
}
return <div>Welcome {user.username}!</div>;
}
```
## User Roles
Four hierarchical roles with increasing permissions:
| Role | Value | Description | Use Case |
|------|-------|-------------|----------|
| **User** | `user` | Read-only access | View events only |
| **Editor** | `editor` | Can CRUD events/media | Content managers |
| **Admin** | `admin` | Manage settings, users (except superadmin), groups | Organization staff |
| **Superadmin** | `superadmin` | Full system access | Developers, system admins |
### Permission Matrix
| Action | User | Editor | Admin | Superadmin |
|--------|------|--------|-------|------------|
| View events | ✅ | ✅ | ✅ | ✅ |
| Create/edit events | ❌ | ✅ | ✅ | ✅ |
| Manage media | ❌ | ✅ | ✅ | ✅ |
| Manage groups/clients | ❌ | ❌ | ✅ | ✅ |
| Manage users (non-superadmin) | ❌ | ❌ | ✅ | ✅ |
| Manage settings | ❌ | ❌ | ✅ | ✅ |
| Manage superadmins | ❌ | ❌ | ❌ | ✅ |
| System configuration | ❌ | ❌ | ❌ | ✅ |
## Setup Instructions
### 1. Environment Configuration
Add to your `.env` file:
```bash
# Flask session secret key (REQUIRED)
# Generate with: python -c 'import secrets; print(secrets.token_hex(32))'
FLASK_SECRET_KEY=your_secret_key_here
# Superadmin account (REQUIRED for initial setup)
DEFAULT_SUPERADMIN_USERNAME=superadmin
DEFAULT_SUPERADMIN_PASSWORD=your_secure_password
```
### 2. Database Initialization
The superadmin user is created automatically when containers start. See `SUPERADMIN_SETUP.md` for details.
### 3. Frontend Integration
Wrap your app with `AuthProvider` in `main.tsx` or `App.tsx`:
```typescript
import { AuthProvider } from './useAuth';
function App() {
return (
<AuthProvider>
{/* Your app components */}
</AuthProvider>
);
}
```
## Usage Examples
### Backend: Protecting Routes
```python
from flask import Blueprint
from server.permissions import require_role, admin_or_higher
users_bp = Blueprint("users", __name__, url_prefix="/api/users")
@users_bp.route("", methods=["GET"])
@admin_or_higher
def list_users():
"""List all users - admin+ only"""
# Implementation
pass
@users_bp.route("", methods=["POST"])
@require_role('superadmin')
def create_superadmin():
"""Create superadmin - superadmin only"""
# Implementation
pass
```
### Frontend: Conditional Rendering
```typescript
import { useAuth } from './useAuth';
import { isAdminOrHigher, isEditorOrHigher } from './apiAuth';
function NavigationMenu() {
const { user } = useAuth();
return (
<nav>
<a href="/dashboard">Dashboard</a>
<a href="/events">Events</a>
{isEditorOrHigher(user) && (
<a href="/events/new">Create Event</a>
)}
{isAdminOrHigher(user) && (
<>
<a href="/settings">Settings</a>
<a href="/users">Manage Users</a>
<a href="/groups">Manage Groups</a>
</>
)}
</nav>
);
}
```
### Frontend: Login Form Example
```typescript
import { useState } from 'react';
import { useAuth } from './useAuth';
function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const { login, loading, error } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await login(username, password);
// Redirect on success
window.location.href = '/dashboard';
} catch (err) {
// Error is already in auth context
console.error('Login failed:', err);
}
};
return (
<form onSubmit={handleSubmit}>
<h1>Login</h1>
{error && <div className="error">{error}</div>}
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
/>
<button type="submit" disabled={loading}>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
);
}
```
## Security Considerations
### Backend Security
1. **Password Hashing**: All passwords hashed with bcrypt (salt rounds default)
2. **Session Security**:
- HTTPOnly cookies (prevent XSS access)
- SameSite=Lax (CSRF protection)
- Secure flag in production (HTTPS only)
3. **Secret Key**: Must be set via environment variable, not hardcoded
4. **Role Checking**: Server-side validation on every protected route
### Frontend Security
1. **Credentials**: Always use `credentials: 'include'` in fetch calls
2. **No Password Storage**: Never store passwords in localStorage/sessionStorage
3. **Role Gating**: UI gating is convenience, not security (always validate server-side)
4. **HTTPS**: Always use HTTPS in production
### Production Checklist
- [ ] Generate strong `FLASK_SECRET_KEY` (32+ bytes)
- [ ] Set `SESSION_COOKIE_SECURE=True` (handled automatically by ENV=production)
- [ ] Use HTTPS with valid TLS certificate
- [ ] Change default superadmin password after first login
- [ ] Review and audit user roles regularly
- [ ] Enable audit logging (future enhancement)
## API Reference
### Authentication Endpoints
#### POST /api/auth/login
Authenticate user and create session.
**Request:**
```json
{
"username": "string",
"password": "string"
}
```
**Response (200):**
```json
{
"message": "Login successful",
"user": {
"id": 1,
"username": "admin",
"role": "admin"
}
}
```
**Errors:**
- `400` - Missing username or password
- `401` - Invalid credentials or account disabled
#### POST /api/auth/logout
End current session.
**Response (200):**
```json
{
"message": "Logout successful"
}
```
#### GET /api/auth/me
Get current user information (requires authentication).
**Response (200):**
```json
{
"id": 1,
"username": "admin",
"role": "admin",
"is_active": true
}
```
**Errors:**
- `401` - Not authenticated or account disabled
#### GET /api/auth/check
Quick authentication status check.
**Response (200):**
```json
{
"authenticated": true,
"role": "admin"
}
```
Or if not authenticated:
```json
{
"authenticated": false
}
```
## Testing
### Manual Testing
1. **Create test users** (via database or future user management UI):
```sql
INSERT INTO users (username, password_hash, role, is_active)
VALUES ('testuser', '<bcrypt_hash>', 'user', 1);
```
2. **Test login**:
```bash
curl -X POST http://localhost:8000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"superadmin","password":"your_password"}' \
-c cookies.txt
```
3. **Test /me endpoint**:
```bash
curl http://localhost:8000/api/auth/me -b cookies.txt
```
4. **Test protected route**:
```bash
# Should fail without auth
curl http://localhost:8000/api/protected
# Should work with cookie
curl http://localhost:8000/api/protected -b cookies.txt
```
### Automated Testing
Example test cases (to be implemented):
```python
def test_login_success():
response = client.post('/api/auth/login', json={
'username': 'testuser',
'password': 'testpass'
})
assert response.status_code == 200
assert 'user' in response.json
def test_login_invalid_credentials():
response = client.post('/api/auth/login', json={
'username': 'testuser',
'password': 'wrongpass'
})
assert response.status_code == 401
def test_me_authenticated():
# Login first
client.post('/api/auth/login', json={'username': 'testuser', 'password': 'testpass'})
response = client.get('/api/auth/me')
assert response.status_code == 200
assert response.json['username'] == 'testuser'
def test_me_not_authenticated():
response = client.get('/api/auth/me')
assert response.status_code == 401
```
## Troubleshooting
### Login Not Working
**Symptoms**: Login endpoint returns 401 even with correct credentials
**Solutions**:
1. Verify user exists in database: `SELECT * FROM users WHERE username='...'`
2. Check password hash is valid bcrypt format
3. Verify user `is_active=1`
4. Check server logs for bcrypt errors
### Session Not Persisting
**Symptoms**: `/api/auth/me` returns 401 after successful login
**Solutions**:
1. Verify `FLASK_SECRET_KEY` is set
2. Check frontend is sending `credentials: 'include'` in fetch
3. Verify cookies are being set (check browser DevTools)
4. Check CORS settings if frontend/backend on different domains
### Permission Denied on Protected Route
**Symptoms**: 403 error on decorated routes
**Solutions**:
1. Verify user is logged in (`/api/auth/me`)
2. Check user role matches required role
3. Verify decorator is applied correctly
4. Check session hasn't expired
### TypeScript Errors in Frontend
**Symptoms**: Type errors when using auth hooks
**Solutions**:
1. Ensure `AuthProvider` is wrapping your app
2. Import types correctly: `import type { User } from './apiAuth'`
3. Check TypeScript config for `verbatimModuleSyntax`
## Next Steps
See `userrole-management.md` for the complete implementation roadmap:
1. ✅ **Extend User Model** - Done
2. ✅ **Seed Superadmin** - Done (`init_defaults.py`)
3. ✅ **Expose Current User Role** - Done (this document)
4. ⏳ **Implement Minimal Role Enforcement** - Apply decorators to existing routes
5. ⏳ **Test the Flow** - Verify permissions work correctly
6. ⏳ **Frontend Role Gating** - Update UI components
7. ⏳ **User Management UI** - Build admin interface
## References
- User model: `models/models.py`
- Auth routes: `server/routes/auth.py`
- Permissions: `server/permissions.py`
- API client: `dashboard/src/apiAuth.ts`
- Auth context: `dashboard/src/useAuth.tsx`
- Flask sessions: https://flask.palletsprojects.com/en/latest/api/#sessions
- Bcrypt: https://pypi.org/project/bcrypt/

39
CLEANUP_SUMMARY.md Normal file
View File

@@ -0,0 +1,39 @@
# Database Cleanup Summary
## Files Removed ✅
The following obsolete database initialization files have been removed:
### Removed Files:
- **`server/init_database.py`** - Manual table creation (superseded by Alembic migrations)
- **`server/init_db.py`** - Alternative initialization (superseded by `init_defaults.py`)
- **`server/init_mariadb.py`** - Database/user creation (handled by Docker Compose)
- **`server/test_sql.py`** - Outdated connection test (used localhost instead of container)
### Why These Were Safe to Remove:
1. **No references found** in any Docker files, scripts, or code
2. **Functionality replaced** by modern Alembic-based approach
3. **Hardcoded connection strings** that don't match current Docker setup
4. **Manual processes** now automated in production deployment
## Current Database Management ✅
### Active Scripts:
- **`server/initialize_database.py`** - Complete initialization (NEW)
- **`server/init_defaults.py`** - Default data creation
- **`server/init_academic_periods.py`** - Academic periods setup
- **`alembic/`** - Schema migrations (version control)
### Development Scripts (Kept):
- **`server/dummy_clients.py`** - Test client data generation
- **`server/dummy_events.py`** - Test event data generation
- **`server/sync_existing_clients.py`** - MQTT synchronization utility
## Result
- **4 obsolete files removed**
- **Documentation updated** to reflect current state
- **No breaking changes** - all functionality preserved
- **Cleaner codebase** with single initialization path
The database initialization process is now streamlined and uses only modern, maintained approaches.

209
DATABASE_GUIDE.md Normal file
View File

@@ -0,0 +1,209 @@
# Database Initialization and Management Guide
## Quick Start
Your database has been successfully initialized! Here's what you need to know:
### ✅ Current Status
- **Database**: MariaDB 11.2 running in Docker container `infoscreen-db`
- **Schema**: Up to date (check with `alembic current` in `server/`)
- **Default Data**: Admin user and client group created
- **Academic Periods**: Austrian school years 2024/25 (active), 2025/26, 2026/27
### 🔐 Default Credentials
- **Admin Username**: `infoscreen_admin`
- **Admin Password**: Check your `.env` file for `DEFAULT_ADMIN_PASSWORD`
- **Database User**: `infoscreen_admin`
- **Database Name**: `infoscreen_by_taa`
## Database Management Commands
### Initialize/Reinitialize Database
```bash
cd /workspace/server
python initialize_database.py
```
### Check Migration Status
```bash
cd /workspace/server
alembic current
alembic history --verbose
```
### Run Migrations Manually
```bash
cd /workspace/server
alembic upgrade head # Apply all pending migrations
alembic upgrade +1 # Apply next migration
alembic downgrade -1 # Rollback one migration
```
### Create New Migration
```bash
cd /workspace/server
alembic revision --autogenerate -m "Description of changes"
```
### Database Connection Test
```bash
cd /workspace/server
python -c "
from database import Session
session = Session()
print('✅ Database connection successful')
session.close()
"
```
## Initialization Scripts
### Core Scripts (recommended order):
1. **`alembic upgrade head`** - Apply database schema migrations
2. **`init_defaults.py`** - Create default user groups and admin user
3. **`init_academic_periods.py`** - Set up Austrian school year periods
### All-in-One Script:
- **`initialize_database.py`** - Complete database initialization (runs all above scripts)
### Development/Testing Scripts:
- **`dummy_clients.py`** - Creates test client data for development
- **`dummy_events.py`** - Creates test event data for development
- **`sync_existing_clients.py`** - One-time MQTT sync for existing clients
## Database Schema Overview
### Main Tables:
- **`users`** - User authentication and roles
- **`clients`** - Registered client devices
- **`client_groups`** - Client organization groups
- **`events`** - Scheduled events and presentations
- **`event_media`** - Media files for events
- **`conversions`** - File conversion jobs (PPT → PDF)
- **`academic_periods`** - School year/semester management
- **`school_holidays`** - Holiday calendar
- **`event_exceptions`** - Overrides and skips for recurring events (per occurrence)
- **`system_settings`** - Keyvalue store for global settings
- **`alembic_version`** - Migration tracking
### Key details and relationships
- Users (`users`)
- Fields: `username` (unique), `password_hash`, `role` (enum: user|editor|admin|superadmin), `is_active`
- Client groups (`client_groups`)
- Fields: `name` (unique), `description`, `is_active`
- Clients (`clients`)
- Fields: `uuid` (PK), network/device metadata, `group_id` (FK→client_groups, default 1), `last_alive` (updated on heartbeat), `is_active`
- Academic periods (`academic_periods`)
- Fields: `name` (unique), optional `display_name`, `start_date`, `end_date`, `period_type` (enum: schuljahr|semester|trimester), `is_active` (at most one should be active)
- Indexes: `is_active`, dates
- Event media (`event_media`)
- Fields: `media_type` (enum, see below), `url`, optional `file_path`, optional `message_content`, optional `academic_period_id`
- Used by events of types: presentation, video, website, message, other
- Events (`events`)
- Core: `group_id` (FK), optional `academic_period_id` (FK), `title`, optional `description`, `start`, `end`, `event_type` (enum), optional `event_media_id` (FK)
- Presentation/video extras: `autoplay`, `loop`, `volume`, `slideshow_interval`, `page_progress`, `auto_progress`
- Recurrence: `recurrence_rule` (RFC 5545 RRULE), `recurrence_end`, `skip_holidays` (bool)
- Audit/state: `created_by` (FK→users), `updated_by` (FK→users), `is_active`
- Indexes: `start`, `end`, `recurrence_rule`, `recurrence_end`
- Relationships: `event_media`, `academic_period`, `exceptions` (one-to-many to `event_exceptions` with cascade delete)
- Event exceptions (`event_exceptions`)
- Purpose: track per-occurrence skips or overrides for a recurring master event
- Fields: `event_id` (FK→events, ondelete CASCADE), `exception_date` (Date), `is_skipped`, optional overrides (`title`, `description`, `start`, `end`)
- School holidays (`school_holidays`)
- Unique: (`name`, `start_date`, `end_date`, `region`)
- Used in combination with `events.skip_holidays`
- Conversions (`conversions`)
- Purpose: track PPT/PPTX/ODP → PDF processing
- Fields: `source_event_media_id` (FK→event_media, ondelete CASCADE), `target_format`, `target_path`, `status` (enum), `file_hash`, timestamps, `error_message`
- Indexes: (`source_event_media_id`, `target_format`), (`status`, `target_format`)
- Unique: (`source_event_media_id`, `target_format`, `file_hash`) — idempotency per content
- System settings (`system_settings`)
- Keyvalue store: `key` (PK), `value`, optional `description`, `updated_at`
- Notable keys used by the app: `presentation_interval`, `presentation_page_progress`, `presentation_auto_progress`
### Enums (reference)
- UserRole: `user`, `editor`, `admin`, `superadmin`
- AcademicPeriodType: `schuljahr`, `semester`, `trimester`
- EventType: `presentation`, `website`, `video`, `message`, `other`, `webuntis`
- MediaType: `pdf`, `ppt`, `pptx`, `odp`, `mp4`, `avi`, `mkv`, `mov`, `wmv`, `flv`, `webm`, `mpg`, `mpeg`, `ogv`, `jpg`, `jpeg`, `png`, `gif`, `bmp`, `tiff`, `svg`, `html`, `website`
- ConversionStatus: `pending`, `processing`, `ready`, `failed`
### Timezones, recurrence, and holidays
- All timestamps are stored/compared as timezone-aware UTC. Any naive datetimes are normalized to UTC before comparisons.
- Recurrence is represented on events via `recurrence_rule` (RFC 5545 RRULE) and `recurrence_end`. Do not pre-expand series in the DB.
- Per-occurrence exclusions/overrides are stored in `event_exceptions`. The API also emits EXDATE tokens matching occurrence start times (UTC) so the frontend can exclude instances natively.
- When `skip_holidays` is true, occurrences that fall on school holidays are excluded via corresponding `event_exceptions`.
### Environment Variables:
```bash
DB_CONN=mysql+pymysql://infoscreen_admin:KqtpM7wmNdM1DamFKs@db/infoscreen_by_taa
DB_USER=infoscreen_admin
DB_PASSWORD=KqtpM7wmNdM1DamFKs
DB_NAME=infoscreen_by_taa
DB_HOST=db
```
## Troubleshooting
### Database Connection Issues:
```bash
# Check if database container is running
docker ps | grep db
# Check database logs
docker logs infoscreen-db
# Test direct connection
docker exec -it infoscreen-db mysql -u infoscreen_admin -p infoscreen_by_taa
```
### Migration Issues:
```bash
# Check current state
cd /workspace/server && alembic current
# Show migration history
cd /workspace/server && alembic history
# Show pending migrations
cd /workspace/server && alembic show head
```
### Reset Database (⚠️ DESTRUCTIVE):
```bash
# Stop services
docker-compose down
# Remove database volume
docker volume rm infoscreen_2025_db-data
# Restart and reinitialize
docker-compose up -d db
cd /workspace/server && python initialize_database.py
```
## Production Deployment
The production setup in `docker-compose.prod.yml` includes automatic database initialization:
```yaml
server:
command: >
bash -c "alembic -c /app/server/alembic.ini upgrade head &&
python /app/server/init_defaults.py &&
exec gunicorn server.wsgi:app --bind 0.0.0.0:8000"
```
This ensures the database is properly initialized on every deployment.

21
DEV-CHANGELOG.md Normal file
View File

@@ -0,0 +1,21 @@
# DEV-CHANGELOG
This changelog tracks all changes made in the development workspace, including internal, experimental, and in-progress updates. Entries here may not be reflected in public releases or the user-facing changelog.
---
## Unreleased (development workspace)
- Frontend (Settings → Events): Added Presentations defaults (slideshow interval, page-progress, auto-progress) with load/save via `/api/system-settings`; UI uses Syncfusion controls.
- Backend defaults: Seeded `presentation_interval` ("10"), `presentation_page_progress` ("true"), `presentation_auto_progress` ("true") in `server/init_defaults.py` when missing.
- Data model: Added per-event fields `page_progress` and `auto_progress` on `Event`; Alembic migration applied successfully.
- Event modal (dashboard): Extended to show and persist presentation `pageProgress`/`autoProgress`; applies system defaults on create and preserves per-event values on edit; payload includes `page_progress`, `auto_progress`, and `slideshow_interval`.
- Scheduler behavior: Now publishes only currently active events per group (at "now"); clears retained topics by publishing `[]` for groups with no active events; normalizes naive timestamps and compares times in UTC; presentation payloads include `page_progress` and `auto_progress`.
- Recurrence handling: Still queries a 7day window to expand recurring events and apply exceptions; recurring events only deactivate after `recurrence_end` (UNTIL).
- Logging: Temporarily added filter diagnostics during debugging; removed verbose logs after verification.
- WebUntis event type: Implemented new `webuntis` type. Event creation resolves URL from system `supplement_table_url`; returns 400 if not configured. WebUntis behaves like Website on clients (shared website payload).
- Settings consolidation: Removed separate `webuntis_url` (if present during dev); WebUntis and Vertretungsplan share `supplement_table_url`. Removed `/api/system-settings/webuntis-url` endpoints; use `/api/system-settings/supplement-table`.
- Scheduler payloads: Added top-level `event_type` for all events; introduced unified nested `website` payload for both `website` and `webuntis` events: `{ "type": "browser", "url": "…" }`.
- Frontend: Program info bumped to `2025.1.0-alpha.13`; changelog includes WebUntis/Website unification and settings update. Event modal shows no per-event URL for WebUntis.
- Documentation: Added `MQTT_EVENT_PAYLOAD_GUIDE.md` and `WEBUNTIS_EVENT_IMPLEMENTATION.md`. Updated `.github/copilot-instructions.md` and `README.md` for unified Website/WebUntis handling and system settings usage.
Note: These changes are available in the development environment and may be included in future releases. For released changes, see TECH-CHANGELOG.md.

View File

@@ -0,0 +1,18 @@
"2.11.","Allerseelen",20251102,20251102,
"Ferien2","Weihnachtsferien",20251224,20260106,
"Ferien3","Semesterferien",20260216,20260222,
"Ferien4_2","Osterferien",20260328,20260406,
"Ferien4","Hl. Florian",20260504,20260504,
"26.10.","Nationalfeiertag",20251026,20251026,"F"
"27.10.","Herbstferien",20251027,20251027,"F"
"28.10.","Herbstferien",20251028,20251028,"F"
"29.10.","Herbstferien",20251029,20251029,"F"
"30.10.","Herbstferien",20251030,20251030,"F"
"31.10.","Herbstferien",20251031,20251031,"F"
"1.11.","Allerheiligen",20251101,20251101,"F"
"8.12.","Mariä Empfängnis",20251208,20251208,"F"
"1.5.","Staatsfeiertag",20260501,20260501,"F"
"14.5.","Christi Himmelfahrt",20260514,20260514,"F"
"24.5.","Pfingstsonntag",20260524,20260524,"F"
"25.5.","Pfingstmontag",20260525,20260525,"F"
"4.6.","Fronleichnam",20260604,20260604,"F"

308
MQTT_EVENT_PAYLOAD_GUIDE.md Normal file
View File

@@ -0,0 +1,308 @@
# MQTT Event Payload Guide
## Overview
This document describes the MQTT message structure used by the Infoscreen system to deliver event information from the scheduler to display clients. It covers best practices, payload formats, and versioning strategies.
## MQTT Topics
### Event Distribution
- **Topic**: `infoscreen/events/{group_id}`
- **Retained**: Yes
- **Format**: JSON array of event objects
- **Purpose**: Delivers active events to client groups
### Per-Client Configuration
- **Topic**: `infoscreen/{uuid}/group_id`
- **Retained**: Yes
- **Format**: Integer (group ID)
- **Purpose**: Assigns clients to groups
## Message Structure
### General Principles
1. **Type Safety**: Always include `event_type` to allow clients to parse appropriately
2. **Backward Compatibility**: Add new fields without removing old ones
3. **Extensibility**: Use nested objects for event-type-specific data
4. **UTC Timestamps**: All times in ISO 8601 format with timezone info
### Base Event Structure
Every event includes these common fields:
```json
{
"id": 123,
"title": "Event Title",
"start": "2025-10-19T09:00:00+00:00",
"end": "2025-10-19T09:30:00+00:00",
"group_id": 1,
"event_type": "presentation|website|webuntis|video|message|other",
"recurrence_rule": "FREQ=WEEKLY;BYDAY=MO,WE,FR" or null,
"recurrence_end": "2025-12-31T23:59:59+00:00" or null
}
```
### Event Type-Specific Payloads
#### Presentation Events
```json
{
"id": 123,
"event_type": "presentation",
"title": "Morning Announcements",
"start": "2025-10-19T09:00:00+00:00",
"end": "2025-10-19T09:30:00+00:00",
"group_id": 1,
"presentation": {
"type": "slideshow",
"files": [
{
"name": "slides.pdf",
"url": "http://server:8000/api/files/converted/abc123.pdf",
"checksum": null,
"size": null
}
],
"slide_interval": 10000,
"auto_advance": true,
"page_progress": true,
"auto_progress": true
}
}
```
**Fields**:
- `type`: Always "slideshow" for presentations
- `files`: Array of file objects with download URLs
- `slide_interval`: Milliseconds between slides (default: 5000)
- `auto_advance`: Whether to automatically advance slides
- `page_progress`: Show page number indicator
- `auto_progress`: Enable automatic progression
#### Website Events
```json
{
"id": 124,
"event_type": "website",
"title": "School Website",
"start": "2025-10-19T09:00:00+00:00",
"end": "2025-10-19T09:30:00+00:00",
"group_id": 1,
"website": {
"type": "browser",
"url": "https://example.com/page"
}
}
```
**Fields**:
- `type`: Always "browser" for website display
- `url`: Full URL to display in embedded browser
#### WebUntis Events
```json
{
"id": 125,
"event_type": "webuntis",
"title": "Schedule Display",
"start": "2025-10-19T09:00:00+00:00",
"end": "2025-10-19T09:30:00+00:00",
"group_id": 1,
"website": {
"type": "browser",
"url": "https://webuntis.example.com/schedule"
}
}
```
**Note**: WebUntis events use the same payload structure as website events. The URL is fetched from system settings (`webuntis_url`) rather than being specified per-event. Clients treat `webuntis` and `website` event types identically—both display a website.
#### Video Events
```json
{
"id": 126,
"event_type": "video",
"title": "Video Playback",
"start": "2025-10-19T09:00:00+00:00",
"end": "2025-10-19T09:30:00+00:00",
"group_id": 1,
"video": {
"type": "media",
"url": "http://server:8000/api/eventmedia/stream/123/video.mp4",
"autoplay": true,
"loop": false,
"volume": 0.8
}
}
```
**Fields**:
- `type`: Always "media" for video playback
- `url`: Video streaming URL with range request support
- `autoplay`: Whether to start playing automatically (default: true)
- `loop`: Whether to loop the video (default: false)
- `volume`: Playback volume from 0.0 to 1.0 (default: 0.8)
#### Message Events (Future)
```json
{
"id": 127,
"event_type": "message",
"title": "Important Announcement",
"start": "2025-10-19T09:00:00+00:00",
"end": "2025-10-19T09:30:00+00:00",
"group_id": 1,
"message": {
"type": "html",
"content": "<h1>Important</h1><p>Message content</p>",
"style": "default"
}
}
```
## Best Practices
### 1. Type-Based Parsing
Clients should:
1. Read the `event_type` field first
2. Switch/dispatch based on type
3. Parse type-specific nested objects (`presentation`, `website`, etc.)
```javascript
// Example client parsing
function parseEvent(event) {
switch (event.event_type) {
case 'presentation':
return handlePresentation(event.presentation);
case 'website':
case 'webuntis':
return handleWebsite(event.website);
case 'video':
return handleVideo(event.video);
// ...
}
}
```
### 2. Graceful Degradation
- Always provide fallback values for optional fields
- Validate URLs before attempting to load
- Handle missing or malformed data gracefully
### 3. Performance Optimization
- Cache downloaded presentation files
- Use checksums to avoid re-downloading unchanged content
- Preload resources before event start time
### 4. Time Handling
- Always parse ISO 8601 timestamps with timezone awareness
- Compare event start/end times in UTC
- Account for clock drift on embedded devices
### 5. Error Recovery
- Retry failed downloads with exponential backoff
- Log errors but continue operation
- Display fallback content if event data is invalid
## Message Flow
1. **Scheduler** queries active events from database
2. **Scheduler** formats events with type-specific payloads
3. **Scheduler** publishes JSON array to `infoscreen/events/{group_id}` (retained)
4. **Client** receives retained message on connect
5. **Client** parses events and schedules display
6. **Client** downloads resources (presentations, etc.)
7. **Client** displays events at scheduled times
## Versioning Strategy
### Adding New Event Types
1. Add enum value to `EventType` in `models/models.py`
2. Update scheduler's `format_event_with_media()` in `scheduler/db_utils.py`
3. Update events API in `server/routes/events.py`
4. Add icon mapping in `get_icon_for_type()`
5. Document payload structure in this guide
### Adding Fields to Existing Types
- **Safe**: Add new optional fields to nested objects
- **Unsafe**: Remove or rename existing fields
- **Migration**: Provide both old and new field names during transition
### Example: Adding a New Field
```json
{
"event_type": "presentation",
"presentation": {
"type": "slideshow",
"files": [...],
"slide_interval": 10000,
"transition_effect": "fade" // NEW FIELD (optional)
}
}
```
Old clients ignore unknown fields; new clients use enhanced features.
## Common Pitfalls
1. **Hardcoding Event Types**: Use `event_type` field, not assumptions
2. **Timezone Confusion**: Always use UTC internally
3. **Missing Error Handling**: Network failures, malformed URLs, etc.
4. **Resource Leaks**: Clean up downloaded files periodically
5. **Not Handling Recurrence**: Events may repeat; check `recurrence_rule`
## System Settings Integration
Some event types rely on system-wide settings rather than per-event configuration:
### WebUntis / Supplement Table URL
- **Setting Key**: `supplement_table_url`
- **API Endpoint**: `GET/POST /api/system-settings/supplement-table`
- **Usage**: Automatically applied when creating `webuntis` events
- **Default**: Empty string (must be configured by admin)
- **Description**: This URL is shared for both Vertretungsplan (supplement table) and WebUntis displays
### Presentation Defaults
- `presentation_interval`: Default slide interval (seconds)
- `presentation_page_progress`: Show page indicators by default
- `presentation_auto_progress`: Auto-advance by default
These are applied when creating new events but can be overridden per-event.
## Testing Recommendations
1. **Unit Tests**: Validate payload serialization/deserialization
2. **Integration Tests**: Full scheduler → MQTT → client flow
3. **Edge Cases**: Empty event lists, missing URLs, malformed data
4. **Performance Tests**: Large file downloads, many events
5. **Time Tests**: Events across midnight, timezone boundaries, DST
## Related Documentation
- `AUTH_SYSTEM.md` - Authentication and authorization
- `DATABASE_GUIDE.md` - Database schema and models
- `.github/copilot-instructions.md` - System architecture overview
- `scheduler/scheduler.py` - Event publishing implementation
- `scheduler/db_utils.py` - Event formatting logic
## Changelog
- **2025-10-19**: Initial documentation
- Documented base event structure
- Added presentation and website/webuntis payload formats
- Established best practices and versioning strategy

92
Makefile Normal file
View File

@@ -0,0 +1,92 @@
# Makefile for infoscreen_2025
# Usage: run `make help` to see available targets.
# Default compose files
COMPOSE_FILES=-f docker-compose.yml -f docker-compose.override.yml
COMPOSE=docker compose $(COMPOSE_FILES)
# Registry and image names (adjust if needed)
REGISTRY=ghcr.io/robbstarkaustria
API_IMAGE=$(REGISTRY)/infoscreen-api:latest
DASH_IMAGE=$(REGISTRY)/infoscreen-dashboard:latest
LISTENER_IMAGE=$(REGISTRY)/infoscreen-listener:latest
SCHED_IMAGE=$(REGISTRY)/infoscreen-scheduler:latest
.PHONY: help
help:
@echo "Available targets:"
@echo " up - Start dev stack (compose + override)"
@echo " down - Stop dev stack"
@echo " logs - Tail logs for all services"
@echo " logs-% - Tail logs for a specific service (e.g., make logs-server)"
@echo " build - Build all images locally"
@echo " push - Push built images to GHCR"
@echo " pull-prod - Pull prod images from GHCR"
@echo " up-prod - Start prod stack (docker-compose.prod.yml)"
@echo " down-prod - Stop prod stack"
@echo " health - Quick health checks"
@echo " fix-perms - Recursively chown workspace to current user"
# ---------- Development stack ----------
.PHONY: up
up: ## Start dev stack
$(COMPOSE) up -d --build
.PHONY: down
down: ## Stop dev stack
$(COMPOSE) down
.PHONY: logs
logs: ## Tail logs for all services
$(COMPOSE) logs -f
.PHONY: logs-%
logs-%: ## Tail logs for a specific service, e.g. `make logs-server`
$(COMPOSE) logs -f $*
# ---------- Images: build/push ----------
.PHONY: build
build: ## Build all images locally
docker build -f server/Dockerfile -t $(API_IMAGE) .
docker build -f dashboard/Dockerfile -t $(DASH_IMAGE) .
docker build -f listener/Dockerfile -t $(LISTENER_IMAGE) .
docker build -f scheduler/Dockerfile -t $(SCHED_IMAGE) .
.PHONY: push
push: ## Push all images to GHCR
docker push $(API_IMAGE)
docker push $(DASH_IMAGE)
docker push $(LISTENER_IMAGE)
docker push $(SCHED_IMAGE)
# ---------- Production stack ----------
PROD_COMPOSE=docker compose -f docker-compose.prod.yml
.PHONY: pull-prod
pull-prod: ## Pull prod images
$(PROD_COMPOSE) pull
.PHONY: up-prod
up-prod: ## Start prod stack
$(PROD_COMPOSE) up -d
.PHONY: down-prod
down-prod: ## Stop prod stack
$(PROD_COMPOSE) down
# ---------- Health ----------
.PHONY: health
health: ## Quick health checks
@echo "API health:" && curl -fsS http://localhost:8000/health || true
@echo "Dashboard (dev):" && curl -fsS http://localhost:5173/ || true
@echo "MQTT TCP 1883:" && nc -z localhost 1883 && echo OK || echo FAIL
@echo "MQTT WS 9001:" && nc -z localhost 9001 && echo OK || echo FAIL
# ---------- Permissions ----------
.PHONY: fix-perms
fix-perms:
@echo "Fixing ownership to current user recursively (may prompt for sudo password)..."
sudo chown -R $$(id -u):$$(id -g) .
@echo "Done. Consider adding UID and GID to your .env to prevent future root-owned files:"
@echo " echo UID=$$(id -u) >> .env && echo GID=$$(id -g) >> .env"

635
README.md Normal file
View File

@@ -0,0 +1,635 @@
# Infoscreen 2025
[![Docker](https://img.shields.io/badge/Docker-Multi--Service-blue?logo=docker)](https://www.docker.com/)
[![React](https://img.shields.io/badge/React-19.1.0-61DAFB?logo=react)](https://reactjs.org/)
[![Flask](https://img.shields.io/badge/Flask-REST_API-green?logo=flask)](https://flask.palletsprojects.com/)
[![MariaDB](https://img.shields.io/badge/MariaDB-11.2-003545?logo=mariadb)](https://mariadb.org/)
[![MQTT](https://img.shields.io/badge/MQTT-Eclipse_Mosquitto-purple)](https://mosquitto.org/)
A comprehensive multi-service digital signage solution for educational institutions, featuring client management, event scheduling, presentation conversion, and real-time MQTT communication.
## 🏗️ Architecture Overview
```
┌───────────────┐ ┌──────────────────────────┐ ┌───────────────┐
│ Dashboard │◄──────►│ API Server │◄──────►│ Worker │
│ (React/Vite) │ │ (Flask) │ │ (Conversions) │
└───────────────┘ └──────────────────────────┘ └───────────────┘
▲ ▲
│ │
┌───────────────┐ │
│ MariaDB │ │
│ (Database) │ │
└───────────────┘ │
│ direct commands/results
Reads events ▲ Interacts with API ▲
│ ┌────┘
┌───────────────┐ │ │ ┌───────────────┐
│ Scheduler │──┘ └──│ Listener │
│ (Events) │ │ (MQTT Client) │
└───────────────┘ └───────────────┘
│ Publishes events ▲ Consumes discovery/heartbeats
▼ │ and forwards to API
┌─────────────────┐◄─────────────────────────────────────────────────────────────────┘
│ MQTT Broker │────────────────────────────────────────────────────────► Clients
│ (Mosquitto) │ Sends events to clients (send discovery/heartbeats)
└─────────────────┘
```
Data flow summary:
- Listener: consumes discovery and heartbeat messages from the MQTT Broker and updates the API Server (client registration/heartbeats).
- Scheduler: reads events from the API Server and publishes only currently active content to the MQTT Broker (retained topics per group). When a group has no active events, the scheduler clears its retained topic by publishing an empty list. All time comparisons are done in UTC; any naive timestamps are normalized.
- Clients: send discovery/heartbeat via the MQTT Broker (handled by the Listener) and receive content from the Scheduler via MQTT.
- Worker: receives conversion commands directly from the API Server and reports results/status back to the API (no MQTT involved).
- MariaDB: is accessed exclusively by the API Server. The Dashboard never talks to the database directly; it only communicates with the API.
## 🌟 Key Features
- **User Management**: Comprehensive role-based access control (user → editor → admin → superadmin)
- Admin panel for user CRUD operations with audit tracking
- Self-service password change available to all users
- Audit trail: login times, password changes, deactivation history
- Soft-delete by default, hard-delete superadmin-only
- Modern React-based web interface with Syncfusion components
- Real-time client monitoring and group management
- Event scheduling with academic period support
- Media management with presentation conversion
- Holiday calendar integration
- Visual indicators: TentTree icon next to the main event icon marks events that skip holidays (icon color: black)
- **Event Deletion**: All event types (single, single-in-series, entire series) are handled with custom dialogs. The frontend intercepts Syncfusion's built-in RecurrenceAlert and DeleteAlert popups to provide a unified, user-friendly deletion flow:
- Single (non-recurring) event: deleted directly after confirmation.
- Single occurrence of a recurring series: user can delete just that instance.
- Entire recurring series: user can delete all occurrences after a final custom confirmation dialog.
- Detached occurrences (edited/broken out): treated as single events.
### 🎯 **Event System**
- **Presentations**: PowerPoint/LibreOffice → PDF conversion via Gotenberg
- **Websites**: URL-based content display
- **Videos**: Media file streaming with per-event playback settings (`autoplay`, `loop`, `volume`, `muted`); system-wide defaults configurable under Settings → Events → Videos
- **Messages**: Text announcements
- **WebUntis**: Educational schedule integration
- Uses the system-wide Vertretungsplan/Supplement-Table URL (`supplement_table_url`) configured under Settings → Events. No separate per-event URL is required; WebUntis events display the same as Website events.
- **Recurrence & Holidays**: Recurring events can be configured to skip holidays. The backend generates EXDATEs (RecurrenceException) for holiday occurrences using RFC 5545 timestamps (yyyyMMddTHHmmssZ), so the calendar never shows those instances. The scheduler queries a 7-day window to expand recurring events and applies event exceptions, but only publishes events that are active at the current time (UTC). The "Termine an Ferientagen erlauben" toggle does not affect these events.
- **Single Occurrence Editing**: Users can edit individual occurrences of recurring events without affecting the master series. The system provides a confirmation dialog to choose between editing a single occurrence or the entire series.
### 🏫 **Academic Period Management**
- Support for school years, semesters, and trimesters
- Austrian school system integration
- Holiday calendar synchronization
- Period-based event organization
### 📡 **Real-time Communication**
- MQTT-based client discovery and heartbeat monitoring
- Retained topics for reliable state synchronization
- WebSocket support for browser clients
- Automatic client group assignment
### 🔄 **Background Processing**
- Redis-based job queues for presentation conversion
- Gotenberg integration for LibreOffice/PowerPoint processing
- Asynchronous file processing with status tracking
- RQ (Redis Queue) worker management
## 🚀 Quick Start
### Prerequisites
- Docker & Docker Compose
- Git
- SSL certificates (for production)
### Development Setup
1. **Clone the repository**
```bash
git clone https://github.com/RobbStarkAustria/infoscreen_2025.git
cd infoscreen_2025
```
2. **Environment Configuration**
```bash
cp .env.example .env
# Edit .env with your configuration
```
3. **Start the development stack**
```bash
make up
# or: docker compose up -d --build
```
Before running the dashboard dev server you may need to install Syncfusion packages used by the UI. Example (install only the packages you use):
```bash
# from the repository root
cd dashboard
npm install --save @syncfusion/ej2-react-splitbuttons @syncfusion/ej2-splitbuttons \
@syncfusion/ej2-react-grids @syncfusion/ej2-react-schedule @syncfusion/ej2-react-filemanager
```
License note: Syncfusion distributes components under a commercial license with a free community license for qualifying users. Verify licensing for your organization before using Syncfusion in production and document any license keys or compliance steps in this repository.
4. **Initialize the database (first run only)**
```bash
# One-shot: runs all Alembic migrations, creates default admin/group, and seeds academic periods
python server/initialize_database.py
```
5. **Access the services**
- Dashboard: http://localhost:5173
- API: http://localhost:8000
- Database: localhost:3306
- MQTT: localhost:1883 (WebSocket: 9001)
### Production Deployment
1. **Build and push images**
```bash
make build
make push
```
2. **Deploy on server**
```bash
make pull-prod
make up-prod
```
For detailed deployment instructions, see:
- [Debian Deployment Guide](deployment-debian.md)
- [Ubuntu Deployment Guide](deployment-ubuntu.md)
## 🛠️ Services
### 🖥️ **Dashboard** (`dashboard/`)
- **Technology**: React 19 + TypeScript + Vite
- **UI Framework**: Syncfusion components (Material 3 theme)
- **Styling**: Centralized Syncfusion Material 3 CSS imports in `dashboard/src/main.tsx`
- **Features**: Responsive design, real-time updates, file management
- **Port**: 5173 (dev), served via Nginx (prod)
- **Data access**: No direct database connection; communicates with the API Server only via HTTP.
- **Dev proxy tip**: In development, use relative paths like `/api/...` in the frontend to route through Vite's proxy to the API. Avoid absolute URLs with an extra `/api` segment to prevent CORS or double-path issues.
### 🔧 **API Server** (`server/`)
- **Technology**: Flask + SQLAlchemy + Alembic
- **Database**: MariaDB with timezone-aware timestamps
- **Features**: RESTful API, file uploads, MQTT integration
- Recurrence/holidays: returns only master events with `RecurrenceRule` and `RecurrenceException` (EXDATEs) so clients render recurrences and skip holiday instances reliably.
- Recurring events are only deactivated after their recurrence_end (UNTIL); non-recurring events deactivate after their end time. Event exceptions are respected and rendered in scheduler output.
- Single occurrence detach: `POST /api/events/<id>/occurrences/<date>/detach` creates standalone events from recurring series without modifying the master event.
- **Port**: 8000
- **Health Check**: `/health`
### 👂 **Listener** (`listener/`)
### ⏰ **Scheduler** (`scheduler/`)
**Technology**: Python + SQLAlchemy
**Purpose**: Event publishing, group-based content distribution
**Features**:
- Queries a future window (default: 7 days) to expand recurring events
- Expands recurrences using RFC 5545 rules
- Applies event exceptions (skipped dates, detached occurrences)
- Only deactivates recurring events after their recurrence_end (UNTIL)
- Publishes only currently active events to MQTT (per group)
- Clears retained topics by publishing an empty list when a group has no active events
- Normalizes naive timestamps and compares times in UTC
- Logging is concise; conversion lookups are cached and logged only once per media
### 🔄 **Worker** (Conversion Service)
- **Technology**: RQ (Redis Queue) + Gotenberg
- **Purpose**: Background presentation conversion
- **Features**: PPT/PPTX/ODP → PDF conversion, status tracking
### 🗄️ **Database** (MariaDB 11.2)
- **Features**: Health checks, automatic initialization
- **Migrations**: Alembic-based schema management
- **Timezone**: UTC-aware timestamps
### 📡 **MQTT Broker** (Eclipse Mosquitto 2.0.21)
- **Features**: WebSocket support, health monitoring
- **Topics**:
- `infoscreen/discovery` - Client registration
- `infoscreen/{uuid}/heartbeat` - Client alive status
- `infoscreen/events/{group_id}` - Event distribution
## 🔗 Scheduler Event Payloads
- Presentations include a `presentation` object with `files`, `slide_interval`, `page_progress`, and `auto_progress`.
- Website and WebUntis events share a unified payload:
- `website`: `{ "type": "browser", "url": "https://..." }`
- The `event_type` field remains specific (e.g., `presentation`, `website`, `webuntis`) so clients can dispatch appropriately; however, `website` and `webuntis` should be handled identically in clients.
- Videos include a `video` payload with a stream URL and playback flags:
- `video`: includes `url` (streaming endpoint) and `autoplay`, `loop`, `volume`, `muted`
- Streaming endpoint supports byte-range requests (206) to enable seeking: `/api/eventmedia/stream/<media_id>/<filename>`
- Server-side UTC: All backend comparisons are performed in UTC; API returns ISO strings without `Z`. Frontend appends `Z` before parsing.
## Recent changes since last commit
- Video / Streaming support: Added end-to-end support for video events. The API and dashboard now allow creating `video` events referencing uploaded media. The server exposes a range-capable streaming endpoint at `/api/eventmedia/stream/<media_id>/<filename>` so clients can seek during playback.
- Scheduler metadata: Scheduler now performs a best-effort HEAD probe for video stream URLs and includes basic metadata in the retained MQTT payload: `mime_type`, `size` (bytes) and `accept_ranges` (bool). Placeholders for richer metadata (`duration`, `resolution`, `bitrate`, `qualities`, `thumbnails`, `checksum`) are emitted as null/empty until a background worker fills them.
- Dashboard & uploads: The dashboard's FileManager upload limits were increased (to support Full-HD uploads) and client-side validation enforces a maximum video length (10 minutes). The event modal exposes playback flags (`autoplay`, `loop`, `volume`, `muted`) and initializes them from system defaults for new events.
- DB model & API: `Event` includes `muted` in addition to `autoplay`, `loop`, and `volume`; endpoints accept, persist, and return these fields for video events. Events reference uploaded media via `event_media_id`.
- Settings UI: Settings page refactored to nested tabs; added Events → Videos defaults (autoplay, loop, volume, mute) backed by system settings keys (`video_autoplay`, `video_loop`, `video_volume`, `video_muted`).
- Academic Calendar UI: Merged “School Holidays Import” and “List” into a single “📥 Import & Liste” tab; nested tab selection is persisted with controlled `selectedItem` state to avoid jumps.
These changes are designed to be safe if metadata extraction or probes fail — clients should still attempt playback using the provided `url` and fall back to requesting/resolving richer metadata when available.
See `MQTT_EVENT_PAYLOAD_GUIDE.md` for details.
- `infoscreen/{uuid}/group_id` - Client group assignment
## 🧩 Developer Environment Notes (Dev Container)
- Extensions: UI-only `Dev Containers` runs on the host UI; not installed inside the container to avoid reinstallation loops. See `/.devcontainer/devcontainer.json` (`remote.extensionKind`).
- Installs: Dashboard uses `npm ci` on `postCreateCommand` for reproducible installs.
- Aliases: `postStartCommand` appends shell aliases idempotently to prevent duplicates across restarts.
## 📦 Versioning
- Unified app version: Use a single SemVer for the product (e.g., `2025.1.0-beta.3`) — simplest for users and release management.
- Pre-releases: Use identifiers like `-alpha.N`, `-beta.N`, `-rc.N` for stage tracking.
- Build metadata: Optionally include component build info (non-ordering) e.g., `+api.abcd123,dash.efgh456,sch.jkl789,wkr.mno012`.
- Component traceability: Document component SHAs or image tags under each TECH-CHANGELOG release entry rather than exposing separate user-facing versions.
- Hotfixes: For backend-only fixes, prefer a patch bump or pre-release increment, and record component metadata under the unified version.
## 📁 Project Structure
```
infoscreen_2025/
├── dashboard/ # React frontend
│ ├── src/ # React components and logic
│ ├── public/ # Static assets
│ └── Dockerfile # Production build
├── server/ # Flask API backend
│ ├── routes/ # API endpoints
│ ├── alembic/ # Database migrations
│ ├── media/ # File storage
│ ├── initialize_database.py # All-in-one DB initialization (dev)
│ └── worker.py # Background jobs
├── listener/ # MQTT listener service
├── scheduler/ # Event scheduling service
├── models/ # Shared database models
├── mosquitto/ # MQTT broker configuration
├── certs/ # SSL certificates
├── docker-compose.yml # Development setup
├── docker-compose.prod.yml # Production setup
└── Makefile # Development shortcuts
```
## 🔧 Development
### Available Commands
```bash
# Development
make up # Start dev stack
make down # Stop dev stack
make logs # View all logs
make logs-server # View specific service logs
# Building & Deployment
make build # Build all images
make push # Push to registry
make pull-prod # Pull production images
make up-prod # Start production stack
# Maintenance
make health # Health checks
make fix-perms # Fix file permissions
```
### Database Management
```bash
# One-shot initialization (schema + defaults + academic periods)
python server/initialize_database.py
# Access database directly
docker exec -it infoscreen-db mysql -u${DB_USER} -p${DB_PASSWORD} ${DB_NAME}
# Run migrations
docker exec -it infoscreen-api alembic upgrade head
# Initialize academic periods (Austrian school system)
docker exec -it infoscreen-api python init_academic_periods.py
```
### MQTT Testing
```bash
# Subscribe to all topics
mosquitto_sub -h localhost -t "infoscreen/#" -v
# Publish test message
mosquitto_pub -h localhost -t "infoscreen/test" -m "Hello World"
# Monitor client heartbeats
mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
```
## 🌐 API Endpoints
### Core Resources
- `GET /api/clients` - List all registered clients
- `PUT /api/clients/{uuid}/group` - Assign client to group
- `GET /api/groups` - List client groups with alive status
- `GET /api/groups/order` - Get saved group display order
- `POST /api/groups/order` - Save group display order (array of group IDs)
- `GET /api/events` - List events with filtering
- `POST /api/events` - Create new event
- `POST /api/events/{id}/occurrences/{date}/detach` - Detach single occurrence from recurring series
- `GET /api/academic_periods` - List academic periods
- `POST /api/academic_periods/active` - Set active period
### File Management
- `POST /api/files` - Upload media files
- `GET /api/files/{path}` - Download files
- `GET /api/files/converted/{path}` - Download converted PDFs
- `POST /api/conversions/{media_id}/pdf` - Request conversion
- `GET /api/conversions/{media_id}/status` - Check conversion status
- `GET /api/eventmedia/stream/<media_id>/<filename>` - Stream media with byte-range support (206) for seeking
- `POST /api/clients/{uuid}/screenshot` - Upload screenshot for client (base64 JPEG)
- **Screenshot retention:** Only the latest and last 20 timestamped screenshots per client are stored on the server. Older screenshots are automatically deleted.
### System Settings
- `GET /api/system-settings` - List all system settings (admin+)
- `GET /api/system-settings/{key}` - Get a specific setting (admin+)
- `POST /api/system-settings/{key}` - Create or update a setting (admin+)
- `DELETE /api/system-settings/{key}` - Delete a setting (admin+)
- `GET /api/system-settings/supplement-table` - Get WebUntis/Vertretungsplan settings (enabled, url)
- `POST /api/system-settings/supplement-table` - Update WebUntis/Vertretungsplan settings
- Presentation defaults stored as keys:
- `presentation_interval` (seconds, default "10")
- `presentation_page_progress` ("true"/"false", default "true")
- `presentation_auto_progress` ("true"/"false", default "true")
- Video defaults stored as keys:
- `video_autoplay` ("true"/"false", default "true")
- `video_loop` ("true"/"false", default "true")
- `video_volume` (0.01.0, default "0.8")
- `video_muted` ("true"/"false", default "false")
### User Management (Admin+)
- `GET /api/users` - List all users (role-filtered by user's role)
- `POST /api/users` - Create new user with username, password (min 6 chars), role, and status
- `GET /api/users/<id>` - Get user details including audit information (login times, password changes, deactivation)
- `PUT /api/users/<id>` - Update user (cannot change own role or account status)
- `PUT /api/users/<id>/password` - Admin password reset (cannot reset own password this way; use `/api/auth/change-password` instead)
- `DELETE /api/users/<id>` - Delete user permanently (superadmin only; cannot delete self)
### Authentication
- `POST /api/auth/login` - User login (tracks last login time and failed attempts)
- `POST /api/auth/logout` - User logout
- `PUT /api/auth/change-password` - Self-service password change (all authenticated users; requires current password verification)
### Health & Monitoring
- `GET /health` - Service health check
- `GET /api/screenshots/{uuid}.jpg` - Client screenshots
## 🎨 Frontend Features
### 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.
### Recurrence & holidays
- Recurrence is handled natively by Syncfusion. The API returns master events with `RecurrenceRule` and `RecurrenceException` (EXDATE) in RFC 5545 format (yyyyMMddTHHmmssZ, UTC) so the Scheduler excludes holiday instances reliably.
- Events with "skip holidays" display a TentTree icon next to the main event icon (icon color: black). The Schedulers native lower-right recurrence badge indicates series membership.
- Single occurrence editing: Users can edit either a single occurrence or the entire series. The UI persists changes using `onActionCompleted (requestType='eventChanged')`:
- Single occurrence → `POST /api/events/<id>/occurrences/<date>/detach` (creates standalone event and adds EXDATE to master)
- Series/single event → `PUT /api/events/<id>`
### Syncfusion Components Used (Material 3)
- **Schedule**: Event calendar with drag-drop support
- **Grid**: Data tables with filtering and sorting
- **DropDownList**: Group and period selectors
- **FileManager**: Media upload and organization
- **Kanban**: Task management views
- **Notifications**: Toast messages and alerts
- **Pager**: Used on Programinfo changelog for pagination
- **Cards (layouts)**: Programinfo sections styled with Syncfusion card classes
- **SplitButtons**: Header user menu (top-right) using Syncfusion DropDownButton to show current user and role, with actions "Passwort ändern", "Profil", and "Abmelden".
### Pages Overview
- **Dashboard**: Card-based overview of all Raumgruppen (room groups) with real-time status monitoring. Features include:
- Global statistics: total infoscreens, online/offline counts, warning groups
- Filter buttons: All / Online / Offline / Warnings with dynamic counts
- Per-group cards showing currently active event (title, type, date/time in local timezone)
- Health bar with online/offline ratio and color-coded status
- Expandable client list with last alive timestamps
- Bulk restart button for offline clients
- Auto-refresh every 15 seconds; manual refresh button available
- **Clients**: Device management and monitoring
- **Groups**: Client group organization
- **Events**: Schedule management
- **Media**: File upload and conversion
- **Users**: Comprehensive user management (admin+ only in menu)
- Full CRUD interface with sortable GridComponent (20 per page)
- Statistics cards: total, active, inactive user counts
- Create, edit, delete, and password reset dialogs
- User details modal showing audit information (login times, password changes, deactivation)
- Role badges with color coding (user: gray, editor: blue, admin: green, superadmin: red)
- Self-protection: cannot modify own account (cannot change role/status or delete self)
- Superadmin-only hard delete; other users soft-deactivate
- **Settings**: Central configuration (tabbed)
- 📅 Academic Calendar (all users):
- 📥 Import & Liste: CSV/TXT import combined with holidays list
- 🗂️ Perioden: Academic Periods (set active period)
- 🖥️ Display & Clients (admin+): Defaults placeholders and quick links to Clients/Groups
- 🎬 Media & Files (admin+): Upload settings placeholders and Conversion status overview
- 🗓️ Events (admin+): WebUntis/Vertretungsplan URL enable/disable, save, preview. Presentations: general defaults for slideshow interval, page-progress, and auto-progress; persisted via `/api/system-settings` keys and applied on create in the event modal. Videos: system-wide defaults for `autoplay`, `loop`, `volume`, and `muted`; persisted via `/api/system-settings` keys and applied on create in the event modal.
- ⚙️ System (superadmin): Organization info and Advanced configuration placeholders
- **Holidays**: Academic calendar management
- **Ressourcen**: Timeline view of active events across all room groups
- Parallel timeline display showing all groups and their current events simultaneously
- Compact visualization: 65px row height per group with color-coded event bars
- Day and week views for flexible time range inspection
- Customizable group ordering with visual drag controls (order persisted to backend)
- Real-time event status: shows currently running events with type, title, and time window
- Filters out unassigned groups for focused view
- Resource-based Syncfusion timeline scheduler with resize and drag-drop support
- **Program info**: Version, build info, tech stack and paginated changelog (reads `dashboard/public/program-info.json`)
## 🔒 Security & Authentication
- **Role-Based Access Control (RBAC)**: 4-tier hierarchy (user → editor → admin → superadmin) with privilege escalation protection
- Admin cannot see, manage, or create superadmin accounts
- Admin can create and manage user/editor/admin roles only
- Superadmin can manage all roles including other superadmins
- Role-gated menu visibility: users only see menu items they have permission for
- **Account Management**:
- Soft-delete by default (deactivated_at, deactivated_by timestamps)
- Hard-delete superadmin-only (permanent removal from database)
- Self-account protections: cannot change own role/status, cannot delete self via admin panel
- Self-service password change available to all authenticated users (requires current password verification)
- Admin password reset available for other users (no current password required)
- **Audit Tracking**: All user accounts track login times, password changes, failed login attempts, and deactivation history
- **Environment Variables**: Sensitive data via `.env`
- **SSL/TLS**: HTTPS support with custom certificates
- **MQTT Security**: Username/password authentication
- **Database**: Parameterized queries, connection pooling
- **File Uploads**: Type validation, size limits
- **CORS**: Configured for production deployment
## 📊 Monitoring & Logging
### Health Checks
- Database: Connection and initialization status
- MQTT: Pub/sub functionality test
- Dashboard: Nginx availability
- **Scheduler**: Logging is concise; conversion lookups are cached and logged only once per media.
- Dashboard: Nginx availability
### Logging Strategy
- **Development**: Docker Compose logs with service prefixes
- **Production**: Centralized logging via Docker log drivers
- **MQTT**: Message-level debugging available
- **Database**: Query logging in development mode
## 🌍 Deployment Options
### Development
- **Hot Reload**: Vite dev server + Flask debug mode
- **Volume Mounts**: Live code editing
- **Debug Ports**: Python debugger support (port 5678)
- **Local Certificates**: Self-signed SSL for testing
### Production
- **Optimized Builds**: Multi-stage Dockerfiles
- **Reverse Proxy**: Nginx with SSL termination
- **Health Monitoring**: Comprehensive healthchecks
- **Registry**: GitHub Container Registry integration
- **Scaling**: Docker Compose for single-node deployment
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/amazing-feature`
3. Commit your changes: `git commit -m 'Add amazing feature'`
4. Push to the branch: `git push origin feature/amazing-feature`
5. Open a Pull Request
### Development Guidelines
- Follow existing code patterns and naming conventions
- Add appropriate tests for new features
- Update documentation for API changes
- Use TypeScript for frontend development
- Follow Python PEP 8 for backend code
## 📋 Requirements
### System Requirements
- **CPU**: 2+ cores recommended
- **RAM**: 4GB minimum, 8GB recommended
- **Storage**: 20GB+ for media files and database
- **Network**: Reliable internet for client communication
### Software Dependencies
- Docker 24.0+
- Docker Compose 2.0+
- Git 2.30+
- Modern web browser (Chrome, Firefox, Safari, Edge)
## 🐛 Troubleshooting
### Common Issues
**Services won't start**
```bash
# Check service health
make health
# View specific service logs
make logs-server
make logs-db
```
**Database connection errors**
```bash
# Verify database is running
docker exec -it infoscreen-db mysqladmin ping
# Check credentials in .env file
# Restart dependent services
```
**MQTT communication issues**
**Vite import-analysis errors (Syncfusion splitbuttons)**
```bash
# Symptom
[plugin:vite:import-analysis] Failed to resolve import "@syncfusion/ej2-react-splitbuttons"
# Fix
# 1) Ensure dependencies are added in dashboard/package.json:
# - @syncfusion/ej2-react-splitbuttons, @syncfusion/ej2-splitbuttons
# 2) In dashboard/vite.config.ts, add to optimizeDeps.include:
# '@syncfusion/ej2-react-splitbuttons', '@syncfusion/ej2-splitbuttons'
# 3) If dashboard uses a named node_modules volume, recreate it so npm ci runs inside the container:
docker compose rm -sf dashboard
docker volume rm <project>_dashboard-node-modules <project>_dashboard-vite-cache || true
docker compose up -d --build dashboard
```
```bash
# Test MQTT broker
mosquitto_pub -h localhost -t test -m "hello"
# Check client certificates and credentials
# Verify firewall settings for ports 1883/9001
```
**File conversion problems**
```bash
# Check Gotenberg service
curl http://localhost:3000/health
# Monitor worker logs
make logs-worker
# Check Redis queue status
docker exec -it infoscreen-redis redis-cli LLEN conversions
```
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 🙏 Acknowledgments
- **Syncfusion**: UI components for React dashboard
- **Eclipse Mosquitto**: MQTT broker implementation
- **Gotenberg**: Document conversion service
- **MariaDB**: Reliable database engine
- **Flask**: Python web framework
- **React**: Frontend user interface library
---
For detailed technical documentation, deployment guides, and API specifications, please refer to the additional documentation files in this repository.
Notes:
- Tailwind CSS was removed. Styling is managed via Syncfusion Material 3 theme imports in `dashboard/src/main.tsx`.
## 🧭 Changelog Style Guide
When adding entries to `dashboard/public/program-info.json` (displayed on the Program info page):
- Structure per release
- `version` (e.g., `2025.1.0-alpha.8`)
- `date` in `YYYY-MM-DD` (ISO format)
- `changes`: array of short bullet strings
- Categories (Keep a Changelog inspired)
- Prefer starting bullets with an implicit category or an emoji, e.g.:
- Added (🆕/✨), Changed (🔧/🛠️), Fixed (🐛/✅), Removed (🗑️), Security (🔒), Deprecated (⚠️)
- Writing rules
- Keep bullets concise (ideally one line) and user-facing; avoid internal IDs or jargon
- Put the affected area first when helpful (e.g., “UI: …”, “API: …”, “Scheduler: …”)
- Highlight breaking changes with “BREAKING:”
- Prefer German wording consistently; dates are localized at runtime for display
- Ordering and size
- Newest release first in the array
- Aim for ≤ 810 bullets per release; group or summarize if longer
- JSON hygiene
- Valid JSON only (no trailing commas); escape quotes as needed
- One release object per version; do not modify historical entries unless to correct typos
The Program info page paginates older entries (default page size 5). Keep highlights at the top of each release for scanability.

View File

@@ -0,0 +1,94 @@
# Screenshot Transmission Implementation
## Overview
Clients send screenshots via MQTT during heartbeat intervals. The listener service receives these screenshots and forwards them to the server API for storage.
## Architecture
### MQTT Topic
- **Topic**: `infoscreen/{uuid}/screenshot`
- **Payload Format**:
- Raw binary image data (JPEG/PNG), OR
- JSON with base64-encoded image: `{"image": "<base64-string>"}`
### Components
#### 1. Listener Service (`listener/listener.py`)
- **Subscribes to**: `infoscreen/+/screenshot`
- **Function**: `handle_screenshot(uuid, payload)`
- Detects payload format (binary or JSON)
- Converts binary to base64 if needed
- Forwards to API via HTTP POST
#### 2. Server API (`server/routes/clients.py`)
- **Endpoint**: `POST /api/clients/<uuid>/screenshot`
- **Authentication**: No authentication required (internal service call)
- **Accepts**:
- JSON: `{"image": "<base64-encoded-image>"}`
- Binary: raw image data
- **Storage**:
- Saves to `server/screenshots/{uuid}_{timestamp}.jpg` (with timestamp)
- Saves to `server/screenshots/{uuid}.jpg` (latest, for quick retrieval)
#### 3. Retrieval (`server/wsgi.py`)
- **Endpoint**: `GET /screenshots/<uuid>`
- **Returns**: Latest screenshot for the given client UUID
- **Nginx**: Exposes `/screenshots/{uuid}.jpg` in production
## Unified Identification Method
Screenshots are identified by **client UUID**:
- Each client has a unique UUID stored in the `clients` table
- Screenshots are stored as `{uuid}.jpg` (latest) and `{uuid}_{timestamp}.jpg` (historical)
- The API endpoint requires UUID validation against the database
- Retrieval is done via `GET /screenshots/<uuid>` which returns the latest screenshot
## Data Flow
```
Client → MQTT (infoscreen/{uuid}/screenshot)
Listener Service
↓ (validates client exists)
↓ (converts binary → base64 if needed)
API POST /api/clients/{uuid}/screenshot
↓ (validates client UUID)
↓ (decodes base64 → binary)
Filesystem: server/screenshots/{uuid}.jpg
Dashboard/Nginx: GET /screenshots/{uuid}
```
## Configuration
### Environment Variables
- **Listener**: `API_BASE_URL` (default: `http://server:8000`)
- **Server**: Screenshots stored in `server/screenshots/` directory
### Dependencies
- Listener: Added `requests>=2.31.0` to `listener/requirements.txt`
- Server: Uses built-in Flask and base64 libraries
## Error Handling
- **Client Not Found**: Returns 404 if UUID doesn't exist in database
- **Invalid Payload**: Returns 400 if image data is missing or invalid
- **API Timeout**: Listener logs error and continues (timeout: 10s)
- **Network Errors**: Listener logs and continues operation
## Security Considerations
- Screenshot endpoint does not require authentication (internal service-to-service)
- Client UUID must exist in database before screenshot is accepted
- Base64 encoding prevents binary data issues in JSON transport
- File size is tracked and logged for monitoring
## Future Enhancements
- Add screenshot retention policy (auto-delete old timestamped files)
- Add compression before transmission
- Add screenshot quality settings
- Add authentication between listener and API
- Add screenshot history API endpoint

159
SUPERADMIN_SETUP.md Normal file
View File

@@ -0,0 +1,159 @@
# Superadmin User Setup
This document describes the superadmin user initialization system implemented in the infoscreen_2025 project.
## Overview
The system automatically creates a default superadmin user during database initialization if one doesn't already exist. This ensures there's always an initial administrator account available for system setup and configuration.
## Implementation Details
### Files Modified
1. **`server/init_defaults.py`**
- Updated to create a superadmin user with role `superadmin` (from `UserRole` enum)
- Password is securely hashed using bcrypt
- Only creates user if not already present in the database
- Provides clear feedback about creation status
2. **`.env.example`**
- Updated with new environment variables
- Includes documentation for required variables
3. **`docker-compose.yml`** and **`docker-compose.prod.yml`**
- Added environment variable passthrough for superadmin credentials
4. **`userrole-management.md`**
- Marked stage 1, step 2 as completed
## Environment Variables
### Required
- **`DEFAULT_SUPERADMIN_PASSWORD`**: The password for the superadmin user
- **IMPORTANT**: This must be set for the superadmin user to be created
- Should be a strong, secure password
- If not set, the script will skip superadmin creation with a warning
### Optional
- **`DEFAULT_SUPERADMIN_USERNAME`**: The username for the superadmin user
- Default: `superadmin`
- Can be customized if needed
## Setup Instructions
### Development
1. Copy `.env.example` to `.env`:
```bash
cp .env.example .env
```
2. Edit `.env` and set a secure password:
```bash
DEFAULT_SUPERADMIN_USERNAME=superadmin
DEFAULT_SUPERADMIN_PASSWORD=your_secure_password_here
```
3. Run the initialization (happens automatically on container startup):
```bash
docker-compose up -d
```
### Production
1. Set environment variables in your deployment configuration:
```bash
export DEFAULT_SUPERADMIN_USERNAME=superadmin
export DEFAULT_SUPERADMIN_PASSWORD=your_very_secure_password
```
2. Deploy with docker-compose:
```bash
docker-compose -f docker-compose.prod.yml up -d
```
## Behavior
The `init_defaults.py` script runs automatically during container initialization and:
1. Checks if the username already exists in the database
2. If it exists: Prints an info message and skips creation
3. If it doesn't exist and `DEFAULT_SUPERADMIN_PASSWORD` is set:
- Hashes the password with bcrypt
- Creates the user with role `superadmin`
- Prints a success message
4. If `DEFAULT_SUPERADMIN_PASSWORD` is not set:
- Prints a warning and skips creation
## Security Considerations
1. **Never commit the `.env` file** to version control
2. Use a strong password (minimum 12 characters, mixed case, numbers, special characters)
3. Change the default password after first login
4. In production, consider using secrets management (Docker secrets, Kubernetes secrets, etc.)
5. Rotate passwords regularly
6. The password is hashed with bcrypt (industry standard) before storage
## Testing
To verify the superadmin user was created:
```bash
# Connect to the database container
docker exec -it infoscreen-db mysql -u root -p
# Check the users table
USE infoscreen_by_taa;
SELECT username, role, is_active FROM users WHERE role = 'superadmin';
```
Expected output:
```
+------------+------------+-----------+
| username | role | is_active |
+------------+------------+-----------+
| superadmin | superadmin | 1 |
+------------+------------+-----------+
```
## Troubleshooting
### Superadmin not created
**Symptoms**: No superadmin user in database
**Solutions**:
1. Check if `DEFAULT_SUPERADMIN_PASSWORD` is set in environment
2. Check container logs: `docker logs infoscreen-api`
3. Look for warning message: "⚠️ DEFAULT_SUPERADMIN_PASSWORD nicht gesetzt"
### User already exists message
**Symptoms**: Script says user already exists but you can't log in
**Solutions**:
1. Verify the username is correct
2. Reset the password manually in the database
3. Or delete the user and restart containers to recreate
### Permission denied errors
**Symptoms**: Database connection errors during initialization
**Solutions**:
1. Verify `DB_USER`, `DB_PASSWORD`, and `DB_NAME` environment variables
2. Check database container is healthy: `docker ps`
3. Verify database connectivity: `docker exec infoscreen-api ping -c 1 db`
## Next Steps
After setting up the superadmin user:
1. Implement the `/api/me` endpoint (Stage 1, Step 3)
2. Add authentication/session management
3. Create permission decorators (Stage 1, Step 4)
4. Build user management UI (Stage 2)
See `userrole-management.md` for the complete roadmap.

285
TECH-CHANGELOG.md Normal file
View File

@@ -0,0 +1,285 @@
# TECH-CHANGELOG
This changelog documents technical and developer-relevant changes included in public releases. For development workspace changes, see DEV-CHANGELOG.md. Not all changes here are reflected in the user-facing changelog (`program-info.json`), and not all UI/feature changes are repeated here. Some changes (e.g., backend refactoring, API adjustments, infrastructure, developer tooling, or internal logic) may only appear in TECH-CHANGELOG.md. For UI/feature changes, see `dashboard/public/program-info.json`.
## 2026.1.0-alpha.14 (2026-01-28)
- 🗓️ **Ressourcen Page (Timeline View)**:
- New frontend page: `dashboard/src/ressourcen.tsx` (357 lines) Parallel timeline view showing active events for all room groups
- Uses Syncfusion ScheduleComponent with TimelineViews module for resource-based scheduling
- Compact visualization: 65px row height per group, dynamically calculated total container height
- Real-time event loading: Fetches events per group for current date range on mount and view/date changes
- Timeline modes: Day (default) and Week views with date range calculation
- Color-coded event bars: Uses `getGroupColor()` from `groupColors.ts` for group theme matching
- Displays first active event per group with type, title, and time window
- Filters out "Nicht zugeordnet" group from timeline display
- Resource mapping: Each group becomes a timeline resource row, events mapped via `ResourceId`
- Syncfusion modules: TimelineViews, Resize, DragAndDrop injected for rich interaction
- 🎨 **Ressourcen Styling**:
- New CSS file: `dashboard/src/ressourcen.css` (178 lines) with modern Material 3 design
- Fixed CSS lint errors: Converted `rgba()` to modern `rgb()` notation with percentage alpha values (`rgb(0 0 0 / 10%)`)
- Removed unnecessary quotes from font-family names (Roboto, Oxygen, Ubuntu, Cantarell)
- Fixed CSS selector specificity ordering (`.e-schedule` before `.ressourcen-timeline-wrapper .e-schedule`)
- Card-based controls layout with shadow and rounded corners
- Group ordering panel with scrollable list and action buttons
- Responsive timeline wrapper with flex layout
- 🔌 **Group Order API**:
- New backend endpoints in `server/routes/groups.py`:
- `GET /api/groups/order` Retrieve saved group display order (returns JSON with `order` array of group IDs)
- `POST /api/groups/order` Persist group display order (accepts JSON with `order` array)
- Order persistence: Stored in `system_settings` table with key `group_display_order` (JSON array of integers)
- Automatic synchronization: Missing group IDs added to order, removed IDs filtered out
- Frontend integration: Group order panel with drag up/down buttons, real-time reordering with backend sync
- 🖥️ **Frontend Technical**:
- State management: React hooks with unused setters removed (setTimelineView, setViewDate) to resolve lint warnings
- TypeScript: Changed `let` to `const` for immutable end date calculation
- UTC date parsing: Uses parseUTCDate callback to append 'Z' and ensure UTC interpretation
- Event formatting: Capitalizes first letter of event type for display (e.g., "Website - Title")
- Loading state: Shows loading indicator while fetching group/event data
- Schedule height: Dynamic calculation based on `groups.length * 65px + 100px` for header
- 📖 **Documentation**:
- Updated `.github/copilot-instructions.md`:
- Added Ressourcen page to "Recent changes" section (January 2026)
- Added `ressourcen.tsx` and `ressourcen.css` to "Important files" list
- Added Groups API order endpoints documentation
- Added comprehensive Ressourcen page section to "Frontend patterns"
- Updated `README.md`:
- Added Ressourcen page to "Pages Overview" section with feature details
- Added `GET/POST /api/groups/order` to Core Resources API section
- Bumped version in `dashboard/public/program-info.json` to `2026.1.0-alpha.14` with user-facing changelog
Notes for integrators:
- Group order API returns JSON with `{ "order": [1, 2, 3, ...] }` structure (array of group IDs)
- Timeline view automatically filters "Nicht zugeordnet" group for cleaner display
- CSS follows modern Material 3 color-function notation (`rgb(r g b / alpha%)`)
- Syncfusion ScheduleComponent requires TimelineViews, Resize, and DragAndDrop modules injected
## 2025.1.0-beta.1 (TBD)
- 🔐 **User Management & Role-Based Access Control**:
- Backend: Implemented comprehensive user management API (`server/routes/users.py`) with 6 endpoints (GET, POST, PUT, DELETE users + password reset).
- Data model: Extended `User` with 7 audit/security fields via Alembic migration (`4f0b8a3e5c20_add_user_audit_fields.py`):
- `last_login_at`, `last_password_change_at`: TIMESTAMP (UTC) for auth event tracking
- `failed_login_attempts`, `last_failed_login_at`: Security monitoring for brute-force detection
- `locked_until`: TIMESTAMP placeholder for account lockout (infrastructure in place, not yet enforced)
- `deactivated_at`, `deactivated_by`: Soft-delete audit trail (FK self-reference)
- Role hierarchy: 4-tier privilege escalation (user → editor → admin → superadmin) enforced at API and UI levels:
- Admin cannot see, create, or manage superadmin accounts
- Admin can manage user/editor/admin roles only
- Superadmin can manage all roles including other superadmins
- Auth routes enhanced (`server/routes/auth.py`):
- Login: Sets `last_login_at`, resets `failed_login_attempts` on success; increments `failed_login_attempts` and `last_failed_login_at` on failure
- Password change: Sets `last_password_change_at` on both self-service and admin reset
- New endpoint: `PUT /api/auth/change-password` for self-service password change (all authenticated users; requires current password verification)
- User API security:
- Admin cannot reset superadmin passwords
- Self-account protections: cannot change own role/status, cannot delete self
- Admin cannot use password reset endpoint for their own account (backend check enforces self-service requirement)
- All user responses include audit fields in camelCase (lastLoginAt, lastPasswordChangeAt, failedLoginAttempts, deactivatedAt, deactivatedBy)
- Soft-delete pattern: Deactivation by default (sets `deactivated_at` and `deactivated_by`); hard-delete superadmin-only
- 🖥️ **Frontend User Management**:
- New page: `dashboard/src/users.tsx` Full CRUD interface (820 lines) with Syncfusion components
- GridComponent: 20 per page (configurable), sortable columns (ID, username, role), custom action button template with role-based visibility
- Statistics cards: Total users, active (non-deactivated), inactive (deactivated) counts
- Dialogs: Create (username/password/role/status), Edit (with self-edit protections), Password Reset (admin only, no current password required), Delete (superadmin only, self-check), Details (read-only audit info with formatted timestamps)
- Role badges: Color-coded display (user: gray, editor: blue, admin: green, superadmin: red)
- Audit information display: last login, password change, last failed login, deactivation timestamps and deactivating user
- Self-protection: Delete button hidden for current user (prevents accidental self-deletion)
- Menu visibility: "Benutzer" sidebar item only visible to admin+ (role-gated in App.tsx)
- 💬 **Header User Menu**:
- Enhanced top-right dropdown with "Passwort ändern" (lock icon), "Profil", and "Abmelden"
- Self-service password change dialog: Available to all authenticated users; requires current password verification, new password min 6 chars, must match confirm field
- Implemented with Syncfusion DropDownButton (`@syncfusion/ej2-react-splitbuttons`)
- 🔌 **API Client**:
- New file: `dashboard/src/apiUsers.ts` Type-safe TypeScript client (143 lines) for user operations
- Functions: listUsers(), getUser(), createUser(), updateUser(), resetUserPassword(), deleteUser()
- All functions include proper error handling and camelCase JSON mapping
- 📖 **Documentation**:
- Updated `.github/copilot-instructions.md`: Added comprehensive sections on user model audit fields, user management API routes, auth routes, header menu, and user management page implementation
- Updated `README.md`: Added user management to Key Features, API endpoints (User Management + Authentication sections), Pages Overview, and Security & Authentication sections with RBAC details
- Updated `TECH-CHANGELOG.md`: Documented all technical changes and integration notes
Notes for integrators:
- User CRUD endpoints accept/return all audit fields in camelCase
- Admin password reset (`PUT /api/users/<id>/password`) cannot be used for admin's own account; users must use self-service endpoint
- Frontend enforces role-gated menu visibility; backend validates all role transitions to prevent privilege escalation
- Soft-delete is default; hard-delete (superadmin-only) requires explicit confirmation
- Audit fields populated automatically on login/logout/password-change/deactivation events
Backend rework (post-release notes; no version bump):
- 🧩 Dev Container hygiene: Remote Containers runs on UI (`remote.extensionKind`), removed in-container install to prevent reappearance loops; switched `postCreateCommand` to `npm ci` for reproducible dashboard installs; `postStartCommand` aliases made idempotent.
- 🔄 Serialization: Consolidated snake_case→camelCase via `server/serializers.py` for all JSON outputs; ensured enums/UTC datetimes serialize consistently across routes.
- 🕒 Time handling: Normalized naive timestamps to UTC in all back-end comparisons (events, scheduler, groups) and kept ISO strings without `Z` in API responses; frontend appends `Z`.
- 📡 Streaming: Stabilized range-capable endpoint (`/api/eventmedia/stream/<media_id>/<filename>`), clarified client handling; scheduler emits basic HEAD-probe metadata (`mime_type`, `size`, `accept_ranges`).
- 📅 Recurrence/exceptions: Ensured EXDATE tokens (RFC 5545 UTC) align with occurrence start; detached-occurrence flow confirmed via `POST /api/events/<id>/occurrences/<date>/detach`.
- 🧰 Routes cleanup: Applied `dict_to_camel_case()` before `jsonify()` uniformly; verified Session lifecycle consistency (open/commit/close) across blueprints.
- 🔄 **API Naming Convention Standardization**:
- Created `server/serializers.py` with `dict_to_camel_case()` and `dict_to_snake_case()` utilities for consistent JSON serialization
- Events API refactored: `GET /api/events` and `GET /api/events/<id>` now return camelCase JSON (`id`, `subject`, `startTime`, `endTime`, `type`, `groupId`, etc.) instead of PascalCase
- Internal event dictionaries use snake_case keys, then converted to camelCase via `dict_to_camel_case()` before `jsonify()`
- **Breaking**: External API consumers must update field names from PascalCase to camelCase
-**UTC Time Handling**:
- Standardized datetime handling: Database stores timestamps in UTC (naive timestamps normalized by backend)
- API returns ISO strings without 'Z' suffix: `"2025-11-27T20:03:00"`
- Frontend appends 'Z' to parse as UTC and displays in user's local timezone via `toLocaleTimeString('de-DE')`
- All time comparisons use UTC; `date.toISOString()` sends UTC back to API
- 🖥️ **Dashboard Major Redesign**:
- Completely redesigned dashboard with card-based layout for Raumgruppen (room groups)
- Global statistics summary card: total infoscreens, online/offline counts, warning groups
- Filter buttons with dynamic counts: All, Online, Offline, Warnings
- Active event display per group: shows currently playing content with type icon, title, date ("Heute"/"Morgen"/date), and time range
- Health visualization: color-coded progress bars showing online/offline ratio per group
- Expandable client details: shows last alive timestamps with human-readable format ("vor X Min.", "vor X Std.", "vor X Tagen")
- Bulk restart functionality: restart all offline clients in a group
- Manual refresh button with toast notifications
- 15-second auto-refresh interval
- "Nicht zugeordnet" group always appears last in sorted list
- 🎨 **Frontend Technical**:
- Dashboard (`dashboard/src/dashboard.tsx`): Uses Syncfusion ButtonComponent, ToastComponent, and card CSS classes
- Appointments page updated to map camelCase API responses to internal PascalCase for Syncfusion compatibility
- Time formatting functions (`formatEventTime`, `formatEventDate`) handle UTC string parsing with 'Z' appending
- TypeScript lint errors resolved: unused error variables removed, null safety checks added with optional chaining
- 📖 **Documentation**:
- Updated `.github/copilot-instructions.md` with comprehensive sections on:
- API patterns: JSON serialization, datetime handling conventions
- Frontend patterns: API response format, UTC time parsing
- Dashboard page overview with features
- Conventions & gotchas: datetime and JSON naming guidelines
- Updated `README.md` with recent changes, API response format section, and dashboard page details
Notes for integrators:
- **Breaking change**: All Events API endpoints now return camelCase field names. Update client code accordingly.
- Frontend must append 'Z' to API datetime strings before parsing: `const utcStr = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'; new Date(utcStr);`
- Use `dict_to_camel_case()` from `server/serializers.py` for any new API endpoints returning JSON
- Dev container: prefer `npm ci` and UI-only Remote Containers to avoid extension drift in-container.
---
### Component build metadata template (for traceability)
Record component builds under the unified app version when releasing:
```
Component builds for this release
- API: image tag `ghcr.io/robbstarkaustria/api:<short-sha>` (commit `<sha>`)
- Dashboard: image tag `ghcr.io/robbstarkaustria/dashboard:<short-sha>` (commit `<sha>`)
- Scheduler: image tag `ghcr.io/robbstarkaustria/scheduler:<short-sha>` (commit `<sha>`)
- Listener: image tag `ghcr.io/robbstarkaustria/listener:<short-sha>` (commit `<sha>`)
- Worker: image tag `ghcr.io/robbstarkaustria/worker:<short-sha>` (commit `<sha>`)
```
This is informational (build metadata) and does not change the user-facing version number.
## 2025.1.0-alpha.11 (2025-11-05)
- 🗃️ Data model & API:
- Added `muted` (Boolean) to `Event` with Alembic migration; create/update and GET endpoints now accept, persist, and return `muted` alongside `autoplay`, `loop`, and `volume` for video events.
- Video event fields consolidated: `event_media_id`, `autoplay`, `loop`, `volume`, `muted`.
- 🔗 Streaming:
- Added range-capable streaming endpoint: `GET /api/eventmedia/stream/<media_id>/<filename>` (supports byte-range requests 206 for seeking).
- Scheduler: Performs a best-effort HEAD probe for video stream URLs and includes basic metadata in the emitted payload (`mime_type`, `size`, `accept_ranges`). Placeholders added for `duration`, `resolution`, `bitrate`, `qualities`, `thumbnails`, `checksum`.
- 🖥️ Frontend/Dashboard:
- Settings page refactored to nested tabs with controlled tab selection (`selectedItem`) to prevent sub-tab jumps.
- Settings → Events → Videos: Added system-wide defaults with load/save via system settings keys: `video_autoplay`, `video_loop`, `video_volume`, `video_muted`.
- Event modal (CustomEventModal): Exposes per-event video options including “Ton aus” (`muted`) and initializes all video fields from system defaults when creating new events.
- Academic Calendar (Settings): Merged “Schulferien Import” and “Liste” into a single sub-tab “📥 Import & Liste”.
- 📖 Documentation:
- Updated `README.md` and `.github/copilot-instructions.md` for video payload (incl. `muted`), streaming endpoint (206), nested Settings tabs, and video defaults keys; clarified client handling of `video` payloads.
- Updated `dashboard/public/program-info.json` (user-facing changelog) and bumped version to `2025.1.0-alpha.11` with corresponding UI/UX notes.
Notes for integrators:
- Clients should parse `event_type` and handle the nested `video` payload, honoring `autoplay`, `loop`, `volume`, and `muted`. Use the streaming endpoint with HTTP Range for seeking.
- System settings keys for video defaults: `video_autoplay`, `video_loop`, `video_volume`, `video_muted`.
## 2025.1.0-alpha.10 (2025-10-25)
- No new developer-facing changes in this release.
- UI/UX updates are documented in `dashboard/public/program-info.json`:
- Event modal: Surfaced video options (Autoplay, Loop, Volume).
- FileManager: Increased upload limits (Full-HD); client-side duration validation (max 10 minutes).
## 2025.1.0-alpha.9 (2025-10-19)
- 🗓️ Events/API:
- Implemented new `webuntis` event type. Event creation now resolves the URL from the system setting `supplement_table_url`; returns 400 if unset.
- Removed obsolete `webuntis-url` settings endpoints. Use `GET/POST /api/system-settings/supplement-table` for URL and enabled state (shared for WebUntis/Vertretungsplan).
- Initialization defaults: dropped `webuntis_url`; updated `supplement_table_url` description to “Vertretungsplan / WebUntis”.
- 🚦 Scheduler payloads:
- Unified Website/WebUntis payload: both emit a nested `website` object `{ "type": "browser", "url": "…" }`; `event_type` remains either `website` or `webuntis` for dispatch.
- Payloads now include a top-level `event_type` string for all events to aid client dispatch.
- 🖥️ Frontend/Dashboard:
- Program info updated to `2025.1.0-alpha.13` with release notes.
- Settings → Events: WebUntis now uses the existing Supplement-Table URL; no separate WebUntis URL field.
- Event modal: WebUntis type behaves like Website (no per-event URL input).
- 📖 Documentation:
- Added `MQTT_EVENT_PAYLOAD_GUIDE.md` (message structure, client best practices, versioning).
- Added `WEBUNTIS_EVENT_IMPLEMENTATION.md` (design notes, admin setup, testing checklist).
- Updated `.github/copilot-instructions.md` and `README.md` for the unified Website/WebUntis handling and settings usage.
Notes for integrators:
- If you previously integrated against `/api/system-settings/webuntis-url`, migrate to `/api/system-settings/supplement-table`.
- Clients should now parse `event_type` and use the corresponding nested payload (`presentation`, `website`, …). `webuntis` and `website` should be handled identically (nested `website` payload).
## 2025.1.0-alpha.8 (2025-10-18)
- 🛠️ Backend: Seeded presentation defaults (`presentation_interval`, `presentation_page_progress`, `presentation_auto_progress`) in system settings; applied on event creation.
- 🗃️ Data model: Added `page_progress` and `auto_progress` fields to `Event` (with Alembic migration).
- 🗓️ Scheduler: Now publishes only currently active events per group (at "now"); clears retained topics by publishing `[]` for groups with no active events; normalizes naive timestamps and compares times in UTC; presentation payloads include `page_progress` and `auto_progress`.
- 🖥️ Dashboard: Settings → Events tab now includes Presentations defaults (interval, page-progress, auto-progress) with load/save via API; event modal applies defaults on create and persists per-event values on edit.
- 📖 Docs: Updated README and Copilot instructions for new scheduler behavior, UTC handling, presentation defaults, and per-event flags.
---
## 2025.1.0-alpha.11 (2025-10-16)
- ✨ Settings page: New tab layout (Syncfusion) with role-based visibility Tabs: 📅 Academic Calendar, 🖥️ Display & Clients, 🎬 Media & Files, 🗓️ Events, ⚙️ System.
- 🛠️ Settings (Technical): API calls now use relative /api paths via the Vite proxy (prevents CORS and double /api).
- 📖 Docs: README updated for settings page (tabs) and system settings API.
## 2025.1.0-alpha.10 (2025-10-15)
- 🔐 Auth: Login and user management implemented (role-based, persistent sessions).
- 🧩 Frontend: Syncfusion SplitButtons integrated (react-splitbuttons) and Vite config updated for pre-bundling.
- 🐛 Fix: Import error @syncfusion/ej2-react-splitbuttons instructions added to README (optimizeDeps + volume reset).
## 2025.1.0-alpha.9 (2025-10-14)
- ✨ UI: Unified deletion workflow for appointments all types (single, single instance, entire series) handled with custom dialogs.
- 🔧 Frontend: Syncfusion RecurrenceAlert and DeleteAlert intercepted and replaced with custom dialogs (including final confirmation for series deletion).
- 📖 Docs: README and Copilot instructions expanded for deletion workflow and dialog handling.
## 2025.1.0-alpha.8 (2025-10-11)
- 🎨 Theme: Migrated to Syncfusion Material 3; centralized CSS imports in main.tsx
- 🧹 Cleanup: Tailwind CSS completely removed (packages, PostCSS, Stylelint, config files)
- 🧩 Group management: "infoscreen_groups" migrated to Syncfusion components (Buttons, Dialogs, DropDownList, TextBox); improved spacing
- 🔔 Notifications: Unified toast/dialog wording; last alert usage replaced
- 📖 Docs: README and Copilot instructions updated (Material 3, centralized styles, no Tailwind)
## 2025.1.0-alpha.7 (2025-09-21)
- 🧭 UI: Period selection (Syncfusion) next to group selection; compact layout
- ✅ Display: Badge for existing holiday plan + counter Holidays in view
- 🛠️ API: Endpoints for academic periods (list, active GET/POST, for_date)
- 📅 Scheduler: By default, no scheduling during holidays; block display like all-day event; black text color
- 📤 Holidays: Upload from TXT/CSV (headless TXT uses columns 24)
- 🔧 UX: Switches in a row; dropdown widths optimized
## 2025.1.0-alpha.6 (2025-09-20)
- 🗓️ NEW: Academic periods system support for school years, semesters, trimesters
- 🏗️ DATABASE: New 'academic_periods' table for time-based organization
- 🔗 EXTENDED: Events and media can now optionally be linked to an academic period
- 📊 ARCHITECTURE: Fully backward-compatible implementation for gradual rollout
- ⚙️ TOOLS: Automatic creation of standard school years for Austrian schools
## 2025.1.0-alpha.5 (2025-09-14)
- Backend: Complete redesign of backend handling for group assignments of new clients and steps for changing group assignment.
## 2025.1.0-alpha.4 (2025-09-01)
- Deployment: Base structure for deployment tested and optimized.
- FIX: Program error when switching view on media page fixed.
## 2025.1.0-alpha.3 (2025-08-30)
- NEW: Program info page with dynamic data, build info, and changelog.
- NEW: Logout functionality implemented.
- FIX: Sidebar width corrected in collapsed state.
## 2025.1.0-alpha.2 (2025-08-29)
- INFO: Analysis and display of used open-source libraries.
## 2025.1.0-alpha.1 (2025-08-28)
- Initial project setup and base structure.

View File

@@ -0,0 +1,324 @@
# WebUntis Event Type Implementation
**Date**: 2025-10-19
**Status**: Completed
## Summary
Implemented support for a new `webuntis` event type that displays a centrally-configured WebUntis website on infoscreen clients. This event type follows the same client-side behavior as `website` events but sources its URL from system settings rather than per-event configuration.
## Changes Made
### 1. Database & Models
The `webuntis` event type was already defined in the `EventType` enum in `models/models.py`:
```python
class EventType(enum.Enum):
presentation = "presentation"
website = "website"
video = "video"
message = "message"
other = "other"
webuntis = "webuntis" # Already present
```
### 2. System Settings
#### Default Initialization (`server/init_defaults.py`)
Updated `supplement_table_url` description to indicate it's used for both Vertretungsplan and WebUntis:
```python
('supplement_table_url', '', 'URL für Vertretungsplan / WebUntis (Stundenplan-Änderungstabelle)')
```
This setting is automatically seeded during database initialization.
**Note**: The same URL (`supplement_table_url`) is used for both:
- Vertretungsplan (supplement table) displays
- WebUntis event displays
#### API Endpoints (`server/routes/system_settings.py`)
WebUntis events use the existing supplement table endpoints:
- **`GET /api/system-settings/supplement-table`** (Admin+)
- Returns: `{"url": "https://...", "enabled": true/false}`
- **`POST /api/system-settings/supplement-table`** (Admin+)
- Body: `{"url": "https://...", "enabled": true/false}`
- Updates the URL used for both supplement table and WebUntis events
No separate WebUntis URL endpoint is needed—the supplement table URL serves both purposes.
### 3. Event Creation (`server/routes/events.py`)
Added handling for `webuntis` event type in `create_event()`:
```python
# WebUntis: URL aus System-Einstellungen holen und EventMedia anlegen
if event_type == "webuntis":
# Hole WebUntis-URL aus Systemeinstellungen (verwendet supplement_table_url)
webuntis_setting = session.query(SystemSetting).filter_by(key='supplement_table_url').first()
webuntis_url = webuntis_setting.value if webuntis_setting else ''
if not webuntis_url:
return jsonify({"error": "WebUntis / Supplement table URL not configured in system settings"}), 400
# EventMedia für WebUntis anlegen
media = EventMedia(
media_type=MediaType.website,
url=webuntis_url,
file_path=webuntis_url
)
session.add(media)
session.commit()
event_media_id = media.id
```
**Workflow**:
1. Check if `supplement_table_url` is configured in system settings
2. Return error if not configured
3. Create `EventMedia` with `MediaType.website` using the supplement table URL
4. Associate the media with the event
### 4. Scheduler Payload (`scheduler/db_utils.py`)
Modified `format_event_with_media()` to handle both `website` and `webuntis` events:
```python
# Handle website and webuntis events (both display a website)
elif event.event_type.value in ("website", "webuntis"):
event_dict["website"] = {
"type": "browser",
"url": media.url if media.url else None
}
if media.id not in _media_decision_logged:
logging.debug(
f"[Scheduler] Using website URL for event_media_id={media.id} (type={event.event_type.value}): {media.url}")
_media_decision_logged.add(media.id)
```
**Key Points**:
- Both event types use the same `website` payload structure
- Clients interpret `event_type` but handle display identically
- URL is already resolved from system settings during event creation
### 5. Documentation
Created comprehensive documentation in `MQTT_EVENT_PAYLOAD_GUIDE.md` covering:
- MQTT message structure
- Event type-specific payloads
- Best practices for client implementation
- Versioning strategy
- System settings integration
## MQTT Message Format
### WebUntis Event Payload
```json
{
"id": 125,
"event_type": "webuntis",
"title": "Schedule Display",
"start": "2025-10-19T09:00:00+00:00",
"end": "2025-10-19T09:30:00+00:00",
"group_id": 1,
"website": {
"type": "browser",
"url": "https://webuntis.example.com/schedule"
}
}
```
### Website Event Payload (for comparison)
```json
{
"id": 124,
"event_type": "website",
"title": "School Website",
"start": "2025-10-19T09:00:00+00:00",
"end": "2025-10-19T09:30:00+00:00",
"group_id": 1,
"website": {
"type": "browser",
"url": "https://example.com/page"
}
}
```
## Client Implementation Guide
Clients should handle both `website` and `webuntis` event types identically:
```javascript
function parseEvent(event) {
switch (event.event_type) {
case 'presentation':
return handlePresentation(event.presentation);
case 'website':
case 'webuntis':
// Both types use the same display logic
return handleWebsite(event.website);
case 'video':
return handleVideo(event.video);
default:
console.warn(`Unknown event type: ${event.event_type}`);
}
}
function handleWebsite(websiteData) {
// websiteData = { type: "browser", url: "https://..." }
if (!websiteData.url) {
console.error('Website event missing URL');
return;
}
// Display URL in embedded browser/webview
displayInBrowser(websiteData.url);
}
```
## Best Practices
### 1. Type-Based Dispatch
Always check `event_type` first and dispatch to appropriate handlers. The nested payload structure (`presentation`, `website`, etc.) provides type-specific details.
### 2. Graceful Error Handling
- Validate URLs before displaying
- Handle missing or empty URLs gracefully
- Provide user-friendly error messages
### 3. Unified Website Display
Both `website` and `webuntis` events trigger the same browser/webview component. The only difference is in event creation (per-event URL vs. system-wide URL).
### 4. Extensibility
The message structure supports adding new event types without breaking existing clients:
- Old clients ignore unknown `event_type` values
- New fields in existing payloads are optional
- Nested objects isolate type-specific changes
## Administrative Setup
### Setting the WebUntis / Supplement Table URL
The same URL is used for both Vertretungsplan (supplement table) and WebUntis displays.
1. **Via API** (recommended for UI integration):
```bash
POST /api/system-settings/supplement-table
{
"url": "https://webuntis.example.com/schedule",
"enabled": true
}
```
2. **Via Database** (for initial setup):
```sql
INSERT INTO system_settings (`key`, value, description)
VALUES ('supplement_table_url', 'https://webuntis.example.com/schedule',
'URL für Vertretungsplan / WebUntis (Stundenplan-Änderungstabelle)');
```
3. **Via Dashboard**:
Settings → Events → WebUntis / Vertretungsplan
### Creating a WebUntis Event
Once the URL is configured, events can be created through:
1. **Dashboard UI**: Select "WebUntis" as event type
2. **API**:
```json
POST /api/events
{
"group_id": 1,
"title": "Daily Schedule",
"description": "Current class schedule",
"start": "2025-10-19T08:00:00Z",
"end": "2025-10-19T16:00:00Z",
"event_type": "webuntis",
"created_by": 1
}
```
No `website_url` is required—it's automatically fetched from the `supplement_table_url` system setting.
## Migration Notes
### From Presentation-Only System
This implementation extends the existing event system without breaking presentation events:
- **Presentation events**: Still use `presentation` payload with `files` array
- **Website/WebUntis events**: Use new `website` payload with `url` field
- **Message structure**: Includes `event_type` for client-side dispatch
### Future Event Types
The pattern established here can be extended to other event types:
- **Video**: `event_dict["video"] = { "type": "media", "url": "...", "autoplay": true }`
- **Message**: `event_dict["message"] = { "type": "html", "content": "..." }`
- **Custom**: Any new type with its own nested payload
## Testing Checklist
- [x] Database migration includes `webuntis` enum value
- [x] System setting `supplement_table_url` description updated to include WebUntis
- [x] Event creation validates supplement_table_url is configured
- [x] Event creation creates `EventMedia` with supplement table URL
- [x] Scheduler includes `website` payload for `webuntis` events
- [x] MQTT message structure documented
- [x] No duplicate webuntis_url setting (uses supplement_table_url)
- [ ] Dashboard UI shows supplement table URL is used for WebUntis (documentation)
- [ ] Client implementation tested with WebUntis events (client-side)
## Related Files
### Modified
- `scheduler/db_utils.py` - Event formatting logic
- `server/routes/events.py` - Event creation handling
- `server/routes/system_settings.py` - WebUntis URL endpoints
- `server/init_defaults.py` - System setting defaults
### Created
- `MQTT_EVENT_PAYLOAD_GUIDE.md` - Comprehensive message format documentation
- `WEBUNTIS_EVENT_IMPLEMENTATION.md` - This file
### Existing (Not Modified)
- `models/models.py` - Already had `webuntis` enum value
- `dashboard/src/components/CustomEventModal.tsx` - Already supports webuntis type
## Further Enhancements
### Short-term
1. Add WebUntis URL configuration to dashboard Settings page
2. Update event creation UI to explain WebUntis URL comes from settings
3. Add validation/preview for WebUntis URL in settings
### Long-term
1. Support multiple WebUntis instances (per-school in multi-tenant setup)
2. Add WebUntis-specific metadata (class filter, room filter, etc.)
3. Implement iframe sandboxing options for security
4. Add refresh intervals for dynamic WebUntis content
## Conclusion
The `webuntis` event type is now fully integrated into the infoscreen system. It uses the existing `supplement_table_url` system setting, which serves dual purposes:
1. **Vertretungsplan (supplement table)** displays in the existing settings UI
2. **WebUntis schedule** displays via the webuntis event type
This provides a clean separation between system-wide URL configuration and per-event scheduling, while maintaining backward compatibility and following established patterns for event payload structure.
The implementation demonstrates best practices:
- **Reuse existing infrastructure**: Uses supplement_table_url instead of creating duplicate settings
- **Consistency**: Follows same patterns as existing event types
- **Extensibility**: Easy to add new event types following this model
- **Documentation**: Comprehensive guides for both developers and clients

Binary file not shown.

View File

@@ -1,38 +0,0 @@
# dashboard/Dockerfile
# Produktions-Dockerfile für die Dash-Applikation
# --- Basis-Image ---
FROM python:3.13-slim
# --- Arbeitsverzeichnis im Container ---
WORKDIR /app
# --- Systemabhängigkeiten installieren (falls benötigt) ---
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential git \
&& rm -rf /var/lib/apt/lists/*
# --- Python-Abhängigkeiten kopieren und installieren ---
COPY dash_using_fullcalendar-0.1.0.tar.gz ./
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# --- Applikationscode kopieren ---
COPY dashboard/ /app
# --- Non-Root-User anlegen und Rechte setzen ---
ARG USER_ID=1000
ARG GROUP_ID=1000
RUN groupadd -g ${GROUP_ID} infoscreen_taa \
&& useradd -u ${USER_ID} -g ${GROUP_ID} \
--shell /bin/bash --create-home infoscreen_taa \
&& chown -R infoscreen_taa:infoscreen_taa /app
USER infoscreen_taa
# --- Port für Dash exposed ---
EXPOSE 8050
# --- Startbefehl: Gunicorn mit Dash-Server ---
# "app.py" enthält: app = dash.Dash(...); server = app.server
CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:8050", "app:server"]

View File

@@ -1,51 +0,0 @@
# dashboard/Dockerfile.dev
# Entwicklungs-Dockerfile für das Dash-Dashboard
FROM python:3.13-slim
# Build args für UID/GID
ARG USER_ID=1000
ARG GROUP_ID=1000
# Systemabhängigkeiten (falls nötig)
RUN apt-get update \
&& apt-get install -y --no-install-recommends locales curl \
&& sed -i 's/# de_DE.UTF-8 UTF-8/de_DE.UTF-8 UTF-8/' /etc/locale.gen \
&& locale-gen \
&& rm -rf /var/lib/apt/lists/*
# Locale setzen
ENV LANG=de_DE.UTF-8 \
LANGUAGE=de_DE:de \
LC_ALL=de_DE.UTF-8
# Non-root User anlegen
RUN groupadd -g ${GROUP_ID} infoscreen_taa \
&& useradd -u ${USER_ID} -g ${GROUP_ID} --shell /bin/bash --create-home infoscreen_taa
# Arbeitsverzeichnis
WORKDIR /app
# Kopiere nur Requirements für schnellen Rebuild
COPY dash_using_fullcalendar-0.1.0.tar.gz ./
COPY requirements.txt ./
COPY requirements-dev.txt ./
# Installiere Abhängigkeiten
RUN pip install --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt \
&& pip install --no-cache-dir -r requirements-dev.txt
# Setze Entwicklungs-Modus
ENV DASH_DEBUG_MODE=True
ENV API_URL=http://server:8000/api
# Ports für Dash und optional Live-Reload
EXPOSE 8050
EXPOSE 5678
# Wechsle zum non-root User
USER infoscreen_taa
# Dev-Start: Dash mit Hot-Reload
CMD ["python", "-m", "debugpy", "--listen", "0.0.0.0:5637", "app.py"]

View File

@@ -1,79 +0,0 @@
# dashboard/app.py
import sys
sys.path.append('/workspace')
from dash import Dash, html, dcc, page_container
from flask import Flask
import dash_bootstrap_components as dbc
import dash_mantine_components as dmc
from components.header import Header
import callbacks.ui_callbacks
import dashboard.callbacks.overview_callbacks
import dashboard.callbacks.appointments_callbacks
import dashboard.callbacks.appointment_modal_callbacks
from config import SECRET_KEY, ENV
import os
import threading
import logging
# Logging konfigurieren
logging.basicConfig(
level=logging.DEBUG if ENV == "development" else logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
server = Flask(__name__)
server.secret_key = SECRET_KEY
# Flask's eigene Logs aktivieren
if ENV == "development":
logging.getLogger('werkzeug').setLevel(logging.INFO)
app = Dash(
__name__,
server=server,
use_pages=True,
external_stylesheets=[dbc.themes.BOOTSTRAP],
suppress_callback_exceptions=True,
serve_locally=True,
meta_tags=[
{"name": "viewport", "content": "width=device-width, initial-scale=1"},
{"charset": "utf-8"}
]
)
app.layout = dmc.MantineProvider([
Header(),
html.Div([
html.Div(id="sidebar"),
html.Div(page_container, className="page-content"),
dcc.Store(id="sidebar-state", data={"collapsed": False}),
], style={"display": "flex"}),
])
# def open_browser():
# """Öffnet die HTTPS-URL im Standardbrowser."""
# os.system('$BROWSER https://localhost:8050') # Entferne das "&", um sicherzustellen, dass der Browser korrekt geöffnet wird
print("Testausgabe: Debug-Print funktioniert!") # Testausgabe
if __name__ == "__main__":
debug_mode = ENV == "development"
logger.info(f"Starting application in {'DEBUG' if debug_mode else 'PRODUCTION'} mode")
logger.info(f"Environment: {ENV}")
logger.info("🔧 Debug features: print(), logging, hot reload all active")
logger.info("🚀 Dashboard starting up...")
# Browser nur einmal öffnen, nicht bei Reload-Prozessen
# if debug_mode and os.environ.get("WERKZEUG_RUN_MAIN") != "true":
# threading.Timer(1.0, open_browser).start()
app.run(
host="0.0.0.0",
port=8050,
debug=debug_mode,
ssl_context=("/workspace/certs/dev.crt", "/workspace/certs/dev.key"),
use_reloader=False # Verhindert doppeltes Öffnen durch Dash
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -1,249 +0,0 @@
/* ==========================
Allgemeines Layout
========================== */
:root {
--mb-z-index: 2000 !important;
--mantine-z-index-popover: 2100 !important;
--mantine-z-index-overlay: 2999 !important;
--mantine-z-index-dropdown: 2100 !important;
--mantine-z-index-max: 9999 !important;
--mantine-z-index-modal: 3000 !important;
}
body {
margin: 0;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background-color: #f8f9fa;
padding-top: 60px; /* Platz für den fixen Header schaffen */
}
/* page-content (rechts neben der Sidebar) */
.page-content {
flex: 1 1 0%;
padding: 20px;
min-width: 0; /* verhindert Überlauf bei zu breitem Inhalt */
transition: margin-left 0.3s ease;
min-height: calc(100vh - 60px); /* Mindesthöhe minus Header-Höhe */
margin-left: 220px; /* <--- Ergänzen */
}
/* Wenn Sidebar collapsed ist, reduziere margin-left */
.sidebar.collapsed ~ .page-content {
margin-left: 70px;
}
/* ==========================
Header
========================== */
.app-header {
position: fixed;
top: 0;
left: 0;
height: 60px;
width: 100%;
background-color: #e4d5c1;
color: #7c5617;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
z-index: 1100;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.logo {
height: 40px;
margin-right: 10px;
}
.app-title {
font-size: 1.5rem;
font-weight: bold;
}
.org-name {
font-size: 1rem;
color: #7c5617;
}
.header-right {
display: flex;
align-items: center;
}
/* ==========================
Sidebar
========================== */
.sidebar {
width: 220px;
transition: width 0.3s ease;
background-color: #e4d5c1;
color: black;
height: calc(100vh - 60px); /* Höhe minus Header */
top: 60px; /* Den gleichen Wert wie Header-Höhe verwenden */
left: 0;
z-index: 1000;
position: fixed; /* <--- Ändere das von relative zu fixed */
overflow-x: hidden;
overflow-y: auto;
}
.sidebar.collapsed {
width: 70px;
}
/* Sidebar Toggle Button (Burger-Icon) */
.sidebar-toggle {
text-align: right;
padding: 5px 10px;
}
.toggle-button {
background: none;
border: none;
color: #7c5617;
cursor: pointer;
font-size: 1.2rem;
transition: transform 0.1s ease, background-color 0.2s ease;
padding: 8px;
border-radius: 4px;
}
.toggle-button:hover {
background-color: #7c5617;
color: #e4d5c1;
transform: scale(1.05);
}
.toggle-button:active {
transform: scale(0.95);
}
/* Navigation in der Sidebar */
.sidebar-nav .nav-link {
color: #7c5617;
padding: 10px 15px;
display: flex;
align-items: center;
border-radius: 4px;
margin: 2px 8px;
transition: background-color 0.2s ease, color 0.2s ease;
}
.sidebar-nav .nav-link:hover {
background-color: #7c5617;
color: #e4d5c1;
}
.sidebar-nav .nav-link.active {
background-color: #7c5617;
color: #e4d5c1;
}
/* Text neben Icons */
.sidebar-label {
display: inline-block;
margin-left: 10px;
white-space: nowrap;
transition: opacity 0.3s ease, width 0.3s ease;
}
/* Wenn Sidebar collapsed ist, blendet das Label aus */
.sidebar.collapsed .sidebar-label {
opacity: 0;
width: 0;
overflow: hidden;
}
/* Tooltips (Bootstrap-Tooltips) */
.tooltip {
z-index: 2000;
background-color: #7c5617;
color: #e4d5c1;
}
/* Optional: Tooltips nur anzeigen, wenn Sidebar collapsed ist */
/* Da dash-bootstrap-components Tooltips in einen anderen DOM-Layer rendert,
kann man bei Bedarf per Callback steuern, ob sie geöffnet sind oder nicht.
Dieser Block ist nur ein Zusatz das Haupt-Show/Hiding erfolgt per
is_open-Callback. */
.sidebar:not(.collapsed) ~ .tooltip {
display: none !important;
}
/* ==========================
Responsive (bei Bedarf)
========================== */
/* @media (max-width: 768px) {
body {
padding-top: 60px; /* Header-Platz auch auf mobilen Geräten */
/* }
.sidebar {
position: fixed;
height: calc(100vh - 60px);
z-index: 1050;
}
.page-content {
margin-left: 0;
}
.sidebar.collapsed {
width: 0;
}
.sidebar.collapsed ~ .page-content {
margin-left: 0;
}
} */
.mantine-Modal-modal {
z-index: var(--mantine-z-index-modal, 3000) !important;
}
/* Modalbox */
.mantine-Modal-inner,
.mantine-Modal-content {
z-index: 4000 !important;
}
/* Popups (Dropdowns, Datepicker, Autocomplete, Menüs) innerhalb der Modalbox */
.mantine-Popover-dropdown,
.mantine-Select-dropdown,
.mantine-DatePicker-dropdown,
.mantine-Autocomplete-dropdown,
.mantine-Menu-dropdown {
z-index: 4100 !important;
}
/* Optional: Overlay für Popups noch höher, falls benötigt */
.mantine-Popover-root,
.mantine-Select-root,
.mantine-DatePicker-root,
.mantine-Autocomplete-root,
.mantine-Menu-root {
z-index: 4101 !important;
}
/* Sidebar collapsed: Icon-Farbe normal */
.sidebar.collapsed .sidebar-item-collapsed svg {
color: #7c5617; /* Icon-Linie/Text */
fill: #e4d5c1; /* Icon-Fläche */
width: 24px;
height: 24px;
margin: 0 auto;
display: block;
transition: color 0.2s, fill 0.2s;
}
/* Sidebar collapsed: Hintergrund und Icon invertieren bei Hover/Active */
.sidebar.collapsed .nav-link:hover,
.sidebar.collapsed .nav-link.active {
background-color: #7c5617 !important;
}
.sidebar.collapsed .nav-link:hover svg,
.sidebar.collapsed .nav-link.active svg {
color: #e4d5c1; /* Icon-Linie/Text invertiert */
fill: #7c5617; /* Icon-Fläche invertiert */
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

View File

@@ -1,370 +0,0 @@
from dash import Input, Output, State, callback, html, dcc, no_update, ctx
import dash_mantine_components as dmc
from dash_iconify import DashIconify
import dash_quill
from datetime import datetime, date, timedelta
import re
# --- Typ-spezifische Felder anzeigen ---
@callback(
Output('type-specific-fields', 'children'),
Input('type-input', 'value'),
prevent_initial_call=True
)
def show_type_specific_fields(event_type):
if not event_type:
return html.Div()
if event_type == "presentation":
return dmc.Stack([
dmc.Divider(label="Präsentations-Details", labelPosition="center"),
dmc.Group([
dcc.Upload(
id='presentation-upload',
children=dmc.Button(
"Datei hochladen",
leftSection=DashIconify(icon="mdi:upload"),
variant="outline"
),
style={'width': 'auto'}
),
dmc.TextInput(
label="Präsentationslink",
placeholder="https://...",
leftSection=DashIconify(icon="mdi:link"),
id="presentation-link",
style={"minWidth": 0, "flex": 1, "marginBottom": 0}
)
], grow=True, align="flex-end", style={"marginBottom": 10}),
html.Div(id="presentation-upload-status")
], gap="sm")
elif event_type == "video":
return dmc.Stack([
dmc.Divider(label="Video-Details", labelPosition="center"),
dmc.Group([
dcc.Upload(
id='video-upload',
children=dmc.Button(
"Video hochladen",
leftSection=DashIconify(icon="mdi:video-plus"),
variant="outline"
),
style={'width': 'auto'}
),
dmc.TextInput(
label="Videolink",
placeholder="https://youtube.com/...",
leftSection=DashIconify(icon="mdi:youtube"),
id="video-link",
style={"minWidth": 0, "flex": 1, "marginBottom": 0}
)
], grow=True, align="flex-end", style={"marginBottom": 10}),
dmc.Group([
dmc.Checkbox(
label="Endlos wiederholen",
id="video-endless",
checked=True,
style={"marginRight": 20}
),
dmc.NumberInput(
label="Wiederholungen",
id="video-repeats",
value=1,
min=1,
max=99,
step=1,
disabled=True,
style={"width": 150}
),
dmc.Slider(
label="Lautstärke",
id="video-volume",
value=70,
min=0,
max=100,
step=5,
marks=[
{"value": 0, "label": "0%"},
{"value": 50, "label": "50%"},
{"value": 100, "label": "100%"}
],
style={"flex": 1, "marginLeft": 20}
)
], grow=True, align="flex-end"),
html.Div(id="video-upload-status")
], gap="sm")
elif event_type == "website":
return dmc.Stack([
dmc.Divider(label="Website-Details", labelPosition="center"),
dmc.TextInput(
label="Website-URL",
placeholder="https://example.com",
leftSection=DashIconify(icon="mdi:web"),
id="website-url",
required=True,
style={"flex": 1}
)
], gap="sm")
elif event_type == "message":
return dmc.Stack([
dmc.Divider(label="Nachrichten-Details", labelPosition="center"),
dash_quill.Quill(
id="message-content",
value="",
),
dmc.Group([
dmc.Select(
label="Schriftgröße",
data=[
{"value": "small", "label": "Klein"},
{"value": "medium", "label": "Normal"},
{"value": "large", "label": "Groß"},
{"value": "xlarge", "label": "Sehr groß"}
],
id="message-font-size",
value="medium",
style={"flex": 1}
),
dmc.ColorPicker(
id="message-color",
value="#000000",
format="hex",
swatches=[
"#000000", "#ffffff", "#ff0000", "#00ff00",
"#0000ff", "#ffff00", "#ff00ff", "#00ffff"
]
)
], grow=True, align="flex-end")
], gap="sm")
return html.Div()
# --- Callback zum Aktivieren/Deaktivieren des Wiederholungsfelds bei Video ---
@callback(
Output("video-repeats", "disabled"),
Input("video-endless", "checked"),
prevent_initial_call=True
)
def toggle_video_repeats(endless_checked):
return endless_checked
# --- Upload-Status für Präsentation ---
@callback(
Output('presentation-upload-status', 'children'),
Input('presentation-upload', 'contents'),
State('presentation-upload', 'filename'),
prevent_initial_call=True
)
def update_presentation_upload_status(contents, filename):
if contents is not None and filename is not None:
return dmc.Alert(
f"✓ Datei '{filename}' erfolgreich hochgeladen",
color="green",
className="mt-2"
)
return html.Div()
# --- Upload-Status für Video ---
@callback(
Output('video-upload-status', 'children'),
Input('video-upload', 'contents'),
State('video-upload', 'filename'),
prevent_initial_call=True
)
def update_video_upload_status(contents, filename):
if contents is not None and filename is not None:
return dmc.Alert(
f"✓ Video '{filename}' erfolgreich hochgeladen",
color="green",
className="mt-2"
)
return html.Div()
# --- Wiederholungsoptionen aktivieren/deaktivieren ---
@callback(
[
Output('weekdays-select', 'disabled'),
Output('repeat-until-date', 'disabled'),
Output('skip-holidays-checkbox', 'disabled'),
Output('weekdays-select', 'value'),
Output('repeat-until-date', 'value'),
Output('skip-holidays-checkbox', 'checked')
],
Input('repeat-checkbox', 'checked'),
prevent_initial_call=True
)
def toggle_repeat_options(is_repeat):
if is_repeat:
next_month = datetime.now().date() + timedelta(weeks=4)
return (
False, # weekdays-select enabled
False, # repeat-until-date enabled
False, # skip-holidays-checkbox enabled
None, # weekdays value
next_month, # repeat-until-date value
False # skip-holidays-checkbox checked
)
else:
return (
True, # weekdays-select disabled
True, # repeat-until-date disabled
True, # skip-holidays-checkbox disabled
None, # weekdays value
None, # repeat-until-date value
False # skip-holidays-checkbox checked
)
# --- Dynamische Zeitoptionen für Startzeit ---
def validate_and_format_time(time_str):
if not time_str:
return None, "Keine Zeit angegeben"
if re.match(r'^\d{2}:\d{2}$', time_str):
try:
h, m = map(int, time_str.split(':'))
if 0 <= h <= 23 and 0 <= m <= 59:
return time_str, "Gültige Zeit"
except:
pass
patterns = [
(r'^(\d{1,2}):(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
(r'^(\d{1,2})\.(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
(r'^(\d{1,2})(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
(r'^(\d{1,2})$', lambda m: (int(m.group(1)), 0)),
]
for pattern, extractor in patterns:
match = re.match(pattern, time_str.strip())
if match:
try:
hours, minutes = extractor(match)
if 0 <= hours <= 23 and 0 <= minutes <= 59:
return f"{hours:02d}:{minutes:02d}", "Automatisch formatiert"
except:
continue
return None, "Ungültiges Zeitformat"
@callback(
[
Output('time-start', 'data'),
Output('start-time-feedback', 'children')
],
Input('time-start', 'searchValue'),
prevent_initial_call=True
)
def update_start_time_options(search_value):
time_options = [
{"value": f"{h:02d}:{m:02d}", "label": f"{h:02d}:{m:02d}"}
for h in range(6, 24) for m in [0, 30]
]
base_options = time_options.copy()
feedback = None
if search_value:
validated_time, status = validate_and_format_time(search_value)
if validated_time:
if not any(opt["value"] == validated_time for opt in base_options):
base_options.insert(0, {
"value": validated_time,
"label": f"{validated_time} (Ihre Eingabe)"
})
feedback = dmc.Text(f"{status}: {validated_time}", size="xs", c="green")
else:
feedback = dmc.Text(f"{status}", size="xs", c="red")
return base_options, feedback
# --- Dynamische Zeitoptionen für Endzeit ---
@callback(
[
Output('time-end', 'data'),
Output('end-time-feedback', 'children')
],
Input('time-end', 'searchValue'),
prevent_initial_call=True
)
def update_end_time_options(search_value):
time_options = [
{"value": f"{h:02d}:{m:02d}", "label": f"{h:02d}:{m:02d}"}
for h in range(6, 24) for m in [0, 30]
]
base_options = time_options.copy()
feedback = None
if search_value:
validated_time, status = validate_and_format_time(search_value)
if validated_time:
if not any(opt["value"] == validated_time for opt in base_options):
base_options.insert(0, {
"value": validated_time,
"label": f"{validated_time} (Ihre Eingabe)"
})
feedback = dmc.Text(f"{status}: {validated_time}", size="xs", c="green")
else:
feedback = dmc.Text(f"{status}", size="xs", c="red")
return base_options, feedback
# --- Automatische Endzeit-Berechnung mit Validation ---
@callback(
Output('time-end', 'value'),
[
Input('time-start', 'value'),
Input('btn-reset', 'n_clicks')
],
State('time-end', 'value'),
prevent_initial_call=True
)
def handle_end_time(start_time, reset_clicks, current_end_time):
if not ctx.triggered:
return no_update
trigger_id = ctx.triggered[0]['prop_id'].split('.')[0]
if trigger_id == 'btn-reset' and reset_clicks:
return None
if trigger_id == 'time-start' and start_time:
if current_end_time:
return no_update
try:
validated_start, _ = validate_and_format_time(start_time)
if validated_start:
start_dt = datetime.strptime(validated_start, "%H:%M")
end_dt = start_dt + timedelta(hours=1, minutes=30)
if end_dt.hour >= 24:
end_dt = end_dt.replace(hour=23, minute=59)
return end_dt.strftime("%H:%M")
except:
pass
return no_update
# --- Reset-Funktion erweitert ---
@callback(
[
Output('title-input', 'value'),
Output('start-date-input', 'value', allow_duplicate=True),
Output('time-start', 'value'),
Output('type-input', 'value'),
Output('description-input', 'value'),
Output('repeat-checkbox', 'checked'),
Output('weekdays-select', 'value', allow_duplicate=True),
Output('repeat-until-date', 'value', allow_duplicate=True),
Output('skip-holidays-checkbox', 'checked', allow_duplicate=True)
],
Input('btn-reset', 'n_clicks'),
prevent_initial_call=True
)
def reset_form(n_clicks):
if n_clicks:
return "", datetime.now().date(), "09:00", None, "", False, None, None, False
return no_update
# --- Speichern-Funktion (Demo) ---
@callback(
Output('save-feedback', 'children'),
Input('btn-save', 'n_clicks'),
prevent_initial_call=True
)
def save_appointments_demo(n_clicks):
if not n_clicks:
return no_update
return dmc.Alert(
"Demo: Termine würden hier gespeichert werden",
color="blue",
title="Speichern (Demo)"
)

View File

@@ -1,168 +0,0 @@
import logging
from math import fabs
logger = logging.getLogger(__name__)
# dashboard/callbacks/appointments_callbacks.py
import requests
import json
from flask import session
from dash import Input, Output, State, callback, ctx, dash, no_update
import os
import sys
from datetime import datetime, timedelta
# This message will now appear in the terminal during startup
logger.debug("Registering appointments page...")
# --- Modalbox öffnen: jetzt auch auf Kalenderklick reagieren ---
sys.path.append('/workspace')
print("appointments_callbacks.py geladen")
API_BASE_URL = os.getenv("API_BASE_URL", "http://192.168.43.100")
ENV = os.getenv("ENV", "development")
@callback(
dash.Output('output', 'children'),
dash.Input('calendar', 'lastDateClick')
)
def display_date(date_str):
if date_str:
return f"Letzter Klick auf: {date_str}"
return "Klicke auf ein Datum im Kalender!"
@callback(
dash.Output('event-output', 'children'),
dash.Input('calendar', 'lastEventClick')
)
def display_event(event_id):
if event_id:
return f"Letztes Event geklickt: {event_id}"
return "Klicke auf ein Event im Kalender!"
@callback(
dash.Output('select-output', 'children'),
dash.Input('calendar', 'lastSelect')
)
def display_select(select_info):
if select_info:
return f"Markiert: {select_info['start']} bis {select_info['end']} (ganztägig: {select_info['allDay']})"
return "Markiere einen Bereich im Kalender!"
@callback(
dash.Output('calendar', 'events'),
dash.Input('calendar', 'lastNavClick'),
)
def load_events(view_dates):
logger.info(f"Lade Events für Zeitraum: {view_dates}")
if not view_dates or "start" not in view_dates or "end" not in view_dates:
return []
start = view_dates["start"]
end = view_dates["end"]
try:
verify_ssl = True if ENV == "production" else False
resp = requests.get(
f"{API_BASE_URL}/api/events",
params={"start": start, "end": end},
verify=verify_ssl
)
resp.raise_for_status()
events = resp.json()
return events
except Exception as e:
logger.info(f"Fehler beim Laden der Events: {e}")
return []
# --- Modalbox öffnen ---
@callback(
[
Output("appointment-modal", "opened"),
Output("start-date-input", "value", allow_duplicate=True),
Output("time-start", "value", allow_duplicate=True),
Output("time-end", "value", allow_duplicate=True),
],
[
Input("calendar", "lastDateClick"),
Input("calendar", "lastSelect"),
Input("open-appointment-modal-btn", "n_clicks"),
Input("close-appointment-modal-btn", "n_clicks"),
],
State("appointment-modal", "opened"),
prevent_initial_call=True
)
def open_modal(date_click, select, open_click, close_click, is_open):
trigger = ctx.triggered_id
# Bereichsauswahl (lastSelect)
if trigger == "calendar" and select:
try:
start_dt = datetime.fromisoformat(select["start"])
end_dt = datetime.fromisoformat(select["end"])
return (
True,
start_dt.date().isoformat(),
start_dt.strftime("%H:%M"),
end_dt.strftime("%H:%M"),
)
except Exception as e:
print("Fehler beim Parsen von select:", e)
return no_update, no_update, no_update, no_update
# Einzelklick (lastDateClick)
if trigger == "calendar" and date_click:
try:
dt = datetime.fromisoformat(date_click)
# Versuche, die Slotlänge aus dem Kalender zu übernehmen (optional)
# Hier als Beispiel 30 Minuten aufaddieren, falls keine Endzeit vorhanden
end_dt = dt + timedelta(minutes=30)
return (
True,
dt.date().isoformat(),
dt.strftime("%H:%M"),
end_dt.strftime("%H:%M"),
)
except Exception as e:
print("Fehler beim Parsen von date_click:", e)
return no_update, no_update, no_update, no_update
# Modal öffnen per Button
if trigger == "open-appointment-modal-btn" and open_click:
now = datetime.now()
end_dt = now + timedelta(minutes=30)
return True, now.date().isoformat(), now.strftime("%H:%M"), end_dt.strftime("%H:%M")
# Modal schließen
if trigger == "close-appointment-modal-btn" and close_click:
return False, no_update, no_update, no_update
return is_open, no_update, no_update, no_update
# @callback(
# Output("time-end", "value", allow_duplicate=True),
# Input("time-start", "value"),
# prevent_initial_call=True
# )
# def handle_end_time(start_time, duration="00:30"):
# trigger = ctx.triggered_id
# if trigger == "time-start" and start_time and duration:
# try:
# # Beispiel für start_time: "09:00"
# start_dt = datetime.strptime(start_time, "%H:%M")
# # Dauer in Stunden und Minuten, z.B. "01:30"
# hours, minutes = map(int, duration.split(":"))
# # Endzeit berechnen: Dauer addieren!
# end_dt = start_dt + timedelta(hours=hours, minutes=minutes)
# return end_dt.strftime("%H:%M")
# except Exception as e:
# print("Fehler bei der Berechnung der Endzeit:", e)
# return no_update

View File

@@ -1,31 +0,0 @@
# dashboard/callbacks/auth_callbacks.py
import dash
from dash import Input, Output, State, dcc
from flask import session
from utils.auth import check_password, get_user_role
from config import ENV
from utils.db import execute_query
@dash.callback(
Output("login-feedback", "children"),
Output("header-right", "children"),
Input("btn-login", "n_clicks"),
State("input-user", "value"),
State("input-pass", "value"),
prevent_initial_call=True
)
def login_user(n_clicks, username, password):
if ENV == "development":
# DevBypass: setze immer AdminSession und leite weiter
session["username"] = "dev_admin"
session["role"] = "admin"
return dcc.Location(href="/overview", id="redirect-dev"), None
# ProduktionsLogin: User in DB suchen
user = execute_query("SELECT username, pwd_hash, role FROM users WHERE username=%s", (username,), fetch_one=True)
if user and check_password(password, user["pwd_hash"]):
session["username"] = user["username"]
session["role"] = user["role"]
return dcc.Location(href="/overview", id="redirect-ok"), None
else:
return "Ungültige Zugangsdaten.", None

View File

@@ -1,139 +0,0 @@
# dashboard/callbacks/overview_callbacks.py
import sys
sys.path.append('/workspace')
import threading
import dash
import requests
from dash import Input, Output, State, MATCH, html, dcc
from flask import session
from utils.db import get_session # Diese Funktion muss eine SQLAlchemy-Session liefern!
from utils.mqtt_client import publish, start_loop
from config import ENV
import dash_bootstrap_components as dbc
import os
import time
import pytz
from datetime import datetime
print("overview_callbacks.py geladen")
API_BASE_URL = os.getenv("API_BASE_URL", "https://192.168.43.100")
mqtt_thread_started = False
SCREENSHOT_DIR = "received-screenshots"
def ensure_mqtt_running():
global mqtt_thread_started
if not mqtt_thread_started:
thread = threading.Thread(target=start_loop, daemon=True)
thread.start()
mqtt_thread_started = True
def get_latest_screenshot(client_uuid):
cache_buster = int(time.time()) # aktuelle Unix-Zeit in Sekunden
# TODO: Hier genau im Produkitv-Modus die IPs testen!
# Wenn API_BASE_URL auf "http" beginnt, absolute URL verwenden (z.B. im lokalen Dev)
if API_BASE_URL.startswith("http"):
return f"{API_BASE_URL}/screenshots/{client_uuid}?t={cache_buster}"
# Sonst relative URL (nginx-Proxy übernimmt das Routing)
return f"/screenshots/{client_uuid}?t={cache_buster}"
def fetch_clients():
try:
verify_ssl = True if ENV == "production" else False
resp = requests.get(
f"{API_BASE_URL}/api/clients",
verify=verify_ssl
)
resp.raise_for_status()
return resp.json()
except Exception as e:
print("Fehler beim Abrufen der Clients:", e)
return []
@dash.callback(
Output("clients-cards-container", "children"),
Input("interval-update", "n_intervals")
)
def update_clients(n):
# ... Session-Handling wie gehabt ...
ensure_mqtt_running()
clients = fetch_clients()
cards = []
for client in clients:
uuid = client["uuid"]
screenshot = get_latest_screenshot(uuid)
last_alive_utc = client.get("last_alive")
if last_alive_utc:
try:
# Unterstützt sowohl "2024-06-08T12:34:56Z" als auch "2024-06-08T12:34:56"
if last_alive_utc.endswith("Z"):
dt_utc = datetime.strptime(last_alive_utc, "%Y-%m-%dT%H:%M:%SZ")
else:
dt_utc = datetime.strptime(last_alive_utc, "%Y-%m-%dT%H:%M:%S")
dt_utc = dt_utc.replace(tzinfo=pytz.UTC)
# Lokale Zeitzone fest angeben, z.B. Europe/Berlin
local_tz = pytz.timezone("Europe/Berlin")
dt_local = dt_utc.astimezone(local_tz)
last_alive_str = dt_local.strftime("%d.%m.%Y %H:%M:%S")
except Exception:
last_alive_str = last_alive_utc
else:
last_alive_str = "-"
card = dbc.Card(
[
dbc.CardHeader(client["location"]),
dbc.CardBody([
html.Img(
src=screenshot,
style={
"width": "240px",
"height": "135px",
"object-fit": "cover",
"display": "block",
"margin-left": "auto",
"margin-right": "auto"
},
),
html.P(f"IP: {client['ip_address'] or '-'}", className="card-text"),
html.P(f"Letzte Aktivität: {last_alive_str}", className="card-text"),
dbc.ButtonGroup([
dbc.Button("Reload Page", color="primary", id={"type": "btn-reload", "index": uuid}, n_clicks=0),
dbc.Button("Restart Client", color="danger", id={"type": "btn-restart", "index": uuid}, n_clicks=0),
], className="mt-2"),
html.Div(id={"type": "restart-feedback", "index": uuid}),
html.Div(id={"type": "reload-feedback", "index": uuid}),
]),
],
className="mb-4",
style={"width": "18rem"},
)
cards.append(dbc.Col(card, width=4))
return dbc.Row(cards)
@dash.callback(
Output({"type": "restart-feedback", "index": MATCH}, "children"),
Input({"type": "btn-restart", "index": MATCH}, "n_clicks"),
State({"type": "btn-restart", "index": MATCH}, "id")
)
def on_restart(n_clicks, btn_id):
if n_clicks and n_clicks > 0:
cid = btn_id["index"]
payload = '{"command": "restart"}'
ok = publish(f"clients/{cid}/control", payload)
return "Befehl gesendet." if ok else "Fehler beim Senden."
return ""
@dash.callback(
Output({"type": "reload-feedback", "index": MATCH}, "children"),
Input({"type": "btn-reload", "index": MATCH}, "n_clicks"),
State({"type": "btn-reload", "index": MATCH}, "id")
)
def on_reload(n_clicks, btn_id):
if n_clicks and n_clicks > 0:
cid = btn_id["index"]
payload = '{"command": "reload"}'
ok = publish(f"clients/{cid}/control", payload)
return "Befehl gesendet." if ok else "Fehler beim Senden."
return ""

View File

@@ -1,20 +0,0 @@
# dashboard/callbacks/settings_callbacks.py
import dash
from dash import Input, Output, State, dcc
from flask import session
from utils.db import execute_query, execute_non_query
@dash.callback(
Output("settings-feedback", "children"),
Input("btn-save-settings", "n_clicks"),
State("input-default-volume", "value"),
prevent_initial_call=True
)
def save_settings(n_clicks, volume):
if "role" not in session:
return dcc.Location(href="/login")
if n_clicks and n_clicks > 0:
sql = "UPDATE global_settings SET value=%s WHERE key='default_volume'"
rc = execute_non_query(sql, (volume,))
return "Einstellungen gespeichert." if rc else "Speichern fehlgeschlagen."
return ""

View File

@@ -1,26 +0,0 @@
# dashboard/callbacks/ui_callbacks.py
from dash import Input, Output, State, callback
from components.sidebar import Sidebar
@callback(
Output("sidebar", "children"),
Output("sidebar", "className"),
Input("sidebar-state", "data"),
)
def render_sidebar(data):
collapsed = data.get("collapsed", False)
return Sidebar(collapsed=collapsed), f"sidebar{' collapsed' if collapsed else ''}"
@callback(
Output("sidebar-state", "data"),
Input("btn-toggle-sidebar", "n_clicks"),
State("sidebar-state", "data"),
prevent_initial_call=True,
)
def toggle_sidebar(n, data):
if n is None:
# Kein Klick, nichts ändern!
return data
collapsed = not data.get("collapsed", False)
return {"collapsed": collapsed}

View File

@@ -1,24 +0,0 @@
# dashboard/callbacks/users_callbacks.py
import dash
from dash import Input, Output, State, dcc
from flask import session
from utils.db import execute_query, execute_non_query
from utils.auth import hash_password
@dash.callback(
Output("users-feedback", "children"),
Input("btn-new-user", "n_clicks"),
State("input-new-username", "value"),
State("input-new-password", "value"),
State("input-new-role", "value"),
prevent_initial_call=True
)
def create_user(n_clicks, uname, pwd, role):
if session.get("role") != "admin":
return "Keine Berechtigung."
if n_clicks and n_clicks > 0:
pwd_hash = hash_password(pwd)
sql = "INSERT INTO users (username, pwd_hash, role) VALUES (%s, %s, %s)"
rc = execute_non_query(sql, (uname, pwd_hash, role))
return "Benutzer erstellt." if rc else "Fehler beim Erstellen."
return ""

View File

@@ -1,258 +0,0 @@
from dash import html, dcc
import dash_mantine_components as dmc
from dash_iconify import DashIconify
import dash_quill
def create_input_with_tooltip_full(component, tooltip_text):
return dmc.Stack([
dmc.Group([
component,
dmc.Tooltip(
children=[
dmc.ActionIcon(
DashIconify(icon="mdi:help-circle-outline", width=16),
variant="subtle",
color="gray",
size="sm"
)
],
label=tooltip_text,
position="top",
multiline=True,
w=300
)
], gap="xs", align="flex-end")
], gap=0)
def create_input_with_tooltip_time(component, tooltip_text):
return dmc.Group([
component,
dmc.Tooltip(
children=[
dmc.ActionIcon(
DashIconify(icon="mdi:help-circle-outline", width=16),
variant="subtle",
color="gray",
size="sm"
)
],
label=tooltip_text,
position="top",
multiline=True,
w=300
)
], gap="xs", align="flex-end")
def get_appointment_modal():
weekday_options = [
{"value": "0", "label": "Montag"},
{"value": "1", "label": "Dienstag"},
{"value": "2", "label": "Mittwoch"},
{"value": "3", "label": "Donnerstag"},
{"value": "4", "label": "Freitag"},
{"value": "5", "label": "Samstag"},
{"value": "6", "label": "Sonntag"}
]
time_options = [
{"value": f"{h:02d}:{m:02d}", "label": f"{h:02d}:{m:02d}"}
for h in range(6, 24) for m in [0, 30]
]
return dmc.Modal(
id="appointment-modal",
title="Neuen Termin anlegen",
centered=True,
size="auto", # oder "80vw"
children=[
dmc.Container([
dmc.Grid([
dmc.GridCol([
dmc.Paper([
dmc.Title("Termindetails", order=3, className="mb-3"),
dmc.Stack([
create_input_with_tooltip_full(
dmc.TextInput(
label="Titel",
placeholder="Terminbezeichnung eingeben",
leftSection=DashIconify(icon="mdi:calendar-text"),
id="title-input",
required=True,
style={"flex": 1}
),
"Geben Sie eine aussagekräftige Bezeichnung für den Termin ein"
),
create_input_with_tooltip_full(
dmc.DatePickerInput(
label="Startdatum",
id="start-date-input",
firstDayOfWeek=1,
weekendDays=[0, 6],
valueFormat="DD.MM.YYYY",
placeholder="Datum auswählen",
leftSection=DashIconify(icon="mdi:calendar"),
clearable=False,
style={"flex": 1}
),
"Wählen Sie das Datum für den Termin aus dem Kalender"
),
dmc.Grid([
dmc.GridCol([
dmc.Stack([
create_input_with_tooltip_time(
dmc.Select(
label="Startzeit",
placeholder="Zeit auswählen",
data=time_options,
searchable=True,
clearable=True,
id="time-start",
value="09:00",
required=True,
style={"flex": 1}
),
"Eingabe: 10:25, 10.25, 1025 oder 10 (wird automatisch formatiert)"
),
html.Div(id="start-time-feedback")
], gap="xs")
], span=6),
dmc.GridCol([
dmc.Stack([
create_input_with_tooltip_time(
dmc.Select(
label="Endzeit",
placeholder="Zeit auswählen",
data=time_options,
searchable=True,
clearable=True,
id="time-end",
required=True,
style={"flex": 1}
),
"Endzeit muss nach der Startzeit am selben Tag liegen. Termine können nicht über Mitternacht hinausgehen."
),
html.Div(id="end-time-feedback")
], gap="xs")
], span=6)
]),
create_input_with_tooltip_full(
dmc.Select(
label="Termintyp",
placeholder="Typ auswählen",
data=[
{"value": "presentation", "label": "Präsentation"},
{"value": "website", "label": "Website"},
{"value": "video", "label": "Video"},
{"value": "message", "label": "Nachricht"},
{"value": "webuntis", "label": "WebUntis"},
{"value": "other", "label": "Sonstiges"}
],
id="type-input",
required=True,
style={"flex": 1}
),
"Wählen Sie die Art der Präsentation aus."
),
html.Div(id="type-specific-fields"),
create_input_with_tooltip_full(
dmc.Textarea(
label="Beschreibung",
placeholder="Zusätzliche Informationen...",
minRows=3,
autosize=True,
id="description-input",
style={"flex": 1}
),
"Optionale Beschreibung mit weiteren Details zum Termin"
)
], gap="md")
], p="md", shadow="sm")
], span=6),
dmc.GridCol([
dmc.Paper([
dmc.Title("Wiederholung", order=3, className="mb-3"),
dmc.Stack([
create_input_with_tooltip_full(
dmc.Checkbox(
label="Wiederholender Termin",
id="repeat-checkbox",
description="Aktivieren für wöchentliche Wiederholung"
),
"Aktivieren Sie diese Option, um den Termin an mehreren Wochentagen zu wiederholen"
),
html.Div(id="repeat-options", children=[
create_input_with_tooltip_full(
dmc.MultiSelect(
label="Wochentage",
placeholder="Wochentage auswählen",
data=weekday_options,
id="weekdays-select",
description="An welchen Wochentagen soll der Termin stattfinden?",
disabled=True,
style={"flex": 1}
),
"Wählen Sie mindestens einen Wochentag für die Wiederholung aus"
),
dmc.Space(h="md"),
create_input_with_tooltip_full(
dmc.DatePickerInput(
label="Wiederholung bis",
id="repeat-until-date",
firstDayOfWeek=1,
weekendDays=[0, 6],
valueFormat="DD.MM.YYYY",
placeholder="Enddatum auswählen",
leftSection=DashIconify(icon="mdi:calendar-end"),
description="Letzter Tag der Wiederholung",
disabled=True,
clearable=True,
style={"flex": 1}
),
"Datum bis wann die Wiederholung stattfinden soll. Muss nach dem Startdatum liegen."
),
dmc.Space(h="lg"),
create_input_with_tooltip_full(
dmc.Checkbox(
label="Ferientage berücksichtigen",
id="skip-holidays-checkbox",
description="Termine an Feiertagen und in Schulferien auslassen",
disabled=True
),
"Aktivieren Sie diese Option, um Termine an deutschen Feiertagen und in Schulferien automatisch zu überspringen"
)
])
], gap="md")
], p="md", shadow="sm"),
dmc.Paper([
dmc.Title("Aktionen", order=3, className="mb-3"),
dmc.Stack([
dmc.Button(
"Termin(e) speichern",
color="green",
leftSection=DashIconify(icon="mdi:content-save"),
id="btn-save",
size="lg",
fullWidth=True
),
dmc.Button(
"Zurücksetzen",
color="gray",
variant="outline",
leftSection=DashIconify(icon="mdi:refresh"),
id="btn-reset",
fullWidth=True
),
dmc.Button(
"Schließen",
id="close-appointment-modal-btn",
color="red", # oder "danger"
leftSection=DashIconify(icon="mdi:close"),
variant="filled",
style={"marginBottom": 10}
),
html.Div(id="save-feedback", className="mt-3")
], gap="md")
], p="md", shadow="sm", className="mt-3")
], span=6)
])
], size="lg")
]
)

View File

@@ -1,13 +0,0 @@
# dashboard/components/header.py
from dash import html
def Header():
return html.Div(
className="app-header",
children=[
html.Img(src="/assets/logo.png", className="logo"),
html.Span("Infoscreen-Manager", className="app-title"),
html.Span(" Organisationsname", className="org-name"),
html.Div(id="header-right", className="header-right") # Platzhalter für Login/ProfilButton
]
)

View File

@@ -1,72 +0,0 @@
# dashboard/components/sidebar.py
from dash import html
import dash_bootstrap_components as dbc
from dash_iconify import DashIconify
def Sidebar(collapsed: bool = False):
"""
Gibt nur den Inhalt der Sidebar zurück (ohne das äußere div mit id="sidebar").
Das äußere div wird bereits in app.py definiert.
"""
nav_items = [
{"label": "Übersicht", "href": "/overview", "icon": "mdi:view-dashboard"},
{"label": "Termine", "href": "/appointments","icon": "mdi:calendar"},
{"label": "Bildschirme", "href": "/clients", "icon": "mdi:monitor"},
{"label": "Einstellungen","href": "/settings", "icon": "mdi:cog"},
{"label": "Benutzer", "href": "/users", "icon": "mdi:account"},
]
if collapsed:
nav_links = [
dbc.NavLink(
DashIconify(icon=item["icon"], width=24),
href=item["href"],
active="exact",
className="sidebar-item-collapsed",
id={"type": "nav-item", "index": item["label"]},
)
for item in nav_items
]
else:
nav_links = [
dbc.NavLink(
[
DashIconify(icon=item["icon"], width=24),
html.Span(item["label"], className="ms-2 sidebar-label"),
],
href=item["href"],
active="exact",
className="sidebar-item",
id={"type": "nav-item", "index": item["label"]},
)
for item in nav_items
]
return [
html.Div(
className="sidebar-toggle",
children=html.Button(
DashIconify(icon="mdi:menu", width=28),
id="btn-toggle-sidebar",
className="toggle-button",
)
),
dbc.Collapse(
dbc.Nav(
nav_links,
vertical=True,
pills=True,
className="sidebar-nav",
),
is_open=not collapsed,
className="sidebar-nav",
) if not collapsed else
dbc.Nav(
nav_links,
vertical=True,
pills=True,
className="sidebar-nav-collapsed",
),
]

View File

@@ -1,28 +0,0 @@
# dashboard/config.py
import os
from dotenv import load_dotenv
# .env aus RootVerzeichnis laden
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
load_dotenv(os.path.join(base_dir, ".env"))
# DBEinstellungen
DB_HOST = os.getenv("DB_HOST")
DB_PORT = int(os.getenv("DB_PORT", "3306"))
DB_USER = os.getenv("DB_USER")
DB_PASSWORD = os.getenv("DB_PASSWORD")
DB_NAME = os.getenv("DB_NAME")
DB_POOL_NAME = os.getenv("DB_POOL_NAME", "my_pool")
DB_POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "5"))
# MQTTEinstellungen
MQTT_BROKER_HOST = os.getenv("MQTT_BROKER_HOST")
MQTT_BROKER_PORT = int(os.getenv("MQTT_BROKER_PORT", "1883"))
MQTT_USERNAME = os.getenv("MQTT_USERNAME")
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD")
MQTT_KEEPALIVE = int(os.getenv("MQTT_KEEPALIVE", "60"))
MQTT_CLIENT_ID = os.getenv("MQTT_CLIENT_ID")
# Sonstige Einstellungen
SECRET_KEY = os.getenv("SECRET_KEY", "changeme")
ENV = os.getenv("ENV", "development")

View File

@@ -1,402 +0,0 @@
import dash
import full_calendar_component as fcc
from dash import *
import dash_mantine_components as dmc
from dash.exceptions import PreventUpdate
from datetime import datetime, date, timedelta
import dash_quill
# dash._dash_renderer._set_react_version('18.2.0')
app = Dash(__name__, prevent_initial_callbacks=True)
quill_mods = [
[{"header": "1"}, {"header": "2"}, {"font": []}],
[{"size": []}],
["bold", "italic", "underline", "strike", "blockquote"],
[{"list": "ordered"}, {"list": "bullet"}, {"indent": "-1"}, {"indent": "+1"}],
["link", "image"],
]
# Get today's date
today = datetime.now()
# Format the date
formatted_date = today.strftime("%Y-%m-%d")
app.layout = html.Div(
[
fcc.FullCalendarComponent(
id="calendar", # Unique ID for the component
initialView="listWeek", # dayGridMonth, timeGridWeek, timeGridDay, listWeek,
# dayGridWeek, dayGridYear, multiMonthYear, resourceTimeline, resourceTimeGridDay, resourceTimeLineWeek
headerToolbar={
"left": "prev,next today",
"center": "",
"right": "listWeek,timeGridDay,timeGridWeek,dayGridMonth",
}, # Calendar header
initialDate=f"{formatted_date}", # Start date for calendar
editable=True, # Allow events to be edited
selectable=True, # Allow dates to be selected
events=[],
nowIndicator=True, # Show current time indicator
navLinks=True, # Allow navigation to other dates
),
dmc.MantineProvider(
theme={"colorScheme": "dark"},
children=[
dmc.Modal(
id="modal",
size="xl",
title="Event Details",
zIndex=10000,
children=[
html.Div(id="modal_event_display_context"),
dmc.Space(h=20),
dmc.Group(
[
dmc.Button(
"Close",
color="red",
variant="outline",
id="modal-close-button",
),
],
pos="right",
),
],
)
],
),
dmc.MantineProvider(
theme={"colorScheme": "dark"},
children=[
dmc.Modal(
id="add_modal",
title="New Event",
size="xl",
children=[
dmc.Grid(
children=[
dmc.GridCol(
html.Div(
dmc.DatePickerInput(
id="start_date",
label="Start Date",
value=datetime.now().date(),
styles={"width": "100%"},
disabled=True,
),
style={"width": "100%"},
),
span=6,
),
dmc.GridCol(
html.Div(
dmc.TimeInput(
label="Start Time",
withSeconds=True,
value=datetime.now(),
# format="12",
id="start_time",
),
style={"width": "100%"},
),
span=6,
),
],
gutter="xl",
),
dmc.Grid(
children=[
dmc.GridCol(
html.Div(
dmc.DatePickerInput(
id="end_date",
label="End Date",
value=datetime.now().date(),
styles={"width": "100%"},
),
style={"width": "100%"},
),
span=6,
),
dmc.GridCol(
html.Div(
dmc.TimeInput(
label="End Time",
withSeconds=True,
value=datetime.now(),
# format="12",
id="end_time",
),
style={"width": "100%"},
),
span=6,
),
],
gutter="xl",
),
dmc.Grid(
children=[
dmc.GridCol(
span=6,
children=[
dmc.TextInput(
label="Event Title:",
style={"width": "100%"},
id="event_name_input",
required=True,
)
],
),
dmc.GridCol(
span=6,
children=[
dmc.Select(
label="Select event color",
placeholder="Select one",
id="event_color_select",
value="ng",
data=[
{
"value": "bg-gradient-primary",
"label": "bg-gradient-primary",
},
{
"value": "bg-gradient-secondary",
"label": "bg-gradient-secondary",
},
{
"value": "bg-gradient-success",
"label": "bg-gradient-success",
},
{
"value": "bg-gradient-info",
"label": "bg-gradient-info",
},
{
"value": "bg-gradient-warning",
"label": "bg-gradient-warning",
},
{
"value": "bg-gradient-danger",
"label": "bg-gradient-danger",
},
{
"value": "bg-gradient-light",
"label": "bg-gradient-light",
},
{
"value": "bg-gradient-dark",
"label": "bg-gradient-dark",
},
{
"value": "bg-gradient-white",
"label": "bg-gradient-white",
},
],
style={"width": "100%", "marginBottom": 10},
required=True,
)
],
),
]
),
dash_quill.Quill(
id="rich_text_input",
modules={
"toolbar": quill_mods,
"clipboard": {
"matchVisual": False,
},
},
),
dmc.Accordion(
children=[
dmc.AccordionItem(
[
dmc.AccordionControl("Raw HTML"),
dmc.AccordionPanel(
html.Div(
id="rich_text_output",
style={
"height": "300px",
"overflowY": "scroll",
},
)
),
],
value="raw_html",
),
],
),
dmc.Space(h=20),
dmc.Group(
[
dmc.Button(
"Submit",
id="modal_submit_new_event_button",
color="green",
),
dmc.Button(
"Close",
color="red",
variant="outline",
id="modal_close_new_event_button",
),
],
pos="right",
),
],
),
],
),
]
)
@app.callback(
Output("modal", "opened"),
Output("modal", "title"),
Output("modal_event_display_context", "children"),
Input("modal-close-button", "n_clicks"),
Input("calendar", "clickedEvent"),
State("modal", "opened"),
)
def open_event_modal(n, clickedEvent, opened):
ctx = callback_context
if not ctx.triggered:
raise PreventUpdate
else:
button_id = ctx.triggered[0]["prop_id"].split(".")[0]
if button_id == "calendar" and clickedEvent is not None:
event_title = clickedEvent["title"]
event_context = clickedEvent["extendedProps"]["context"]
return (
True,
event_title,
html.Div(
dash_quill.Quill(
id="input3",
value=f"{event_context}",
modules={
"toolbar": False,
"clipboard": {
"matchVisual": False,
},
},
),
style={"width": "100%", "overflowY": "auto"},
),
)
elif button_id == "modal-close-button" and n is not None:
return False, dash.no_update, dash.no_update
return opened, dash.no_update
@app.callback(
Output("add_modal", "opened"),
Output("start_date", "value"),
Output("end_date", "value"),
Output("start_time", "value"),
Output("end_time", "value"),
Input("calendar", "dateClicked"),
Input("modal_close_new_event_button", "n_clicks"),
State("add_modal", "opened"),
)
def open_add_modal(dateClicked, close_clicks, opened):
ctx = callback_context
if not ctx.triggered:
raise PreventUpdate
else:
button_id = ctx.triggered[0]["prop_id"].split(".")[0]
if button_id == "calendar" and dateClicked is not None:
try:
start_time = datetime.strptime(dateClicked, "%Y-%m-%dT%H:%M:%S%z").time()
start_date_obj = datetime.strptime(dateClicked, "%Y-%m-%dT%H:%M:%S%z")
start_date = start_date_obj.strftime("%Y-%m-%d")
end_date = start_date_obj.strftime("%Y-%m-%d")
except ValueError:
start_time = datetime.now().time()
start_date_obj = datetime.strptime(dateClicked, "%Y-%m-%d")
start_date = start_date_obj.strftime("%Y-%m-%d")
end_date = start_date_obj.strftime("%Y-%m-%d")
end_time = datetime.combine(date.today(), start_time) + timedelta(hours=1)
start_time_str = start_time.strftime("%Y-%m-%d %H:%M:%S")
end_time_str = end_time.strftime("%Y-%m-%d %H:%M:%S")
return True, start_date, end_date, start_time_str, end_time_str
elif button_id == "modal_close_new_event_button" and close_clicks is not None:
return False, dash.no_update, dash.no_update, dash.no_update, dash.no_update
return opened, dash.no_update, dash.no_update, dash.no_update, dash.no_update
@app.callback(
Output("calendar", "events"),
Output("add_modal", "opened", allow_duplicate=True),
Output("event_name_input", "value"),
Output("event_color_select", "value"),
Output("rich_text_input", "value"),
Input("modal_submit_new_event_button", "n_clicks"),
State("start_date", "value"),
State("start_time", "value"),
State("end_date", "value"),
State("end_time", "value"),
State("event_name_input", "value"),
State("event_color_select", "value"),
State("rich_text_output", "children"),
State("calendar", "events"),
)
def add_new_event(
n,
start_date,
start_time,
end_date,
end_time,
event_name,
event_color,
event_context,
current_events,
):
if n is None:
raise PreventUpdate
start_time_obj = datetime.strptime(start_time, "%Y-%m-%d %H:%M:%S")
end_time_obj = datetime.strptime(end_time, "%Y-%m-%d %H:%M:%S")
start_time_str = start_time_obj.strftime("%H:%M:%S")
end_time_str = end_time_obj.strftime("%H:%M:%S")
start_date = f"{start_date}T{start_time_str}"
end_date = f"{end_date}T{end_time_str}"
new_event = {
"title": event_name,
"start": start_date,
"end": end_date,
"className": event_color,
"context": event_context,
}
return current_events + [new_event], False, "", "bg-gradient-primary", ""
@app.callback(
Output("rich_text_output", "children"),
[Input("rich_text_input", "value")],
[State("rich_text_input", "charCount")],
)
def display_output(value, charCount):
return value
if __name__ == "__main__":
app.run(host='0.0.0.0', debug=True, port=8050)

View File

@@ -1,83 +0,0 @@
"""
Collapsible navbar on both desktop and mobile
"""
import dash_mantine_components as dmc
from dash import Dash, Input, Output, State, callback
from dash_iconify import DashIconify
app = Dash(external_stylesheets=dmc.styles.ALL)
logo = "https://github.com/user-attachments/assets/c1ff143b-4365-4fd1-880f-3e97aab5c302"
def get_icon(icon):
return DashIconify(icon=icon, height=16)
layout = dmc.AppShell(
[
dmc.AppShellHeader(
dmc.Group(
[
dmc.Burger(
id="mobile-burger",
size="sm",
hiddenFrom="sm",
opened=False,
),
dmc.Burger(
id="desktop-burger",
size="sm",
visibleFrom="sm",
opened=True,
),
dmc.Image(src=logo, h=40),
dmc.Title("Demo App", c="blue"),
],
h="100%",
px="md",
)
),
dmc.AppShellNavbar(
id="navbar",
children=[
"Navbar",
dmc.NavLink(
label="With icon",
leftSection=get_icon(icon="bi:house-door-fill"),
),
],
p="md",
),
dmc.AppShellMain("Main"),
],
header={"height": 60},
navbar={
"width": 300,
"breakpoint": "sm",
"collapsed": {"mobile": True, "desktop": False},
},
padding="md",
id="appshell",
)
app.layout = dmc.MantineProvider(layout)
@callback(
Output("appshell", "navbar"),
Input("mobile-burger", "opened"),
Input("desktop-burger", "opened"),
State("appshell", "navbar"),
)
def toggle_navbar(mobile_opened, desktop_opened, navbar):
navbar["collapsed"] = {
"mobile": not mobile_opened,
"desktop": not desktop_opened,
}
return navbar
if __name__ == "__main__":
app.run(host='0.0.0.0', debug=True, port=8050)

View File

@@ -1,892 +0,0 @@
import dash
from dash import html, Input, Output, State, callback, dcc
import dash_mantine_components as dmc
from dash_iconify import DashIconify
from datetime import datetime, date, timedelta
import re
import base64
import dash_quill
app = dash.Dash(__name__, suppress_callback_exceptions=True) # Wichtig für dynamische IDs
# Deutsche Lokalisierung für Mantine
german_dates_provider_props = {
"settings": {
"locale": "de",
"firstDayOfWeek": 1,
"weekendDays": [0, 6],
"labels": {
"ok": "OK",
"cancel": "Abbrechen",
"clear": "Löschen",
"monthPickerControl": "Monat auswählen",
"yearPickerControl": "Jahr auswählen",
"nextMonth": "Nächster Monat",
"previousMonth": "Vorheriger Monat",
"nextYear": "Nächstes Jahr",
"previousYear": "Vorheriges Jahr",
"nextDecade": "Nächstes Jahrzehnt",
"previousDecade": "Vorheriges Jahrzehnt"
}
}
}
# Wochentage für Wiederholung
weekday_options = [
{"value": "0", "label": "Montag"},
{"value": "1", "label": "Dienstag"},
{"value": "2", "label": "Mittwoch"},
{"value": "3", "label": "Donnerstag"},
{"value": "4", "label": "Freitag"},
{"value": "5", "label": "Samstag"},
{"value": "6", "label": "Sonntag"}
]
# Deutsche Feiertage (vereinfacht, ohne Berechnung von Ostern etc.)
GERMAN_HOLIDAYS_2025 = [
date(2025, 1, 1), # Neujahr
date(2025, 1, 6), # Heilige Drei Könige
date(2025, 4, 18), # Karfreitag (Beispiel - muss berechnet werden)
date(2025, 4, 21), # Ostermontag (Beispiel - muss berechnet werden)
date(2025, 5, 1), # Tag der Arbeit
date(2025, 5, 29), # Christi Himmelfahrt (Beispiel - muss berechnet werden)
date(2025, 6, 9), # Pfingstmontag (Beispiel - muss berechnet werden)
date(2025, 10, 3), # Tag der Deutschen Einheit
date(2025, 12, 25), # 1. Weihnachtstag
date(2025, 12, 26), # 2. Weihnachtstag
]
# Schulferien (Beispiel für NRW 2025)
SCHOOL_HOLIDAYS_2025 = [
# Weihnachtsferien
(date(2024, 12, 23), date(2025, 1, 6)),
# Osterferien
(date(2025, 4, 14), date(2025, 4, 26)),
# Sommerferien
(date(2025, 7, 14), date(2025, 8, 26)),
# Herbstferien
(date(2025, 10, 14), date(2025, 10, 25)),
]
def is_holiday_or_vacation(check_date):
"""Prüft, ob ein Datum ein Feiertag oder in den Ferien liegt"""
# Feiertage prüfen
if check_date in GERMAN_HOLIDAYS_2025:
return True
# Schulferien prüfen
for start_vacation, end_vacation in SCHOOL_HOLIDAYS_2025:
if start_vacation <= check_date <= end_vacation:
return True
return False
# Zeitraster für 30-Minuten-Intervalle generieren
def generate_time_options():
options = []
# Basis: 30-Minuten-Raster
for h in range(6, 24): # Bis 23:30
for m in [0, 30]:
options.append({"value": f"{h:02d}:{m:02d}", "label": f"{h:02d}:{m:02d}"})
return options
time_options = generate_time_options()
# Hilfsfunktion für Input-Felder mit Tooltip - volle Breite
def create_input_with_tooltip_full(component, tooltip_text):
"""Erstellt ein Input-Feld mit Tooltip-Icon über die volle Breite"""
return dmc.Stack([
dmc.Group([
component,
dmc.Tooltip(
children=[
dmc.ActionIcon(
DashIconify(icon="mdi:help-circle-outline", width=16),
variant="subtle",
color="gray",
size="sm"
)
],
label=tooltip_text,
position="top",
multiline=True,
w=300
)
], gap="xs", align="flex-end")
], gap=0)
# Hilfsfunktion für Input-Felder mit Tooltip - für Zeit-Grid
def create_input_with_tooltip_time(component, tooltip_text):
"""Erstellt ein Input-Feld mit Tooltip-Icon für Zeit-Eingaben"""
return dmc.Group([
component,
dmc.Tooltip(
children=[
dmc.ActionIcon(
DashIconify(icon="mdi:help-circle-outline", width=16),
variant="subtle",
color="gray",
size="sm"
)
],
label=tooltip_text,
position="top",
multiline=True,
w=300
)
], gap="xs", align="flex-end")
app.layout = dmc.MantineProvider([
dmc.DatesProvider(**german_dates_provider_props, children=[
dmc.Container([
dmc.Title("Erweiterte Terminverwaltung", order=1, className="mb-4"),
dmc.Grid([
dmc.GridCol([
dmc.Paper([
dmc.Title("Termindetails", order=3, className="mb-3"),
dmc.Stack([
create_input_with_tooltip_full(
dmc.TextInput(
label="Titel",
placeholder="Terminbezeichnung eingeben",
leftSection=DashIconify(icon="mdi:calendar-text"),
id="title-input",
required=True,
style={"flex": 1}
),
"Geben Sie eine aussagekräftige Bezeichnung für den Termin ein"
),
create_input_with_tooltip_full(
dmc.DatePickerInput(
label="Startdatum",
value=datetime.now().date(),
id="start-date-input",
firstDayOfWeek=1,
weekendDays=[0, 6],
valueFormat="DD.MM.YYYY",
placeholder="Datum auswählen",
leftSection=DashIconify(icon="mdi:calendar"),
clearable=False,
style={"flex": 1}
),
"Wählen Sie das Datum für den Termin aus dem Kalender"
),
# Zeitbereich - nebeneinander
dmc.Grid([
dmc.GridCol([
dmc.Stack([
create_input_with_tooltip_time(
dmc.Select(
label="Startzeit",
placeholder="Zeit auswählen",
data=time_options,
searchable=True,
clearable=True,
id="time-start",
value="09:00",
required=True,
style={"flex": 1}
),
"Eingabe: 10:25, 10.25, 1025 oder 10 (wird automatisch formatiert)"
),
html.Div(id="start-time-feedback")
], gap="xs")
], span=6),
dmc.GridCol([
dmc.Stack([
create_input_with_tooltip_time(
dmc.Select(
label="Endzeit",
placeholder="Zeit auswählen",
data=time_options,
searchable=True,
clearable=True,
id="time-end",
required=True,
style={"flex": 1}
),
"Endzeit muss nach der Startzeit am selben Tag liegen. Termine können nicht über Mitternacht hinausgehen."
),
html.Div(id="end-time-feedback")
], gap="xs")
], span=6)
]),
create_input_with_tooltip_full(
dmc.Select(
label="Termintyp",
placeholder="Typ auswählen",
data=[
{"value": "presentation", "label": "Präsentation"},
{"value": "website", "label": "Website"},
{"value": "video", "label": "Video"},
{"value": "message", "label": "Nachricht"},
{"value": "other", "label": "Sonstiges"}
],
id="type-input",
required=True,
style={"flex": 1}
),
"Wählen Sie den Typ des Termins für bessere Kategorisierung"
),
# Dynamische typ-spezifische Felder
html.Div(id="type-specific-fields"),
create_input_with_tooltip_full(
dmc.Textarea(
label="Beschreibung",
placeholder="Zusätzliche Informationen...",
minRows=3,
autosize=True,
id="description-input",
style={"flex": 1}
),
"Optionale Beschreibung mit weiteren Details zum Termin"
)
], gap="md")
], p="md", shadow="sm")
], span=6),
dmc.GridCol([
dmc.Paper([
dmc.Title("Wiederholung", order=3, className="mb-3"),
dmc.Stack([
create_input_with_tooltip_full(
dmc.Checkbox(
label="Wiederholender Termin",
id="repeat-checkbox",
description="Aktivieren für wöchentliche Wiederholung"
),
"Aktivieren Sie diese Option, um den Termin an mehreren Wochentagen zu wiederholen"
),
html.Div(id="repeat-options", children=[
create_input_with_tooltip_full(
dmc.MultiSelect(
label="Wochentage",
placeholder="Wochentage auswählen",
data=weekday_options,
id="weekdays-select",
description="An welchen Wochentagen soll der Termin stattfinden?",
disabled=True,
style={"flex": 1}
),
"Wählen Sie mindestens einen Wochentag für die Wiederholung aus"
),
dmc.Space(h="md"), # Abstand zwischen DatePicker und Ferientage
create_input_with_tooltip_full(
dmc.DatePickerInput(
label="Wiederholung bis",
id="repeat-until-date",
firstDayOfWeek=1,
weekendDays=[0, 6],
valueFormat="DD.MM.YYYY",
placeholder="Enddatum auswählen",
leftSection=DashIconify(icon="mdi:calendar-end"),
description="Letzter Tag der Wiederholung",
disabled=True,
clearable=True,
style={"flex": 1}
),
"Datum bis wann die Wiederholung stattfinden soll. Muss nach dem Startdatum liegen."
),
dmc.Space(h="lg"), # Größerer Abstand vor Ferientage
create_input_with_tooltip_full(
dmc.Checkbox(
label="Ferientage berücksichtigen",
id="skip-holidays-checkbox",
description="Termine an Feiertagen und in Schulferien auslassen",
disabled=True
),
"Aktivieren Sie diese Option, um Termine an deutschen Feiertagen und in Schulferien automatisch zu überspringen"
)
])
], gap="md")
], p="md", shadow="sm"),
dmc.Paper([
dmc.Title("Aktionen", order=3, className="mb-3"),
dmc.Stack([
dmc.Button(
"Termin(e) speichern",
color="green",
leftSection=DashIconify(icon="mdi:content-save"),
id="btn-save",
size="lg",
fullWidth=True
),
dmc.Button(
"Zurücksetzen",
color="gray",
variant="outline",
leftSection=DashIconify(icon="mdi:refresh"),
id="btn-reset",
fullWidth=True
),
html.Div(id="save-feedback", className="mt-3")
], gap="md")
], p="md", shadow="sm", className="mt-3")
], span=6)
]),
# Vorschau-Bereich
dmc.Paper([
dmc.Title("Vorschau", order=3, className="mb-3"),
html.Div(id="preview-area")
], p="md", shadow="sm", className="mt-4")
], size="lg")
])
])
# Zeit-Validierungsfunktion
def validate_and_format_time(time_str):
"""Validiert und formatiert Zeitangaben"""
if not time_str:
return None, "Keine Zeit angegeben"
# Bereits korrektes Format
if re.match(r'^\d{2}:\d{2}$', time_str):
try:
h, m = map(int, time_str.split(':'))
if 0 <= h <= 23 and 0 <= m <= 59:
return time_str, "Gültige Zeit"
except:
pass
# Verschiedene Eingabeformate versuchen
patterns = [
(r'^(\d{1,2}):(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
(r'^(\d{1,2})\.(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
(r'^(\d{1,2})(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
(r'^(\d{1,2})$', lambda m: (int(m.group(1)), 0)),
]
for pattern, extractor in patterns:
match = re.match(pattern, time_str.strip())
if match:
try:
hours, minutes = extractor(match)
if 0 <= hours <= 23 and 0 <= minutes <= 59:
return f"{hours:02d}:{minutes:02d}", "Automatisch formatiert"
except:
continue
return None, "Ungültiges Zeitformat"
# Typ-spezifische Felder anzeigen
@callback(
Output('type-specific-fields', 'children'),
Input('type-input', 'value'),
prevent_initial_call=True
)
def show_type_specific_fields(event_type):
if not event_type:
return html.Div()
if event_type == "presentation":
return dmc.Stack([
dmc.Divider(label="Präsentations-Details", labelPosition="center"),
dmc.Group([
dcc.Upload(
id='presentation-upload',
children=dmc.Button(
"Datei hochladen",
leftSection=DashIconify(icon="mdi:upload"),
variant="outline"
),
style={'width': 'auto'}
),
dmc.TextInput(
label="Präsentationslink",
placeholder="https://...",
leftSection=DashIconify(icon="mdi:link"),
id="presentation-link",
style={"minWidth": 0, "flex": 1, "marginBottom": 0}
)
], grow=True, align="flex-end", style={"marginBottom": 10}),
html.Div(id="presentation-upload-status")
], gap="sm")
elif event_type == "video":
return dmc.Stack([
dmc.Divider(label="Video-Details", labelPosition="center"),
dmc.Group([
dcc.Upload(
id='video-upload',
children=dmc.Button(
"Video hochladen",
leftSection=DashIconify(icon="mdi:video-plus"),
variant="outline"
),
style={'width': 'auto'}
),
dmc.TextInput(
label="Videolink",
placeholder="https://youtube.com/...",
leftSection=DashIconify(icon="mdi:youtube"),
id="video-link",
style={"minWidth": 0, "flex": 1, "marginBottom": 0}
)
], grow=True, align="flex-end", style={"marginBottom": 10}),
dmc.Group([
dmc.Checkbox(
label="Endlos wiederholen",
id="video-endless",
checked=True,
style={"marginRight": 20}
),
dmc.NumberInput(
label="Wiederholungen",
id="video-repeats",
value=1,
min=1,
max=99,
step=1,
disabled=True,
style={"width": 150}
),
dmc.Slider(
label="Lautstärke",
id="video-volume",
value=70,
min=0,
max=100,
step=5,
marks=[
{"value": 0, "label": "0%"},
{"value": 50, "label": "50%"},
{"value": 100, "label": "100%"}
],
style={"flex": 1, "marginLeft": 20}
)
], grow=True, align="flex-end"),
html.Div(id="video-upload-status")
], gap="sm")
elif event_type == "website":
return dmc.Stack([
dmc.Divider(label="Website-Details", labelPosition="center"),
dmc.TextInput(
label="Website-URL",
placeholder="https://example.com",
leftSection=DashIconify(icon="mdi:web"),
id="website-url",
required=True,
style={"flex": 1}
)
# Anzeigedauer entfernt!
], gap="sm")
elif event_type == "message":
return dmc.Stack([
dmc.Divider(label="Nachrichten-Details", labelPosition="center"),
dash_quill.Quill(
id="message-content",
value="",
# theme="snow",
# style={"height": "150px", "marginBottom": 10}
),
dmc.Group([
dmc.Select(
label="Schriftgröße",
data=[
{"value": "small", "label": "Klein"},
{"value": "medium", "label": "Normal"},
{"value": "large", "label": "Groß"},
{"value": "xlarge", "label": "Sehr groß"}
],
id="message-font-size",
value="medium",
style={"flex": 1}
),
dmc.ColorPicker(
id="message-color",
value="#000000",
format="hex",
swatches=[
"#000000", "#ffffff", "#ff0000", "#00ff00",
"#0000ff", "#ffff00", "#ff00ff", "#00ffff"
]
)
], grow=True, align="flex-end")
], gap="sm")
return html.Div()
# Callback zum Aktivieren/Deaktivieren des Wiederholungsfelds bei Video
@callback(
Output("video-repeats", "disabled"),
Input("video-endless", "checked"),
prevent_initial_call=True
)
def toggle_video_repeats(endless_checked):
return endless_checked
# Upload-Status für Präsentation
@callback(
Output('presentation-upload-status', 'children'),
Input('presentation-upload', 'contents'),
State('presentation-upload', 'filename'),
prevent_initial_call=True
)
def update_presentation_upload_status(contents, filename):
"""Zeigt Status des Präsentations-Uploads"""
if contents is not None and filename is not None:
return dmc.Alert(
f"✓ Datei '{filename}' erfolgreich hochgeladen",
color="green",
className="mt-2"
)
return html.Div()
# Upload-Status für Video
@callback(
Output('video-upload-status', 'children'),
Input('video-upload', 'contents'),
State('video-upload', 'filename'),
prevent_initial_call=True
)
def update_video_upload_status(contents, filename):
"""Zeigt Status des Video-Uploads"""
if contents is not None and filename is not None:
return dmc.Alert(
f"✓ Video '{filename}' erfolgreich hochgeladen",
color="green",
className="mt-2"
)
return html.Div()
# Wiederholungsoptionen aktivieren/deaktivieren
@callback(
[
Output('weekdays-select', 'disabled'),
Output('repeat-until-date', 'disabled'),
Output('skip-holidays-checkbox', 'disabled'),
Output('weekdays-select', 'value'),
Output('repeat-until-date', 'value'),
Output('skip-holidays-checkbox', 'checked')
],
Input('repeat-checkbox', 'checked'),
prevent_initial_call=True
)
def toggle_repeat_options(is_repeat):
"""Aktiviert/deaktiviert Wiederholungsoptionen"""
if is_repeat:
# Aktiviert und setzt Standardwerte
next_month = datetime.now().date() + timedelta(weeks=4) # 4 Wochen später
return (
False, # weekdays-select enabled
False, # repeat-until-date enabled
False, # skip-holidays-checkbox enabled
None, # weekdays value
next_month, # repeat-until-date value
False # skip-holidays-checkbox checked
)
else:
# Deaktiviert und löscht Werte
return (
True, # weekdays-select disabled
True, # repeat-until-date disabled
True, # skip-holidays-checkbox disabled
None, # weekdays value
None, # repeat-until-date value
False # skip-holidays-checkbox checked
)
# Dynamische Zeitoptionen für Startzeit
@callback(
[
Output('time-start', 'data'),
Output('start-time-feedback', 'children')
],
Input('time-start', 'searchValue'),
prevent_initial_call=True
)
def update_start_time_options(search_value):
"""Erweitert Zeitoptionen basierend auf Sucheingabe"""
base_options = time_options.copy()
feedback = None
if search_value:
validated_time, status = validate_and_format_time(search_value)
if validated_time:
if not any(opt["value"] == validated_time for opt in base_options):
base_options.insert(0, {
"value": validated_time,
"label": f"{validated_time} (Ihre Eingabe)"
})
feedback = dmc.Text(f"{status}: {validated_time}", size="xs", c="green")
else:
feedback = dmc.Text(f"{status}", size="xs", c="red")
return base_options, feedback
# Dynamische Zeitoptionen für Endzeit
@callback(
[
Output('time-end', 'data'),
Output('end-time-feedback', 'children')
],
Input('time-end', 'searchValue'),
prevent_initial_call=True
)
def update_end_time_options(search_value):
"""Erweitert Zeitoptionen basierend auf Sucheingabe"""
base_options = time_options.copy()
feedback = None
if search_value:
validated_time, status = validate_and_format_time(search_value)
if validated_time:
if not any(opt["value"] == validated_time for opt in base_options):
base_options.insert(0, {
"value": validated_time,
"label": f"{validated_time} (Ihre Eingabe)"
})
feedback = dmc.Text(f"{status}: {validated_time}", size="xs", c="green")
else:
feedback = dmc.Text(f"{status}", size="xs", c="red")
return base_options, feedback
# Automatische Endzeit-Berechnung mit Validation
@callback(
Output('time-end', 'value'),
[
Input('time-start', 'value'),
Input('btn-reset', 'n_clicks')
],
State('time-end', 'value'),
prevent_initial_call=True
)
def handle_end_time(start_time, reset_clicks, current_end_time):
"""Behandelt automatische Endzeit-Berechnung und Reset"""
ctx = dash.callback_context
if not ctx.triggered:
return dash.no_update
trigger_id = ctx.triggered[0]['prop_id'].split('.')[0]
if trigger_id == 'btn-reset' and reset_clicks:
return None
if trigger_id == 'time-start' and start_time:
if current_end_time:
return dash.no_update
try:
validated_start, _ = validate_and_format_time(start_time)
if validated_start:
start_dt = datetime.strptime(validated_start, "%H:%M")
# 1.5 Stunden später, aber maximal 23:59
end_dt = start_dt + timedelta(hours=1, minutes=30)
if end_dt.hour >= 24:
end_dt = end_dt.replace(hour=23, minute=59)
return end_dt.strftime("%H:%M")
except:
pass
return dash.no_update
# Hilfsfunktion für sichere Werte-Abfrage
def get_safe_value(ctx, prop_id):
"""Gibt den Wert einer Property zurück oder None, wenn sie nicht existiert"""
try:
return ctx.states.get(prop_id, {}).get('value')
except:
return None
# Vorschau-Bereich mit Ferientags-Berücksichtigung und typ-spezifischen Daten
@callback(
Output('preview-area', 'children'),
[
Input('title-input', 'value'),
Input('start-date-input', 'value'),
Input('time-start', 'value'),
Input('time-end', 'value'),
Input('type-input', 'value'),
Input('description-input', 'value'),
Input('repeat-checkbox', 'checked'),
Input('weekdays-select', 'value'),
Input('repeat-until-date', 'value'),
Input('skip-holidays-checkbox', 'checked')
],
prevent_initial_call=True
)
def update_preview(title, start_date, start_time, end_time, event_type, description,
is_repeat, weekdays, repeat_until, skip_holidays):
"""Zeigt Live-Vorschau der Termine mit typ-spezifischen Daten"""
validated_start, start_status = validate_and_format_time(start_time)
validated_end, end_status = validate_and_format_time(end_time)
# Zeitvalidierung
time_valid = True
time_error = ""
if validated_start and validated_end:
start_dt = datetime.strptime(validated_start, "%H:%M")
end_dt = datetime.strptime(validated_end, "%H:%M")
if end_dt <= start_dt:
time_valid = False
time_error = "Endzeit muss nach Startzeit liegen"
elif end_dt.hour < start_dt.hour: # Über Mitternacht
time_valid = False
time_error = "Termine dürfen nicht über Mitternacht hinausgehen"
# Typ-spezifische Details mit sicherer Abfrage
type_details = []
if event_type == "presentation":
# Hier würden wir normalerweise die Werte abfragen, aber da sie dynamisch sind,
# zeigen wir nur den Typ an
type_details.append(dmc.Text("🎯 Präsentationsdetails werden nach Auswahl angezeigt", size="sm"))
elif event_type == "video":
type_details.append(dmc.Text("📹 Videodetails werden nach Auswahl angezeigt", size="sm"))
elif event_type == "website":
type_details.append(dmc.Text("🌐 Website-Details werden nach Auswahl angezeigt", size="sm"))
elif event_type == "message":
type_details.append(dmc.Text("💬 Nachrichten-Details werden nach Auswahl angezeigt", size="sm"))
# Wiederholungslogik mit Ferientags-Berücksichtigung
if is_repeat and weekdays and start_date and repeat_until and time_valid:
weekday_names = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
selected_days = [weekday_names[int(day)] for day in weekdays]
# Termine berechnen
termine_count = 0
skipped_holidays = 0
# Sicherstellen, dass start_date ein date-Objekt ist
if isinstance(start_date, str):
try:
current_date = datetime.strptime(start_date, "%Y-%m-%d").date()
except:
current_date = datetime.now().date()
else:
current_date = start_date
# Sicherstellen, dass repeat_until ein date-Objekt ist
if isinstance(repeat_until, str):
try:
end_date = datetime.strptime(repeat_until, "%Y-%m-%d").date()
except:
end_date = current_date + timedelta(weeks=4)
else:
end_date = repeat_until
# Kopie für Iteration erstellen
iter_date = current_date
while iter_date <= end_date:
if str(iter_date.weekday()) in weekdays:
if skip_holidays and is_holiday_or_vacation(iter_date):
skipped_holidays += 1
else:
termine_count += 1
iter_date += timedelta(days=1)
holiday_info = []
if skip_holidays:
holiday_info = [
dmc.Text(f"🚫 Übersprungene Ferientage: {skipped_holidays}", size="sm", c="orange"),
dmc.Text(f"📅 Tatsächliche Termine: {termine_count}", size="sm", fw=500)
]
repeat_info = dmc.Stack([
dmc.Text(f"📅 Wiederholung: {', '.join(selected_days)}", size="sm"),
dmc.Text(f"📆 Zeitraum: {current_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}", size="sm"),
dmc.Text(f"🔢 Geplante Termine: {termine_count + skipped_holidays if skip_holidays else termine_count}", size="sm"),
*holiday_info
])
else:
repeat_info = dmc.Text("📅 Einzeltermin", size="sm")
# Datum formatieren
date_str = start_date.strftime('%d.%m.%Y') if isinstance(start_date, date) else (start_date or "Nicht gesetzt")
return dmc.Stack([
dmc.Title(title or "Unbenannter Termin", order=4),
dmc.Text(f"📅 Datum: {date_str}", size="sm"),
dmc.Text(f"🕐 Zeit: {validated_start or 'Nicht gesetzt'} - {validated_end or 'Nicht gesetzt'}", size="sm"),
dmc.Text(f"📋 Typ: {event_type or 'Nicht gesetzt'}", size="sm"),
# Typ-spezifische Details
*type_details,
dmc.Text(f"📝 Beschreibung: {description[:100] + '...' if description and len(description) > 100 else description or 'Keine'}", size="sm"),
dmc.Divider(className="my-2"),
repeat_info,
dmc.Divider(className="my-2"),
dmc.Stack([
dmc.Text("Validierung:", fw=500, size="xs"),
dmc.Text(f"Start: {start_status}", size="xs", c="green" if validated_start else "red"),
dmc.Text(f"Ende: {end_status}", size="xs", c="green" if validated_end else "red"),
dmc.Text(f"Zeitbereich: {'✓ Gültig' if time_valid else f'{time_error}'}",
size="xs", c="green" if time_valid else "red")
], gap="xs")
])
# Reset-Funktion erweitert
@callback(
[
Output('title-input', 'value'),
Output('start-date-input', 'value'),
Output('time-start', 'value'),
Output('type-input', 'value'),
Output('description-input', 'value'),
Output('repeat-checkbox', 'checked'),
Output('weekdays-select', 'value', allow_duplicate=True),
Output('repeat-until-date', 'value', allow_duplicate=True),
Output('skip-holidays-checkbox', 'checked', allow_duplicate=True)
],
Input('btn-reset', 'n_clicks'),
prevent_initial_call=True
)
def reset_form(n_clicks):
"""Setzt das komplette Formular zurück"""
if n_clicks:
return "", datetime.now().date(), "09:00", None, "", False, None, None, False
return dash.no_update
# Speichern-Funktion (vereinfacht für Demo)
@callback(
Output('save-feedback', 'children'),
Input('btn-save', 'n_clicks'),
prevent_initial_call=True
)
def save_appointments_demo(n_clicks):
"""Demo-Speicherfunktion"""
if not n_clicks:
return dash.no_update
return dmc.Alert(
"Demo: Termine würden hier gespeichert werden",
color="blue",
title="Speichern (Demo-Modus)"
)
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=8051)

View File

@@ -1,63 +0,0 @@
# dashboard/pages/appointments.py
from dash import html, dcc
import dash
from dash_using_fullcalendar import DashUsingFullcalendar
import dash_bootstrap_components as dbc
from dashboard.components.appointment_modal import get_appointment_modal
dash.register_page(__name__, path="/appointments", name="Termine")
layout = dbc.Container([
dbc.Row([
dbc.Col(html.H2("Dash FullCalendar"))
]),
# Button zum Öffnen der Modalbox
dbc.Row([
dbc.Col(
dbc.Button(
"Neuen Termin anlegen",
id="open-appointment-modal-btn",
color="primary",
className="mb-3"
)
)
]),
dbc.Row([
dbc.Col(
DashUsingFullcalendar(
id='calendar',
events=[],
initialView="timeGridWeek",
headerToolbar={
"left": "prev,next today",
"center": "title",
# "right": "dayGridMonth,timeGridWeek,timeGridDay"
},
height=600,
locale="de",
slotDuration="00:30:00",
slotMinTime="00:00:00",
slotMaxTime="24:00:00",
scrollTime="07:00:00",
weekends=True,
allDaySlot=False,
firstDay=1,
# themeSystem kann auf "bootstrap5" gesetzt werden, wenn das Plugin eingebunden ist
# themeSystem="bootstrap5"
)
)
]),
dbc.Row([
dbc.Col(html.Div(id='output'))
]),
dbc.Row([
dbc.Col(html.Div(id='event-output'))
]),
dbc.Row([
dbc.Col(html.Div(id='select-output'))
]),
dbc.Row([
dbc.Col(html.Div(id='modal-output', children=get_appointment_modal()))
])
], fluid=True)

View File

@@ -1,12 +0,0 @@
# dashboard/pages/clients.py
from dash import html, dcc
import dash
dash.register_page(__name__, path="/clients", name="Bildschirme")
layout = html.Div(
className="clients-page",
children=[
html.H3("Bildschirme"),
]
)

View File

@@ -1,16 +0,0 @@
# dashboard/pages/login.py
from dash import html, dcc
import dash
dash.register_page(__name__, path="/login", name="Login")
layout = html.Div(
className="login-page",
children=[
html.H2("Bitte einloggen"),
dcc.Input(id="input-user", type="text", placeholder="Benutzername"),
dcc.Input(id="input-pass", type="password", placeholder="Passwort"),
html.Button("Einloggen", id="btn-login"),
html.Div(id="login-feedback", className="text-danger")
]
)

View File

@@ -1,13 +0,0 @@
# dashboard/pages/overview.py
from dash import html, dcc
import dash
dash.register_page(__name__, path="/overview", name="Übersicht")
layout = html.Div(
className="overview-page",
children=[
dcc.Interval(id="interval-update", interval=10_000, n_intervals=0),
html.Div(id="clients-cards-container")
]
)

View File

@@ -1,13 +0,0 @@
# dashboard/pages/settings.py
from dash import html
import dash
dash.register_page(__name__, path="/settings", name="Einstellungen")
layout = html.Div(
className="settings-page",
children=[
html.H3("Allgemeine Einstellungen"),
# Formularfelder / Tabs für globale Optionen
]
)

View File

@@ -1,5 +0,0 @@
import dash
from dash import html
dash.register_page(__name__, path="/test", name="Testseite")
layout = html.Div("Testseite funktioniert!")

View File

@@ -1,15 +0,0 @@
# dashboard/pages/users.py
from dash import html, dash_table, dcc
import dash
dash.register_page(__name__, path="/users", name="Benutzer")
layout = html.Div(
className="users-page",
children=[
html.H3("Benutzerverwaltung"),
html.Button("Neuen Benutzer anlegen", id="btn-new-user"),
html.Div(id="users-table-container"),
html.Div(id="users-feedback")
]
)

View File

@@ -1 +0,0 @@
debugpy

View File

@@ -1,13 +0,0 @@
bcrypt>=4.3.0
dash>=3.0.4
dash-bootstrap-components>=2.0.3
dash_iconify>=0.1.2
dash_mantine_components>=1.2.0
dash-quill>=0.0.4
full-calendar-component>=0.0.4
pandas>=2.2.3
paho-mqtt>=2.1.0
python-dotenv>=1.1.0
PyMySQL>=1.1.1
SQLAlchemy>=2.0.41
./dash_using_fullcalendar-0.1.0.tar.gz

View File

@@ -1,193 +0,0 @@
"""
This app creates a collapsible, responsive sidebar layout with
dash-bootstrap-components and some custom css with media queries.
When the screen is small, the sidebar moved to the top of the page, and the
links get hidden in a collapse element. We use a callback to toggle the
collapse when on a small screen, and the custom CSS to hide the toggle, and
force the collapse to stay open when the screen is large.
dcc.Location is used to track the current location, a callback uses the current
location to render the appropriate page content. The active prop of each
NavLink is set automatically according to the current pathname. To use this
feature you must install dash-bootstrap-components >= 0.11.0.
For more details on building multi-page Dash applications, check out the Dash
documentation: https://dash.plotly.com/urls
"""
import sys
sys.path.append('/workspace')
import dash
import dash_bootstrap_components as dbc
from dash import Input, Output, State, dcc, html, page_container
from dash_iconify import DashIconify
# import callbacks.ui_callbacks
import dashboard.callbacks.appointments_callbacks
import dashboard.callbacks.appointment_modal_callbacks
import dash_mantine_components as dmc
app = dash.Dash(
external_stylesheets=[dbc.themes.BOOTSTRAP],
# these meta_tags ensure content is scaled correctly on different devices
# see: https://www.w3schools.com/css/css_rwd_viewport.asp for more
meta_tags=[{"name": "viewport", "content": "width=device-width, initial-scale=1"}],
use_pages=True,
suppress_callback_exceptions=True,
)
nav_items = [
{"label": "Übersicht", "href": "/overview", "icon": "mdi:view-dashboard"},
{"label": "Termine", "href": "/appointments","icon": "mdi:calendar"},
{"label": "Bildschirme", "href": "/clients", "icon": "mdi:monitor"},
{"label": "Einstellungen","href": "/settings", "icon": "mdi:cog"},
{"label": "Benutzer", "href": "/users", "icon": "mdi:account"},
]
nav_links = []
for item in nav_items:
# Create a NavLink for each item
link_id = {"type": "nav-item", "index": item["label"]}
nav_link = dbc.NavLink(
[
DashIconify(icon=item["icon"], width=24),
html.Span(item["label"], className="ms-2 sidebar-label"),
],
href=item["href"],
active="exact",
className="sidebar-item",
id=link_id,
)
nav_links.append(
html.Div(
children=nav_link,
className="nav-item-container"
)
)
# we use the Row and Col components to construct the sidebar header
# it consists of a title, and a toggle, the latter is hidden on large screens
sidebar_header = dbc.Row(
[
dbc.Col(html.H2("Sidebar", className="display-4")),
dbc.Col(
[
html.Button(
# use the Bootstrap navbar-toggler classes to style
html.Span(className="navbar-toggler-icon"),
className="navbar-toggler",
# the navbar-toggler classes don't set color
style={
"color": "rgba(0,0,0,.5)",
"border-color": "rgba(0,0,0,.1)",
},
id="navbar-toggle",
),
html.Button(
# use the Bootstrap navbar-toggler classes to style
html.Span(className="navbar-toggler-icon"),
className="navbar-toggler",
# the navbar-toggler classes don't set color
style={
"color": "rgba(0,0,0,.5)",
"border-color": "rgba(0,0,0,.1)",
},
id="sidebar-toggle",
),
],
# the column containing the toggle will be only as wide as the
# toggle, resulting in the toggle being right aligned
width="auto",
# vertically align the toggle in the center
align="center",
),
]
)
sidebar = html.Div(
[
sidebar_header,
# we wrap the horizontal rule and short blurb in a div that can be
# hidden on a small screen
html.Div(
[
html.Hr(),
html.P(
"A responsive sidebar layout with collapsible navigation " "links.",
className="lead",
),
],
id="blurb",
),
# use the Collapse component to animate hiding / revealing links
dbc.Collapse(
dbc.Nav(
nav_links, # <-- Korrigiert: keine zusätzliche Liste
vertical=True,
pills=True,
),
id="collapse",
),
],
id="sidebar",
)
content = dmc.MantineProvider([
html.Div(
html.Div(page_container, className="page-content"),style={"flex": "1", "padding": "20px"}
)
])
app.layout = html.Div([dcc.Location(id="url"), sidebar, content])
# @app.callback(Output("page-content", "children"), [Input("url", "pathname")])
# def render_page_content(pathname):
# if pathname == "/":
# return html.P("This is the content of the home page!")
# elif pathname == "/page-1":
# return html.P("This is the content of page 1. Yay!")
# elif pathname == "/page-2":
# return html.P("Oh cool, this is page 2!")
# # If the user tries to reach a different page, return a 404 message
# return html.Div(
# [
# html.H1("404: Not found", className="text-danger"),
# html.Hr(),
# html.P(f"The pathname {pathname} was not recognised..."),
# ],
# className="p-3 bg-light rounded-3",
# )
@app.callback(
[Output("sidebar", "className"), Output("collapse", "is_open")],
[
Input("sidebar-toggle", "n_clicks"),
Input("navbar-toggle", "n_clicks"),
],
[
State("sidebar", "className"),
State("collapse", "is_open"),
],
)
def toggle_sidebar_and_collapse(sidebar_n, navbar_n, classname, is_open):
ctx = dash.callback_context
if not ctx.triggered:
return classname, is_open
trigger_id = ctx.triggered[0]["prop_id"].split(".")[0]
if trigger_id == "sidebar-toggle":
# Toggle sidebar collapse
if sidebar_n and classname == "":
return "collapsed", is_open
return "", is_open
elif trigger_id == "navbar-toggle":
# Toggle collapse
if navbar_n:
return classname, not is_open
return classname, is_open
return classname, is_open
if __name__ == "__main__":
app.run(port=8888, debug=True)

View File

@@ -1,12 +0,0 @@
# dashboard/utils/auth.py
import bcrypt
def hash_password(plain_text: str) -> str:
return bcrypt.hashpw(plain_text.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def check_password(plain_text: str, hashed: str) -> bool:
return bcrypt.checkpw(plain_text.encode("utf-8"), hashed.encode("utf-8"))
def get_user_role(username: str) -> str:
# Beispiel: aus der Datenbank auslesen (oder Hardcode während Dev-Phase)
pass

View File

@@ -1,46 +0,0 @@
import os
from dotenv import load_dotenv
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
# .env laden
load_dotenv()
# Datenbank-Zugangsdaten aus .env
DB_USER = os.getenv("DB_USER")
DB_PASSWORD = os.getenv("DB_PASSWORD")
DB_HOST = os.getenv("DB_HOST", "localhost")
DB_PORT = os.getenv("DB_PORT", "3306")
DB_NAME = os.getenv("DB_NAME")
# Pooling Parameter aus .env (optional mit Default-Werten)
POOL_SIZE = int(os.getenv("POOL_SIZE", 10))
MAX_OVERFLOW = int(os.getenv("MAX_OVERFLOW", 20))
POOL_TIMEOUT = int(os.getenv("POOL_TIMEOUT", 30))
POOL_RECYCLE = int(os.getenv("POOL_RECYCLE", 1800))
# Connection-String zusammenbauen
DATABASE_URL = (
f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
)
# Engine mit Pooling konfigurieren
engine = create_engine(
DATABASE_URL,
pool_size=POOL_SIZE,
max_overflow=MAX_OVERFLOW,
pool_timeout=POOL_TIMEOUT,
pool_recycle=POOL_RECYCLE,
echo=True, # für Debug, später False
)
# Session Factory
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def get_session():
return SessionLocal()
def execute_query(query):
with engine.connect() as connection:
result = connection.execute(text(query))
return [dict(row) for row in result]

View File

@@ -1,124 +0,0 @@
# dashboard/utils/mqtt_client.py
import os
import threading
import time
from dotenv import load_dotenv
import paho.mqtt.client as mqtt
import random
# 1. Laden der Umgebungsvariablen aus .env
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), "..", ".env"))
# 2. Lese MQTTEinstellungen
MQTT_BROKER_HOST = os.getenv("MQTT_BROKER_HOST", "localhost")
MQTT_BROKER_PORT = int(os.getenv("MQTT_BROKER_PORT", "1883"))
MQTT_USERNAME = os.getenv("MQTT_USERNAME", None)
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD", None)
MQTT_KEEPALIVE = int(os.getenv("MQTT_KEEPALIVE", "60"))
base_id = os.getenv("MQTT_CLIENT_ID", "dash")
unique_part = f"{os.getpid()}_{random.randint(1000,9999)}"
MQTT_CLIENT_ID = f"{base_id}-{unique_part}"
# 3. Erstelle eine globale ClientInstanz
client = mqtt.Client(client_id=MQTT_CLIENT_ID)
# Falls Nutzer/Passwort gesetzt sind, authentifizieren
if MQTT_USERNAME and MQTT_PASSWORD:
client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
# 4. CallbackStubs (kannst du bei Bedarf anpassen)
def _on_connect(client, userdata, flags, rc):
if rc == 0:
print(f"[mqtt_client.py] Erfolgreich mit MQTTBroker verbunden (Code {rc})")
else:
print(f"[mqtt_client.py] Verbindungsfehler, rc={rc}")
def _on_disconnect(client, userdata, rc):
print(f"[mqtt_client.py] Verbindung getrennt (rc={rc}). Versuche, neu zu verbinden …")
def _on_message(client, userdata, msg):
"""
Diese CallbackFunktion wird aufgerufen, sobald eine Nachricht auf einem
Topic ankommt, auf das wir subscribed haben. Du kannst hier eine Queue
füllen oder direkt eine DatenbankFunktion aufrufen.
"""
topic = msg.topic
payload = msg.payload.decode("utf-8", errors="ignore")
print(f"[mqtt_client.py] Nachricht eingegangen Topic: {topic}, Payload: {payload}")
# Beispiel: Wenn du LiveStatusdaten in die Datenbank schreibst,
# könntest du hier utils/db.execute_non_query(...) aufrufen.
# 5. Setze die Callbacks
client.on_connect = _on_connect
client.on_disconnect = _on_disconnect
client.on_message = _on_message
def start_loop():
"""
Startet die EndlosSchleife, in der der Client auf eingehende
MQTTNachrichten hört und automatisch reconnectet.
Muss idealerweise in einem eigenen Thread laufen, damit DashCallbacks
nicht blockieren.
"""
try:
client.connect(MQTT_BROKER_HOST, MQTT_BROKER_PORT, keepalive=MQTT_KEEPALIVE)
client.loop_start()
except Exception as e:
print(f"[mqtt_client.py] Konnte keine Verbindung zum MQTTBroker herstellen: {e}")
def stop_loop():
"""
Stoppt die MQTTLoop und trennt die Verbindung.
"""
try:
client.loop_stop()
client.disconnect()
except Exception as e:
print(f"[mqtt_client.py] Fehler beim Stoppen der MQTTSchleife: {e}")
def publish(topic: str, payload: str, qos: int = 0, retain: bool = False) -> bool:
"""
Verschickt eine MQTTNachricht:
- topic: z. B. "clients/{client_id}/control"
- payload: z. B. '{"command":"restart"}'
- qos: 0, 1 oder 2
- retain: True/False
Rückgabe: True, falls Veröffentlichung bestätigt wurde; sonst False.
"""
try:
result = client.publish(topic, payload, qos=qos, retain=retain)
status = result.rc # 0=Erfolg, sonst Fehler
if status == mqtt.MQTT_ERR_SUCCESS:
return True
else:
print(f"[mqtt_client.py] Publish-Fehler für Topic {topic}, rc={status}")
return False
except Exception as e:
print(f"[mqtt_client.py] Exception beim Publish: {e}")
return False
def subscribe(topic: str, qos: int = 0) -> bool:
"""
Abonniert ein MQTTTopic, sodass _on_message gerufen wird, sobald Nachrichten
ankommen.
Rückgabe: True bei Erfolg, ansonsten False.
"""
try:
result, mid = client.subscribe(topic, qos=qos)
if result == mqtt.MQTT_ERR_SUCCESS:
return True
else:
print(f"[mqtt_client.py] SubscribeFehler für Topic {topic}, rc={result}")
return False
except Exception as e:
print(f"[mqtt_client.py] Exception beim Subscribe: {e}")
return False

24
dashboard/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,7 +1,6 @@
{
"extends": [
"stylelint-config-standard",
"stylelint-config-tailwindcss"
"stylelint-config-standard"
],
"rules": {
"at-rule-no-unknown": null

View File

@@ -1,39 +1,25 @@
# ==========================================
# dashboard/Dockerfile (Production)
# ==========================================
FROM node:lts-alpine AS builder
FROM node:20-alpine AS build
WORKDIR /app
# Copy package files
# Kopiere package.json und Lockfile aus dem Build-Kontext (./dashboard)
COPY package*.json ./
COPY pnpm-lock.yaml* ./
# Install pnpm and dependencies
RUN npm install -g pnpm
RUN pnpm install --frozen-lockfile
# Produktions-Abhängigkeiten installieren
ENV NODE_ENV=production
RUN npm ci --omit=dev
# Copy source code
# Quellcode kopieren und builden
COPY . .
# Build arguments
ARG NODE_ENV=production
ARG VITE_API_URL
ENV VITE_API_URL=${VITE_API_URL}
RUN npm run build
# Build the application
RUN pnpm build
FROM nginx:1.25-alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", " -g", "daemon off;"]
# Production stage with nginx
FROM nginx:alpine
# Copy built files to nginx
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy custom nginx config (optional)
COPY nginx.conf /etc/nginx/nginx.conf
# Expose port
EXPOSE 3000
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,24 +1,28 @@
# ==========================================
# dashboard/Dockerfile.dev (Development)
# 🔧 OPTIMIERT: Für schnelle Entwicklung mit Vite und npm
# ==========================================
FROM node:lts-alpine
FROM node:20-alpine
# Stelle sicher, dass benötigte Tools verfügbar sind (z. B. für wait-for-backend.sh)
RUN apk add --no-cache curl
# Setze Arbeitsverzeichnis direkt auf das Dashboard-Verzeichnis im Container
# (Der Build-Kontext ist ./dashboard, siehe docker-compose.override.yml)
WORKDIR /workspace/dashboard
# Install dependencies manager (pnpm optional, npm reicht für Compose-Setup)
# RUN npm install -g pnpm
# Copy package files
# KOPIEREN: Nur package-Dateien relativ zum Build-Kontext (KEINE /workspace-Pfade)
# package*.json deckt sowohl package.json als auch package-lock.json ab, falls vorhanden
COPY package*.json ./
# Install dependencies (nutze npm, da Compose "npm run dev" nutzt)
RUN npm install
# Installation robust machen: npm ci erfordert package-lock.json; fallback auf npm install
RUN if [ -f package-lock.json ]; then \
npm ci --legacy-peer-deps; \
else \
npm install --legacy-peer-deps; \
fi && \
npm cache clean --force
# Copy source code
COPY . .
EXPOSE 5173 9230
# Expose ports
EXPOSE 3000 9229
# Standard-Dev-Command (wird von Compose überschrieben)
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]

File diff suppressed because it is too large Load Diff

View File

@@ -4,22 +4,42 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 3000",
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@syncfusion/ej2-react-buttons": "^30.1.37",
"@syncfusion/ej2-react-calendars": "^30.1.37",
"@syncfusion/ej2-react-dropdowns": "^30.1.37",
"@syncfusion/ej2-react-filemanager": "^30.1.38",
"@syncfusion/ej2-react-grids": "^30.1.37",
"@syncfusion/ej2-react-inputs": "^30.1.38",
"@syncfusion/ej2-react-kanban": "^30.1.37",
"@syncfusion/ej2-react-notifications": "^30.1.37",
"@syncfusion/ej2-react-popups": "^30.1.37",
"@syncfusion/ej2-react-schedule": "^30.1.37",
"@syncfusion/ej2-base": "^30.2.0",
"@syncfusion/ej2-buttons": "^30.2.0",
"@syncfusion/ej2-calendars": "^30.2.0",
"@syncfusion/ej2-dropdowns": "^30.2.0",
"@syncfusion/ej2-gantt": "^32.1.23",
"@syncfusion/ej2-grids": "^30.2.0",
"@syncfusion/ej2-icons": "^30.2.0",
"@syncfusion/ej2-inputs": "^30.2.0",
"@syncfusion/ej2-kanban": "^30.2.0",
"@syncfusion/ej2-layouts": "^30.2.0",
"@syncfusion/ej2-lists": "^30.2.0",
"@syncfusion/ej2-navigations": "^30.2.0",
"@syncfusion/ej2-notifications": "^30.2.0",
"@syncfusion/ej2-popups": "^30.2.0",
"@syncfusion/ej2-react-base": "^30.2.0",
"@syncfusion/ej2-react-buttons": "^30.2.0",
"@syncfusion/ej2-react-calendars": "^30.2.0",
"@syncfusion/ej2-react-dropdowns": "^30.2.0",
"@syncfusion/ej2-react-filemanager": "^30.2.0",
"@syncfusion/ej2-react-gantt": "^32.1.23",
"@syncfusion/ej2-react-grids": "^30.2.0",
"@syncfusion/ej2-react-inputs": "^30.2.0",
"@syncfusion/ej2-react-kanban": "^30.2.0",
"@syncfusion/ej2-react-layouts": "^30.2.0",
"@syncfusion/ej2-react-navigations": "^30.2.0",
"@syncfusion/ej2-react-notifications": "^30.2.0",
"@syncfusion/ej2-react-popups": "^30.2.0",
"@syncfusion/ej2-react-schedule": "^30.2.0",
"@syncfusion/ej2-react-splitbuttons": "^30.2.0",
"@syncfusion/ej2-splitbuttons": "^30.2.0",
"cldr-data": "^36.0.4",
"lucide-react": "^0.522.0",
"react": "^19.1.0",
@@ -28,9 +48,6 @@
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-router-dom": "^5.3.3",
@@ -49,8 +66,6 @@
"prettier": "^3.5.3",
"stylelint": "^16.21.0",
"stylelint-config-standard": "^38.0.0",
"stylelint-config-tailwindcss": "^1.0.0",
"tailwindcss": "^3.4.17",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"

2310
dashboard/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,172 @@
{
"appName": "Infoscreen-Management",
"version": "2026.1.0-alpha.14",
"copyright": "© 2026 Third-Age-Applications",
"supportContact": "support@third-age-applications.com",
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
"techStack": {
"Frontend": "React, Vite, TypeScript, Syncfusion UI Components (Material 3)",
"Backend": "Python (Flask), SQLAlchemy",
"Database": "MariaDB",
"Realtime": "Mosquitto (MQTT)",
"Containerization": "Docker"
},
"openSourceComponents": {
"frontend": [
{ "name": "React", "license": "MIT" },
{ "name": "Vite", "license": "MIT" },
{ "name": "Lucide Icons", "license": "ISC" },
{ "name": "Syncfusion UI Components", "license": "Kommerziell / Community" }
],
"backend": [
{ "name": "Flask", "license": "BSD" },
{ "name": "SQLAlchemy", "license": "MIT" },
{ "name": "Paho-MQTT", "license": "EPL/EDL" },
{ "name": "Alembic", "license": "MIT" }
]
},
"buildInfo": {
"buildDate": "2025-12-29T12:00:00Z",
"commitId": "9f2ae8b44c3a"
},
"changelog": [
{
"version": "2026.1.0-alpha.14",
"date": "2026-01-28",
"changes": [
"✨ UI: Neue 'Ressourcen'-Seite mit Timeline-Ansicht zeigt aktive Events für alle Raumgruppen parallel.",
"📊 Ressourcen: Kompakte Zeitachsen-Darstellung.",
"🎯 Ressourcen: Zeigt aktuell laufende Events mit Typ, Titel und Zeitfenster in Echtzeit.",
"🔄 Ressourcen: Gruppensortierung anpassbar mit visueller Reihenfolgen-Verwaltung.",
"🎨 Ressourcen: Farbcodierte Event-Balken entsprechend dem Gruppen-Theme."
]
},
{
"version": "2025.1.0-alpha.13",
"date": "2025-12-29",
"changes": [
"👥 UI: Neue 'Benutzer'-Seite mit vollständiger Benutzerverwaltung (CRUD) für Admins und Superadmins.",
"🔐 Benutzer-Seite: Sortierbare Gitter-Tabelle mit Benutzer-ID, Benutzername und Rolle; 20 Einträge pro Seite.",
"📊 Benutzer-Seite: Statistik-Karten zeigen Gesamtanzahl, aktive und inaktive Benutzer.",
" Benutzer-Seite: Dialog zum Erstellen neuer Benutzer (Benutzername, Passwort, Rolle, Status).",
"✏️ Benutzer-Seite: Dialog zum Bearbeiten von Benutzer-Details mit Schutz vor Selbst-Änderungen.",
"🔑 Benutzer-Seite: Dialog zum Zurücksetzen von Passwörtern durch Admins (ohne alte Passwort-Anfrage).",
"❌ Benutzer-Seite: Dialog zum Löschen von Benutzern (nur für Superadmins; verhindert Selbst-Löschung).",
"📋 Benutzer-Seite: Details-Modal zeigt Audit-Informationen (letzte Anmeldung, Passwort-Änderung, Abmeldungen).",
"🎨 Benutzer-Seite: Rollen-Abzeichen mit Farb-Kodierung (Benutzer: grau, Editor: blau, Admin: grün, Superadmin: rot).",
"🔒 Header-Menü: Neue 'Passwort ändern'-Option im Benutzer-Dropdown für Selbstbedienung (alle Benutzer).",
"🔐 Passwort-Dialog: Authentifizierung mit aktuellem Passwort erforderlich (min. 6 Zeichen für neues Passwort).",
"🎯 Rollenbasiert: Menu-Einträge werden basierend auf Benutzer-Rolle gefiltert (z.B. 'Benutzer' nur für Admin+)."
]
},
{
"version": "2025.1.0-alpha.12",
"date": "2025-11-27",
"changes": [
"✨ Dashboard: Komplett überarbeitetes Dashboard mit Karten-Design für alle Raumgruppen.",
"📊 Dashboard: Globale Statistik-Übersicht zeigt Gesamt-Infoscreens, Online/Offline-Anzahl und Warnungen.",
"🔍 Dashboard: Filter-Buttons (Alle, Online, Offline, Warnungen) mit dynamischen Zählern.",
"🎯 Dashboard: Anzeige des aktuell laufenden Events pro Gruppe (Titel, Typ, Datum, Uhrzeit in lokaler Zeitzone).",
"📈 Dashboard: Farbcodierte Health-Bars zeigen Online/Offline-Verhältnis je Gruppe.",
"👥 Dashboard: Ausklappbare Client-Details mit 'Zeit seit letztem Lebenszeichen' (z.B. 'vor 5 Min.').",
"🔄 Dashboard: Sammel-Neustart-Funktion für alle offline Clients einer Gruppe.",
"⏱️ Dashboard: Auto-Aktualisierung alle 15 Sekunden; manueller Aktualisierungs-Button verfügbar."
]
},
{
"version": "2025.1.0-alpha.11",
"date": "2025-11-05",
"changes": [
"🎬 Client: Clients können jetzt Video-Events aus dem Terminplaner abspielen (Streaming mit Seek via Byte-Range).",
"🧭 Einstellungen: Neues verschachteltes Tab-Layout mit kontrollierter Tab-Auswahl (keine Sprünge in Unter-Tabs).",
"📅 Einstellungen Akademischer Kalender: Schulferien Import und Liste zusammengeführt in ‘📥 Import & Liste.",
"🗓️ Events-Modal: Video-Optionen erweitert (Autoplay, Loop, Lautstärke, Ton aus). Werte werden bei neuen Terminen aus System-Defaults initialisiert.",
"⚙️ Einstellungen Events Videos: Globale Defaults für Autoplay, Loop, Lautstärke und Mute (Keys: video_autoplay, video_loop, video_volume, video_muted)."
]
},
{
"version": "2025.1.0-alpha.10",
"date": "2025-10-25",
"changes": [
"🎬 Client: Client kann jetzt Videos wiedergeben (Playback/UI surface) — Benutzerseitige Präsentation wurde ergänzt.",
"🧩 UI: Event-Modal ergänzt um Video-Auswahl und Wiedergabe-Optionen (Autoplay, Loop, Lautstärke).",
"📁 Medien-UI: FileManager erlaubt größere Uploads für Full-HD-Videos; Client-seitige Validierung begrenzt Videolänge auf 10 Minuten."
]
},
{
"version": "2025.1.0-alpha.9",
"date": "2025-10-19",
"changes": [
"🆕 Events: Darstellung für WebUntis harmonisiert mit Website (UI/representation).",
"🛠️ Einstellungen Events: WebUntis verwendet jetzt die bestehende Supplement-Table-Einstellung (Settings UI updated)."
]
},
{
"version": "2025.1.0-alpha.8",
"date": "2025-10-18",
"changes": [
"✨ Einstellungen Events Präsentationen: Neue UI-Felder für Slide-Show Intervall, Page-Progress und Auto-Progress.",
" UI: Event-Modal lädt Präsentations-Einstellungen aus Global-Defaults bzw. Event-Daten (behaviour surfaced in UI)."
]
},
{
"version": "2025.1.0-alpha.7",
"date": "2025-10-16",
"changes": [
"✨ Einstellungen-Seite: Neues Tab-Layout (Syncfusion) mit rollenbasierter Sichtbarkeit.",
"🗓️ Einstellungen Events: WebUntis/Vertretungsplan in Events-Tab (enable/preview in UI).",
"📅 UI: Akademische Periode kann in der Einstellungen-Seite direkt gesetzt werden."
]
},
{
"version": "2025.1.0-alpha.6",
"date": "2025-10-15",
"changes": [
"✨ UI: Benutzer-Menü (top-right) mit Name/Rolle und Einträgen 'Profil' und 'Abmelden'."
]
},
{
"version": "2025.1.0-alpha.5",
"date": "2025-10-14",
"changes": [
"✨ UI: Einheitlicher Lösch-Workflow für Termine mit benutzerfreundlichen Dialogen (Einzeltermin, Einzelinstanz, Serie).",
"🔧 Frontend: RecurrenceAlert/DeleteAlert werden abgefangen und durch eigene Dialoge ersetzt (Verbesserung der UX).",
"✅ Bugfix (UX): Keine doppelten oder verwirrenden Bestätigungsdialoge mehr beim Löschen von Serienterminen."
]
},
{
"version": "2025.1.0-alpha.4",
"date": "2025-10-11",
"changes": [
"🎨 Theme: Umstellung auf Syncfusion Material 3; zentrale CSS-Imports (UI theme update).",
"🧩 UI: Gruppenverwaltung ('infoscreen_groups') auf Syncfusion-Komponenten umgestellt.",
"🔔 UI: Vereinheitlichte Notifications / Toast-Texte für konsistente UX."
]
},
{
"version": "2025.1.0-alpha.3",
"date": "2025-09-21",
"changes": [
"🧭 UI: Periode-Auswahl (Syncfusion) neben Gruppenauswahl; kompakte Layout-Verbesserung.",
"✅ Anzeige: Abzeichen für vorhandenen Ferienplan + 'Ferien im Blick' Zähler (UI indicator).",
"📤 UI: Ferien-Upload (TXT/CSV) Benutzer-Workflow ergänzt."
]
},
{
"version": "2025.1.0-alpha.2",
"date": "2025-09-01",
"changes": [
"UI Fix: Fehler beim Umschalten der Ansicht auf der Medien-Seite behoben."
]
},
{
"version": "2025.1.0-alpha.1",
"date": "2025-08-30",
"changes": [
"🆕 UI: Programminfo-Seite mit dynamischen Daten, Build-Infos und Changelog.",
"✨ UI: Logout-Funktionalität (Frontend) implementiert.",
"🐛 UI Fix: Breite der Sidebar im eingeklappten Zustand korrigiert."
]
}
]
}

View File

@@ -1,35 +1,59 @@
@import "../node_modules/@syncfusion/ej2-base/styles/material.css";
@import "../node_modules/@syncfusion/ej2-buttons/styles/material.css";
@import "../node_modules/@syncfusion/ej2-calendars/styles/material.css";
@import "../node_modules/@syncfusion/ej2-dropdowns/styles/material.css";
@import "../node_modules/@syncfusion/ej2-inputs/styles/material.css";
@import "../node_modules/@syncfusion/ej2-lists/styles/material.css";
@import "../node_modules/@syncfusion/ej2-navigations/styles/material.css";
@import "../node_modules/@syncfusion/ej2-popups/styles/material.css";
@import "../node_modules/@syncfusion/ej2-splitbuttons/styles/material.css";
@import "../node_modules/@syncfusion/ej2-react-schedule/styles/material.css";
@import "../node_modules/@syncfusion/ej2-kanban/styles/material.css";
@import "../node_modules/@syncfusion/ej2-notifications/styles/material.css";
@import "../node_modules/@syncfusion/ej2-react-filemanager/styles/material.css";
@import "../node_modules/@syncfusion/ej2-layouts/styles/material.css";
@import "../node_modules/@syncfusion/ej2-grids/styles/material.css";
@import "../node_modules/@syncfusion/ej2-icons/styles/material.css";
/* Removed legacy Syncfusion material theme imports; using material3 imports in main.tsx */
body {
font-family: Inter, 'Segoe UI', Roboto, Arial, sans-serif;
overflow: hidden; /* Verhindert den Scrollbalken auf der obersten Ebene */
}
:root {
--sidebar-bg: #e5d8c7;
--sidebar-fg: #78591c;
--sidebar-border: #d6c3a6;
--sidebar-text: #000;
--sidebar-hover-bg: #d1b89b;
--sidebar-hover-text: #000;
--sidebar-active-bg: #cda76b;
--sidebar-active-text: #fff;
}
/* Layout-Container für Sidebar und Content */
.layout-container {
display: flex;
height: 100vh; /* Feste Höhe auf die des Viewports setzen */
overflow: hidden; /* Verhindert, dass der Scrollbalken den gesamten Container betrifft */
}
/* Sidebar fixieren, keine Scrollbalken, volle Höhe */
.sidebar-theme {
background-color: var(--sidebar-bg);
color: var(--sidebar-fg);
color: var(--sidebar-text);
font-size: 1.15rem;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
flex-shrink: 0;
z-index: 10; /* Stellt sicher, dass die Sidebar über dem Inhalt ist */
height: 100vh; /* Volle Browser-Höhe */
min-height: 100vh; /* Mindesthöhe für volle Browser-Höhe */
max-height: 100vh; /* Maximale Höhe begrenzen */
display: flex !important;
flex-direction: column !important;
overflow: hidden !important;
}
/* Sicherstelle vertikale Anordnung der Navigation und Footer am Ende */
.sidebar-theme nav {
display: flex !important;
flex-direction: column !important;
flex: 1 1 auto !important;
overflow-y: auto !important;
min-height: 0 !important; /* Ermöglicht Flex-Shrinking */
}
/* Footer-Bereich am unteren Ende fixieren */
.sidebar-theme > div:last-child {
margin-top: auto !important;
flex-shrink: 0 !important;
min-height: auto !important;
padding-bottom: 0.5rem !important; /* Zusätzlicher Abstand vom unteren Rand */
}
.sidebar-theme .sidebar-link {
@@ -37,6 +61,9 @@ body {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex !important;
width: 100% !important;
box-sizing: border-box;
}
.sidebar-theme .sidebar-logout {
@@ -45,24 +72,48 @@ body {
text-align: left;
width: 100%;
font-size: 1.15rem;
display: flex !important;
box-sizing: border-box;
}
.sidebar-theme .sidebar-btn,
.sidebar-theme .sidebar-link,
.sidebar-theme .sidebar-logout {
background-color: var(--sidebar-bg);
color: var(--sidebar-fg);
transition: background 0.2s, color 0.2s;
font-weight: 500;
.sidebar-link:hover,
.sidebar-logout:hover {
background-color: var(--sidebar-hover-bg);
color: var(--sidebar-hover-text);
}
.sidebar-theme .sidebar-btn:hover,
.sidebar-theme .sidebar-link:hover,
.sidebar-theme .sidebar-logout:hover {
background-color: var(--sidebar-fg);
color: var(--sidebar-bg);
.sidebar-link.active {
background-color: var(--sidebar-active-bg);
color: var(--sidebar-active-text);
font-weight: bold;
}
/* === START: SYNCFUSION-KOMPATIBLES LAYOUT === */
/* Der Inhaltsbereich arbeitet mit Syncfusion's natürlichem Layout */
.content-area {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0; /* Verhindert Flex-Item-Overflow */
}
.content-header {
flex-shrink: 0; /* Header soll nicht schrumpfen */
}
.page-content {
flex-grow: 1; /* Füllt den verbleibenden Platz */
overflow-y: auto; /* NUR dieser Bereich scrollt */
padding: 2rem;
background-color: #f3f4f6;
}
/* === ENDE: SYNCFUSION-KOMPATIBLES LAYOUT === */
/* Kanban-Karten im Sidebar-Style */
.e-kanban .e-card,
.e-kanban .e-card .e-card-content,
@@ -106,4 +157,104 @@ body {
color: color-mix(in srgb, var(--sidebar-fg) 85%, #000 15%) !important;
}
/* Entferne den globalen Scrollbalken von .main-content! */
.main-content {
width: 100%;
overflow-x: auto; /* Wiederherstellen des ursprünglichen Scroll-Verhaltens */
padding-bottom: 8px;
}
/* Entfernt - Syncfusion verwaltet das Layout selbst */
/* Grundlegende Sidebar-Styles - Syncfusion-kompatibel */
#sidebar .sidebar-link,
#sidebar .sidebar-logout {
display: flex !important;
align-items: center !important;
gap: 8px !important;
}
#sidebar .sidebar-link svg,
#sidebar .sidebar-logout svg {
flex-shrink: 0 !important;
}
/* Text standardmäßig IMMER sichtbar */
#sidebar .sidebar-link .sidebar-text,
#sidebar .sidebar-logout .sidebar-text {
margin-left: 0 !important;
display: inline-block !important;
opacity: 1 !important;
transition: opacity 0.3s, transform 0.3s !important;
}
#sidebar .sidebar-link:hover,
#sidebar .sidebar-logout:hover {
background-color: var(--sidebar-hover-bg) !important;
color: var(--sidebar-hover-text) !important;
}
/* Expanded state - Text sichtbar (Standard) */
#sidebar .sidebar-theme.expanded .sidebar-link,
#sidebar .sidebar-theme.expanded .sidebar-logout {
justify-content: flex-start !important;
padding: 12px 24px !important;
gap: 8px !important;
}
#sidebar .sidebar-theme.expanded .sidebar-text {
display: inline-block !important;
opacity: 1 !important;
}
#sidebar .sidebar-theme.expanded .sidebar-link svg,
#sidebar .sidebar-theme.expanded .sidebar-logout svg {
margin-right: 8px !important;
}
/* Collapsed state - nur Icons */
#sidebar .sidebar-theme.collapsed .sidebar-link,
#sidebar .sidebar-theme.collapsed .sidebar-logout {
justify-content: center !important;
padding: 12px 8px !important;
gap: 0 !important;
position: relative !important;
}
#sidebar .sidebar-theme.collapsed .sidebar-text {
display: none !important;
}
#sidebar .sidebar-theme.collapsed .sidebar-link svg,
#sidebar .sidebar-theme.collapsed .sidebar-logout svg {
margin-right: 0 !important;
}
/* Syncfusion TooltipComponent wird jetzt verwendet - CSS-Tooltips entfernt */
/* Logo und Versionsnummer im collapsed state ausblenden */
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(-50%) translateX(-5px);
}
to {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
}
/* Logo und Versionsnummer im collapsed state ausblenden */
#sidebar .sidebar-theme.collapsed img {
display: none !important;
}
#sidebar .sidebar-theme.collapsed .version-info {
display: none !important;
}

View File

@@ -1,5 +1,11 @@
import React, { useState } from 'react';
import { BrowserRouter as Router, Routes, Route, Link, Outlet } from 'react-router-dom';
import { BrowserRouter as Router, Routes, Route, Link, Outlet, useNavigate } from 'react-router-dom';
import { SidebarComponent } from '@syncfusion/ej2-react-navigations';
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
import { DropDownButtonComponent } from '@syncfusion/ej2-react-splitbuttons';
import type { MenuEventArgs } from '@syncfusion/ej2-splitbuttons';
import { TooltipComponent, DialogComponent } from '@syncfusion/ej2-react-popups';
import { TextBoxComponent } from '@syncfusion/ej2-react-inputs';
import logo from './assets/logo.png';
import './App.css';
@@ -14,132 +20,24 @@ import {
Monitor,
MonitorDotIcon,
LogOut,
Wrench,
Info,
} from 'lucide-react';
import { ToastProvider } from './components/ToastProvider';
const sidebarItems = [
{ name: 'Dashboard', path: '/', icon: LayoutDashboard },
{ name: 'Termine', path: '/termine', icon: Calendar },
{ name: 'Ressourcen', path: '/ressourcen', icon: Boxes },
{ name: 'Raumgruppen', path: '/infoscr_groups', icon: MonitorDotIcon },
{ name: 'Infoscreens', path: '/Infoscreens', icon: Monitor },
{ name: 'Medien', path: '/medien', icon: Image },
{ name: 'Benutzer', path: '/benutzer', icon: User },
{ name: 'Einstellungen', path: '/einstellungen', icon: Settings },
{ name: 'Dashboard', path: '/', icon: LayoutDashboard, minRole: 'user' },
{ name: 'Termine', path: '/termine', icon: Calendar, minRole: 'user' },
{ name: 'Ressourcen', path: '/ressourcen', icon: Boxes, minRole: 'editor' },
{ name: 'Raumgruppen', path: '/infoscr_groups', icon: MonitorDotIcon, minRole: 'admin' },
{ name: 'Infoscreen-Clients', path: '/clients', icon: Monitor, minRole: 'admin' },
{ name: 'Erweiterungsmodus', path: '/setup', icon: Wrench, minRole: 'admin' },
{ name: 'Medien', path: '/medien', icon: Image, minRole: 'editor' },
{ name: 'Benutzer', path: '/benutzer', icon: User, minRole: 'admin' },
{ name: 'Einstellungen', path: '/einstellungen', icon: Settings, minRole: 'admin' },
{ name: 'Programminfo', path: '/programminfo', icon: Info, minRole: 'user' },
];
const Layout: React.FC = () => {
const [collapsed, setCollapsed] = useState(false);
return (
<div className="flex min-h-screen">
{/* Sidebar */}
<aside
className={`sidebar-theme flex flex-col transition-all duration-300 ${collapsed ? 'w-20' : 'w-30'}`}
>
<div
className="h-20 flex items-center justify-center border-b"
style={{ borderColor: 'var(--sidebar-border)' }}
>
<img
src={logo}
alt="Logo"
className="h-12"
style={{ display: collapsed ? 'none' : 'block' }}
/>
</div>
<button
className="sidebar-btn p-2 focus:outline-none transition-colors"
onClick={() => setCollapsed(!collapsed)}
aria-label={collapsed ? 'Sidebar ausklappen' : 'Sidebar einklappen'}
>
<span style={{ fontSize: 20 }}>{collapsed ? '▶' : '◀'}</span>
</button>
<nav className="flex-1 mt-4">
{sidebarItems.map(item => {
const Icon = item.icon;
return (
<Link
key={item.path}
to={item.path}
className="sidebar-link flex items-center gap-3 px-6 py-3 transition-colors no-underline"
title={collapsed ? item.name : undefined}
>
<Icon size={22} />
{!collapsed && item.name}
</Link>
);
})}
</nav>
{/* Abmelden-Button immer ganz unten */}
<div className="mb-4 mt-auto">
<button
className="sidebar-logout flex items-center gap-3 px-6 py-3 w-full transition-colors no-underline"
title={collapsed ? 'Abmelden' : undefined}
onClick={() => {
// Hier ggf. Logout-Logik einfügen
window.location.href = '/logout';
}}
>
<LogOut size={22} />
{!collapsed && 'Abmelden'}
</button>
</div>
</aside>
{/* Main Content */}
<div className="flex-1 flex flex-col">
{/* Header */}
<header
className="flex items-center px-8 shadow"
style={{
backgroundColor: '#e5d8c7',
color: '#78591c',
height: 'calc(48px + 20px)',
fontSize: '1.15rem',
fontFamily:
'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif',
}}
>
<img
src={logo}
alt="Logo"
className="h-12 mr-4"
style={{ marginTop: 10, marginBottom: 10 }}
/>
<span className="text-2xl font-bold mr-8">Infoscreen-Management</span>
<span className="ml-auto" style={{ color: '#78591c' }}>
[Organisationsname]
</span>
</header>
<main className="flex-1 p-8 bg-gray-100">
<Outlet />
</main>
</div>
</div>
);
};
const App: React.FC = () => (
<Router>
<ToastProvider>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="termine" element={<Appointments />} />
<Route path="ressourcen" element={<Ressourcen />} />
<Route path="Infoscreens" element={<Infoscreens />} />
<Route path="infoscr_groups" element={<Infoscreen_groups />} />
<Route path="medien" element={<Media />} />
<Route path="benutzer" element={<Benutzer />} />
<Route path="einstellungen" element={<Einstellungen />} />
</Route>
</Routes>
</ToastProvider>
</Router>
);
export default App;
// Dummy Components (können in eigene Dateien ausgelagert werden)
import Dashboard from './dashboard';
import Appointments from './appointments';
@@ -147,5 +45,474 @@ import Ressourcen from './ressourcen';
import Infoscreens from './clients';
import Infoscreen_groups from './infoscreen_groups';
import Media from './media';
import Benutzer from './benutzer';
import Einstellungen from './einstellungen';
import Benutzer from './users';
import Einstellungen from './settings';
import SetupMode from './SetupMode';
import Programminfo from './programminfo';
import Logout from './logout';
import Login from './login';
import { useAuth } from './useAuth';
import { changePassword } from './apiAuth';
import { useToast } from './components/ToastProvider';
// ENV aus .env holen (Platzhalter, im echten Projekt über process.env oder API)
// const ENV = import.meta.env.VITE_ENV || 'development';
const Layout: React.FC = () => {
const [version, setVersion] = useState('');
const [isCollapsed, setIsCollapsed] = useState(false);
const [organizationName, setOrganizationName] = useState('');
let sidebarRef: SidebarComponent | null;
const { user } = useAuth();
const toast = useToast();
const navigate = useNavigate();
// Change password dialog state
const [showPwdDialog, setShowPwdDialog] = useState(false);
const [pwdCurrent, setPwdCurrent] = useState('');
const [pwdNew, setPwdNew] = useState('');
const [pwdConfirm, setPwdConfirm] = useState('');
const [pwdBusy, setPwdBusy] = useState(false);
React.useEffect(() => {
fetch('/program-info.json')
.then(res => res.json())
.then(data => setVersion(data.version))
.catch(err => console.error('Failed to load version info:', err));
}, []);
// Load organization name
React.useEffect(() => {
const loadOrgName = async () => {
try {
const { getOrganizationName } = await import('./apiSystemSettings');
const data = await getOrganizationName();
setOrganizationName(data.name || '');
} catch (err) {
console.error('Failed to load organization name:', err);
}
};
loadOrgName();
// Listen for organization name updates from Settings page
const handleUpdate = () => loadOrgName();
window.addEventListener('organizationNameUpdated', handleUpdate);
return () => window.removeEventListener('organizationNameUpdated', handleUpdate);
}, []);
const toggleSidebar = () => {
if (sidebarRef) {
sidebarRef.toggle();
}
};
const onSidebarChange = () => {
// Syncfusion unterscheidet zwischen isOpen (true/false) und dem Dock-Modus
// Im Dock-Modus ist isOpen=true, aber die Sidebar ist kollabiert
const sidebar = sidebarRef?.element;
if (sidebar) {
const currentWidth = sidebar.style.width;
const newCollapsedState = currentWidth === '60px' || currentWidth.includes('60');
setIsCollapsed(newCollapsedState);
}
};
const submitPasswordChange = async () => {
if (!pwdCurrent || !pwdNew || !pwdConfirm) {
toast.show({ content: 'Bitte alle Felder ausfüllen', cssClass: 'e-toast-warning' });
return;
}
if (pwdNew.length < 6) {
toast.show({ content: 'Neues Passwort muss mindestens 6 Zeichen haben', cssClass: 'e-toast-warning' });
return;
}
if (pwdNew !== pwdConfirm) {
toast.show({ content: 'Passwörter stimmen nicht überein', cssClass: 'e-toast-warning' });
return;
}
setPwdBusy(true);
try {
await changePassword(pwdCurrent, pwdNew);
toast.show({ content: 'Passwort erfolgreich geändert', cssClass: 'e-toast-success' });
setShowPwdDialog(false);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Ändern des Passworts';
toast.show({ content: msg, cssClass: 'e-toast-danger' });
} finally {
setPwdBusy(false);
}
};
const sidebarTemplate = () => (
<div
className={`sidebar-theme ${isCollapsed ? 'collapsed' : 'expanded'}`}
style={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
minHeight: '100vh',
overflow: 'hidden',
}}
>
<div
style={{
borderColor: 'var(--sidebar-border)',
height: '68px',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid var(--sidebar-border)',
margin: 0,
padding: 0,
}}
>
<img
src={logo}
alt="Logo"
style={{
height: '64px',
maxHeight: '60px',
display: 'block',
margin: '0 auto',
}}
/>
</div>
<nav
style={{
flex: '1 1 auto',
display: 'flex',
flexDirection: 'column',
marginTop: '1rem',
overflowY: 'auto',
minHeight: 0, // Wichtig für Flex-Shrinking
}}
>
{sidebarItems
.filter(item => {
// Only show items the current user is allowed to see
if (!user) return false;
const roleHierarchy = ['user', 'editor', 'admin', 'superadmin'];
const userRoleIndex = roleHierarchy.indexOf(user.role);
const itemRoleIndex = roleHierarchy.indexOf(item.minRole || 'user');
return userRoleIndex >= itemRoleIndex;
})
.map(item => {
const Icon = item.icon;
const linkContent = (
<Link
key={item.path}
to={item.path}
className="sidebar-link no-underline w-full"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
gap: '8px',
padding: '12px 24px',
transition: 'background 0.2s, color 0.2s, justify-content 0.3s',
textDecoration: 'none',
color: 'var(--sidebar-fg)',
backgroundColor: 'var(--sidebar-bg)',
}}
>
<Icon size={22} style={{ flexShrink: 0, marginRight: 0 }} />
<span className="sidebar-text" style={{ marginLeft: 0, transition: 'opacity 0.3s' }}>
{item.name}
</span>
</Link>
);
// Syncfusion Tooltip nur im collapsed state
return isCollapsed ? (
<TooltipComponent
key={item.path}
content={item.name}
position="RightCenter"
opensOn="Hover"
showTipPointer={true}
animation={{
open: { effect: 'FadeIn', duration: 200 },
close: { effect: 'FadeOut', duration: 200 },
}}
>
{linkContent}
</TooltipComponent>
) : (
linkContent
);
})}
</nav>
<div
style={{
flexShrink: 0,
marginTop: 'auto',
display: 'flex',
flexDirection: 'column',
minHeight: 'auto',
}}
>
{(() => {
const logoutContent = (
<Link
to="/logout"
className="sidebar-logout no-underline w-full"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
gap: '8px',
padding: '12px 24px',
transition: 'background 0.2s, color 0.2s, justify-content 0.3s',
textDecoration: 'none',
color: 'var(--sidebar-fg)',
backgroundColor: 'var(--sidebar-bg)',
border: 'none',
cursor: 'pointer',
fontSize: '1.15rem',
}}
>
<LogOut size={22} style={{ flexShrink: 0, marginRight: 0 }} />
<span className="sidebar-text" style={{ marginLeft: 0, transition: 'opacity 0.3s' }}>
Abmelden
</span>
</Link>
);
// Syncfusion Tooltip nur im collapsed state
return isCollapsed ? (
<TooltipComponent
content="Abmelden"
position="RightCenter"
opensOn="Hover"
showTipPointer={true}
animation={{
open: { effect: 'FadeIn', duration: 200 },
close: { effect: 'FadeOut', duration: 200 },
}}
>
{logoutContent}
</TooltipComponent>
) : (
logoutContent
);
})()}
{version && (
<div
className="version-info px-6 py-2 text-xs text-center opacity-70 border-t"
style={{ borderColor: 'var(--sidebar-border)' }}
>
Version {version}
</div>
)}
</div>
</div>
);
return (
<div className="layout-container">
<SidebarComponent
id="sidebar"
ref={(sidebar: SidebarComponent | null) => {
sidebarRef = sidebar;
}}
width="256px"
target=".layout-container"
isOpen={true}
closeOnDocumentClick={false}
enableGestures={false}
type="Auto"
enableDock={true}
dockSize="60px"
change={onSidebarChange}
>
{sidebarTemplate()}
</SidebarComponent>
<div className="content-area">
<header
className="content-header flex items-center shadow"
style={{
backgroundColor: '#e5d8c7',
color: '#78591c',
height: '68px', // Exakt gleiche Höhe wie Sidebar-Header
fontSize: '1.15rem',
fontFamily:
'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif',
margin: 0,
padding: '0 2rem 0 0', // Nur rechts Padding, links kein Padding
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
}}
>
<ButtonComponent
cssClass="e-inherit"
iconCss="e-icons e-menu"
onClick={toggleSidebar}
isToggle={true}
style={{
margin: '0 1rem 0 0', // Nur rechts Margin für Abstand zum Logo
padding: '8px 12px',
minWidth: '44px',
height: '44px',
flexShrink: 0,
}}
/>
<img src={logo} alt="Logo" className="h-16 mr-4" style={{ maxHeight: '60px' }} />
<span className="text-2xl font-bold mr-8" style={{ color: '#78591c' }}>
Infoscreen-Management
</span>
<div style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 16 }}>
{organizationName && (
<span className="text-lg font-medium" style={{ color: '#78591c' }}>
{organizationName}
</span>
)}
{user && (
<DropDownButtonComponent
items={[
{ text: 'Passwort ändern', id: 'change-password', iconCss: 'e-icons e-lock' },
{ separator: true },
{ text: 'Abmelden', id: 'logout', iconCss: 'e-icons e-logout' },
]}
select={(args: MenuEventArgs) => {
if (args.item.id === 'change-password') {
setPwdCurrent('');
setPwdNew('');
setPwdConfirm('');
setShowPwdDialog(true);
} else if (args.item.id === 'logout') {
navigate('/logout');
}
}}
cssClass="e-inherit"
>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<User size={18} />
<span style={{ fontWeight: 600 }}>{user.username}</span>
<span
style={{
fontSize: '0.8rem',
textTransform: 'uppercase',
opacity: 0.85,
border: '1px solid rgba(120, 89, 28, 0.25)',
borderRadius: 6,
padding: '2px 6px',
backgroundColor: 'rgba(255, 255, 255, 0.6)',
}}
>
{user.role}
</span>
</div>
</DropDownButtonComponent>
)}
</div>
</header>
<DialogComponent
isModal={true}
visible={showPwdDialog}
width="480px"
header="Passwort ändern"
showCloseIcon={true}
close={() => setShowPwdDialog(false)}
footerTemplate={() => (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<ButtonComponent cssClass="e-flat" onClick={() => setShowPwdDialog(false)} disabled={pwdBusy}>
Abbrechen
</ButtonComponent>
<ButtonComponent cssClass="e-primary" onClick={submitPasswordChange} disabled={pwdBusy}>
{pwdBusy ? 'Speichere...' : 'Speichern'}
</ButtonComponent>
</div>
)}
>
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<label style={{ display: 'block', marginBottom: 6, fontWeight: 500 }}>Aktuelles Passwort *</label>
<TextBoxComponent
type="password"
placeholder="Aktuelles Passwort"
value={pwdCurrent}
input={(e: any) => setPwdCurrent(e.value)}
disabled={pwdBusy}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: 6, fontWeight: 500 }}>Neues Passwort *</label>
<TextBoxComponent
type="password"
placeholder="Mindestens 6 Zeichen"
value={pwdNew}
input={(e: any) => setPwdNew(e.value)}
disabled={pwdBusy}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: 6, fontWeight: 500 }}>Neues Passwort bestätigen *</label>
<TextBoxComponent
type="password"
placeholder="Wiederholen"
value={pwdConfirm}
input={(e: any) => setPwdConfirm(e.value)}
disabled={pwdBusy}
/>
</div>
</div>
</DialogComponent>
<main className="page-content">
<Outlet />
</main>
</div>
</div>
);
};
const App: React.FC = () => {
// Automatische Navigation zu /clients bei leerer Beschreibung entfernt
const RequireAuth: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated, loading } = useAuth();
if (loading) return <div style={{ padding: 24 }}>Lade ...</div>;
if (!isAuthenticated) return <Login />;
return <>{children}</>;
};
return (
<ToastProvider>
<Routes>
<Route
path="/"
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route index element={<Dashboard />} />
<Route path="termine" element={<Appointments />} />
<Route path="ressourcen" element={<Ressourcen />} />
<Route path="infoscr_groups" element={<Infoscreen_groups />} />
<Route path="medien" element={<Media />} />
<Route path="benutzer" element={<Benutzer />} />
<Route path="einstellungen" element={<Einstellungen />} />
<Route path="clients" element={<Infoscreens />} />
<Route path="setup" element={<SetupMode />} />
<Route path="programminfo" element={<Programminfo />} />
</Route>
<Route path="/logout" element={<Logout />} />
<Route path="/login" element={<Login />} />
</Routes>
</ToastProvider>
);
};
const AppWrapper: React.FC = () => (
<Router>
<App />
</Router>
);
export default AppWrapper;

174
dashboard/src/SetupMode.tsx Normal file
View File

@@ -0,0 +1,174 @@
import React, { useEffect, useState } from 'react';
import { fetchClientsWithoutDescription, setClientDescription } from './apiClients';
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
import { TextBoxComponent } from '@syncfusion/ej2-react-inputs';
import { GridComponent, ColumnsDirective, ColumnDirective } from '@syncfusion/ej2-react-grids';
import { DialogComponent } from '@syncfusion/ej2-react-popups';
import { useClientDelete } from './hooks/useClientDelete';
type Client = {
uuid: string;
hostname?: string;
ip_address?: string;
last_alive?: string;
};
const SetupMode: React.FC = () => {
const [clients, setClients] = useState<Client[]>([]);
const [descriptions, setDescriptions] = useState<Record<string, string>>({});
const [loading /* setLoading */] = useState(false);
const [inputActive, setInputActive] = useState(false);
// Lösch-Logik aus Hook (analog zu clients.tsx)
const { showDialog, deleteClientId, handleDelete, confirmDelete, cancelDelete } = useClientDelete(
async uuid => {
// Nach dem Löschen neu laden!
const updated = await fetchClientsWithoutDescription();
setClients(updated);
setDescriptions(prev => {
const copy = { ...prev };
delete copy[uuid];
return copy;
});
}
);
// Hilfsfunktion zum Vergleich der Clients
const isEqual = (a: Client[], b: Client[]) => {
if (a.length !== b.length) return false;
const aSorted = [...a].sort((x, y) => x.uuid.localeCompare(y.uuid));
const bSorted = [...b].sort((x, y) => x.uuid.localeCompare(y.uuid));
for (let i = 0; i < aSorted.length; i++) {
if (aSorted[i].uuid !== bSorted[i].uuid) return false;
if (aSorted[i].hostname !== bSorted[i].hostname) return false;
if (aSorted[i].ip_address !== bSorted[i].ip_address) return false;
if (aSorted[i].last_alive !== bSorted[i].last_alive) return false;
}
return true;
};
useEffect(() => {
let polling: ReturnType<typeof setInterval> | null = null;
const fetchClients = () => {
if (inputActive) return;
fetchClientsWithoutDescription().then(list => {
setClients(prev => (isEqual(prev, list) ? prev : list));
});
};
fetchClients();
polling = setInterval(fetchClients, 5000);
return () => {
if (polling) clearInterval(polling);
};
}, [inputActive]);
const handleDescriptionChange = (uuid: string, value: string) => {
setDescriptions(prev => ({ ...prev, [uuid]: value }));
};
const handleSave = (uuid: string) => {
setClientDescription(uuid, descriptions[uuid] || '')
.then(() => {
setClients(prev => prev.filter(c => c.uuid !== uuid));
})
.catch(err => {
console.error('Fehler beim Speichern der Beschreibung:', err);
});
};
if (loading) return <div>Lade neue Clients ...</div>;
return (
<div>
<h2>Erweiterungsmodus: Neue Clients zuordnen</h2>
<GridComponent
dataSource={clients}
allowPaging={true}
pageSettings={{ pageSize: 10 }}
rowHeight={50}
width="100%"
allowTextWrap={false}
>
<ColumnsDirective>
<ColumnDirective field="uuid" headerText="UUID" width="180" />
<ColumnDirective field="hostname" headerText="Hostname" width="90" />
<ColumnDirective field="ip_address" headerText="IP" width="80" />
<ColumnDirective
headerText="Letzter Kontakt"
width="120"
template={(props: Client) => {
if (!props.last_alive) return '';
let iso = props.last_alive;
if (!iso.endsWith('Z')) iso += 'Z';
const date = new Date(iso);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}}
/>
<ColumnDirective
headerText="Beschreibung"
width="220"
template={(props: Client) => (
<TextBoxComponent
value={descriptions[props.uuid] || ''}
placeholder="Beschreibung eingeben"
change={e => handleDescriptionChange(props.uuid, e.value as string)}
focus={() => setInputActive(true)}
blur={() => setInputActive(false)}
/>
)}
/>
<ColumnDirective
headerText="Aktion"
width="180"
template={(props: Client) => (
<div style={{ display: 'flex', gap: '8px' }}>
<ButtonComponent
content="Speichern"
disabled={!descriptions[props.uuid]}
onClick={() => handleSave(props.uuid)}
/>
<ButtonComponent
content="Entfernen"
cssClass="e-danger"
onClick={e => {
e.stopPropagation();
handleDelete(props.uuid);
}}
/>
</div>
)}
/>
</ColumnsDirective>
</GridComponent>
{clients.length === 0 && <div>Keine neuen Clients ohne Beschreibung.</div>}
{/* Syncfusion Dialog für Sicherheitsabfrage */}
{showDialog && deleteClientId && (
<DialogComponent
visible={showDialog}
header="Bestätigung"
content={(() => {
const client = clients.find(c => c.uuid === deleteClientId);
const hostname = client?.hostname ? ` (${client.hostname})` : '';
return client
? `Möchten Sie diesen Client${hostname} wirklich entfernen?`
: 'Client nicht gefunden.';
})()}
showCloseIcon={true}
width="400px"
buttons={[
{ click: confirmDelete, buttonModel: { content: 'Ja', isPrimary: true } },
{ click: cancelDelete, buttonModel: { content: 'Abbrechen' } },
]}
close={cancelDelete}
/>
)}
</div>
);
};
export default SetupMode;

View File

@@ -0,0 +1,42 @@
export type AcademicPeriod = {
id: number;
name: string;
display_name?: string | null;
start_date: string; // YYYY-MM-DD
end_date: string; // YYYY-MM-DD
period_type: 'schuljahr' | 'semester' | 'trimester';
is_active: boolean;
};
async function api<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, { credentials: 'include', ...init });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
export async function getAcademicPeriodForDate(date: Date): Promise<AcademicPeriod | null> {
const iso = date.toISOString().slice(0, 10);
const { period } = await api<{ period: AcademicPeriod | null }>(
`/api/academic_periods/for_date?date=${iso}`
);
return period ?? null;
}
export async function listAcademicPeriods(): Promise<AcademicPeriod[]> {
const { periods } = await api<{ periods: AcademicPeriod[] }>(`/api/academic_periods`);
return Array.isArray(periods) ? periods : [];
}
export async function getActiveAcademicPeriod(): Promise<AcademicPeriod | null> {
const { period } = await api<{ period: AcademicPeriod | null }>(`/api/academic_periods/active`);
return period ?? null;
}
export async function setActiveAcademicPeriod(id: number): Promise<AcademicPeriod> {
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/active`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
return period;
}

182
dashboard/src/apiAuth.ts Normal file
View File

@@ -0,0 +1,182 @@
/**
* Authentication API client for the dashboard.
*
* Provides functions to interact with auth endpoints including login,
* logout, and fetching current user information.
*/
export interface User {
id: number;
username: string;
role: 'user' | 'editor' | 'admin' | 'superadmin';
is_active: boolean;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
message: string;
user: {
id: number;
username: string;
role: string;
};
}
export interface AuthCheckResponse {
authenticated: boolean;
role?: string;
}
/**
* Change password for the currently authenticated user.
*/
export async function changePassword(currentPassword: string, newPassword: string): Promise<{ message: string }> {
const res = await fetch('/api/auth/change-password', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to change password');
}
return data as { message: string };
}
/**
* Authenticate a user with username and password.
*
* @param username - The user's username
* @param password - The user's password
* @returns Promise<LoginResponse>
* @throws Error if login fails
*/
export async function login(username: string, password: string): Promise<LoginResponse> {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Important for session cookies
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok || data.error) {
throw new Error(data.error || 'Login failed');
}
return data;
}
/**
* Log out the current user.
*
* @returns Promise<void>
* @throws Error if logout fails
*/
export async function logout(): Promise<void> {
const res = await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
});
const data = await res.json();
if (!res.ok || data.error) {
throw new Error(data.error || 'Logout failed');
}
}
/**
* Fetch the current authenticated user's information.
*
* @returns Promise<User>
* @throws Error if not authenticated or request fails
*/
export async function fetchCurrentUser(): Promise<User> {
const res = await fetch('/api/auth/me', {
method: 'GET',
credentials: 'include',
});
const data = await res.json();
if (!res.ok || data.error) {
throw new Error(data.error || 'Failed to fetch current user');
}
return data as User;
}
/**
* Quick check if user is authenticated (lighter than fetchCurrentUser).
*
* @returns Promise<AuthCheckResponse>
*/
export async function checkAuth(): Promise<AuthCheckResponse> {
const res = await fetch('/api/auth/check', {
method: 'GET',
credentials: 'include',
});
const data = await res.json();
if (!res.ok) {
throw new Error('Failed to check authentication status');
}
return data;
}
/**
* Helper function to check if a user has a specific role.
*
* @param user - The user object
* @param role - The role to check for
* @returns boolean
*/
export function hasRole(user: User | null, role: string): boolean {
if (!user) return false;
return user.role === role;
}
/**
* Helper function to check if a user has any of the specified roles.
*
* @param user - The user object
* @param roles - Array of roles to check for
* @returns boolean
*/
export function hasAnyRole(user: User | null, roles: string[]): boolean {
if (!user) return false;
return roles.includes(user.role);
}
/**
* Helper function to check if user is superadmin.
*/
export function isSuperadmin(user: User | null): boolean {
return hasRole(user, 'superadmin');
}
/**
* Helper function to check if user is admin or higher.
*/
export function isAdminOrHigher(user: User | null): boolean {
return hasAnyRole(user, ['admin', 'superadmin']);
}
/**
* Helper function to check if user is editor or higher.
*/
export function isEditorOrHigher(user: User | null): boolean {
return hasAnyRole(user, ['editor', 'admin', 'superadmin']);
}

View File

@@ -1,12 +1,36 @@
// Funktion zum Laden der Clients von der API
export interface Client {
uuid: string;
location: string;
hardware_hash: string;
ip_address: string;
last_alive: string | null;
group_id: number; // <--- Dieses Feld ergänzen
hardware_token?: string;
ip?: string;
type?: string;
hostname?: string;
os_version?: string;
software_version?: string;
macs?: string;
model?: string;
description?: string;
registration_time?: string;
last_alive?: string;
is_active?: boolean;
group_id?: number;
// Für Health-Status
is_alive?: boolean;
}
export interface Group {
id: number;
name: string;
created_at?: string;
is_active?: boolean;
clients: Client[];
}
// Liefert alle Gruppen mit zugehörigen Clients
export async function fetchGroupsWithClients(): Promise<Group[]> {
const response = await fetch('/api/groups/with_clients');
if (!response.ok) {
throw new Error('Fehler beim Laden der Gruppen mit Clients');
}
return await response.json();
}
export async function fetchClients(): Promise<Client[]> {
@@ -17,12 +41,65 @@ export async function fetchClients(): Promise<Client[]> {
return await response.json();
}
export async function updateClientGroup(clientIds: string[], groupName: string) {
export async function fetchClientsWithoutDescription(): Promise<Client[]> {
const response = await fetch('/api/clients/without_description');
if (!response.ok) {
throw new Error('Fehler beim Laden der Clients ohne Beschreibung');
}
return await response.json();
}
export async function setClientDescription(uuid: string, description: string) {
const res = await fetch(`/api/clients/${uuid}/description`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description }),
});
if (!res.ok) throw new Error('Fehler beim Setzen der Beschreibung');
return await res.json();
}
export async function updateClientGroup(clientIds: string[], groupId: number) {
const res = await fetch('/api/clients/group', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_ids: clientIds, group_name: groupName }),
body: JSON.stringify({ client_ids: clientIds, group_id: groupId }),
});
if (!res.ok) throw new Error('Fehler beim Aktualisieren der Clients');
return await res.json();
}
export async function updateClient(uuid: string, data: { description?: string; model?: string }) {
const res = await fetch(`/api/clients/${uuid}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Fehler beim Aktualisieren des Clients');
return await res.json();
}
export async function restartClient(uuid: string): Promise<{ success: boolean; message?: string }> {
const response = await fetch(`/api/clients/${uuid}/restart`, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Fehler beim Neustart des Clients');
}
return await response.json();
}
export async function deleteClient(uuid: string) {
const res = await fetch(`/api/clients/${uuid}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Fehler beim Entfernen des Clients');
return await res.json();
}
export async function fetchMediaById(mediaId: number | string) {
const response = await fetch(`/api/eventmedia/${mediaId}`);
if (!response.ok) throw new Error('Fehler beim Laden der Mediainformationen');
return await response.json();
}

View File

@@ -0,0 +1,31 @@
export interface EventException {
id?: number;
event_id: number;
exception_date: string; // YYYY-MM-DD
is_skipped: boolean;
override_title?: string;
override_description?: string;
override_start?: string;
override_end?: string;
}
export async function createEventException(exception: Omit<EventException, 'id'>) {
const res = await fetch('/api/event_exceptions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(exception),
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Erstellen der Ausnahme');
return data;
}
export async function listEventExceptions(eventId?: number) {
const params = new URLSearchParams();
if (eventId) params.set('event_id', eventId.toString());
const res = await fetch(`/api/event_exceptions?${params.toString()}`);
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Ausnahmen');
return data as EventException[];
}

View File

@@ -8,18 +8,94 @@ export interface Event {
extendedProps: Record<string, unknown>;
}
export async function fetchEvents(groupId: string) {
const res = await fetch(`/api/events?group_id=${encodeURIComponent(groupId)}`);
export async function fetchEvents(
groupId: string,
showInactive = false,
options?: { start?: Date; end?: Date; expand?: boolean }
) {
const params = new URLSearchParams();
params.set('group_id', groupId);
params.set('show_inactive', showInactive ? '1' : '0');
if (options?.start) params.set('start', options.start.toISOString());
if (options?.end) params.set('end', options.end.toISOString());
if (options?.expand) params.set('expand', options.expand ? '1' : '0');
const res = await fetch(`/api/events?${params.toString()}`);
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Termine');
return data;
}
export async function deleteEvent(eventId: string) {
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}`, {
export async function fetchEventById(eventId: string) {
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}`);
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden des Termins');
return data;
}
export async function deleteEvent(eventId: string, force: boolean = false) {
const url = force
? `/api/events/${encodeURIComponent(eventId)}?force=1`
: `/api/events/${encodeURIComponent(eventId)}`;
const res = await fetch(url, {
method: 'DELETE',
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Löschen des Termins');
return data;
}
export async function deleteEventOccurrence(eventId: string, occurrenceDate: string) {
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}/occurrences/${encodeURIComponent(occurrenceDate)}`, {
method: 'DELETE',
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Löschen des Einzeltermins');
return data;
}
export async function updateEventOccurrence(eventId: string, occurrenceDate: string, payload: UpdateEventPayload) {
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}/occurrences/${encodeURIComponent(occurrenceDate)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Aktualisieren des Einzeltermins');
return data;
}
export interface UpdateEventPayload {
[key: string]: unknown;
}
export async function updateEvent(eventId: string, payload: UpdateEventPayload) {
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Aktualisieren des Termins');
return data;
}
export const detachEventOccurrence = async (masterId: number, occurrenceDate: string, eventData: object) => {
const url = `/api/events/${masterId}/occurrences/${occurrenceDate}/detach`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(eventData),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `HTTP error! status: ${response.status}`);
}
return data;
};

View File

@@ -0,0 +1,26 @@
export type Holiday = {
id: number;
name: string;
start_date: string;
end_date: string;
region?: string | null;
source_file_name?: string | null;
imported_at?: string | null;
};
export async function listHolidays(region?: string) {
const url = region ? `/api/holidays?region=${encodeURIComponent(region)}` : '/api/holidays';
const res = await fetch(url);
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Ferien');
return data as { holidays: Holiday[] };
}
export async function uploadHolidaysCsv(file: File) {
const form = new FormData();
form.append('file', file);
const res = await fetch('/api/holidays/upload', { method: 'POST', body: form });
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Import der Ferien');
return data as { success: boolean; inserted: number; updated: number };
}

View File

@@ -0,0 +1,168 @@
/**
* API client for system settings
*/
export interface SystemSetting {
key: string;
value: string | null;
description: string | null;
updated_at: string | null;
}
export interface SupplementTableSettings {
url: string;
enabled: boolean;
}
/**
* Get all system settings
*/
export async function getAllSettings(): Promise<{ settings: SystemSetting[] }> {
const response = await fetch(`/api/system-settings`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch settings: ${response.statusText}`);
}
return response.json();
}
/**
* Get a specific setting by key
*/
export async function getSetting(key: string): Promise<SystemSetting> {
const response = await fetch(`/api/system-settings/${key}`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch setting: ${response.statusText}`);
}
return response.json();
}
/**
* Update or create a setting
*/
export async function updateSetting(
key: string,
value: string,
description?: string
): Promise<SystemSetting> {
const response = await fetch(`/api/system-settings/${key}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ value, description }),
});
if (!response.ok) {
throw new Error(`Failed to update setting: ${response.statusText}`);
}
return response.json();
}
/**
* Delete a setting
*/
export async function deleteSetting(key: string): Promise<{ message: string }> {
const response = await fetch(`/api/system-settings/${key}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to delete setting: ${response.statusText}`);
}
return response.json();
}
/**
* Get supplement table settings
*/
export async function getSupplementTableSettings(): Promise<SupplementTableSettings> {
const response = await fetch(`/api/system-settings/supplement-table`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch supplement table settings: ${response.statusText}`);
}
return response.json();
}
/**
* Update supplement table settings
*/
export async function updateSupplementTableSettings(
url: string,
enabled: boolean
): Promise<SupplementTableSettings & { message: string }> {
const response = await fetch(`/api/system-settings/supplement-table`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ url, enabled }),
});
if (!response.ok) {
throw new Error(`Failed to update supplement table settings: ${response.statusText}`);
}
return response.json();
}
/**
* Get holiday banner setting
*/
export async function getHolidayBannerSetting(): Promise<{ enabled: boolean }> {
const response = await fetch(`/api/system-settings/holiday-banner`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch holiday banner setting: ${response.statusText}`);
}
return response.json();
}
/**
* Update holiday banner setting
*/
export async function updateHolidayBannerSetting(
enabled: boolean
): Promise<{ enabled: boolean; message: string }> {
const response = await fetch(`/api/system-settings/holiday-banner`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ enabled }),
});
if (!response.ok) {
throw new Error(`Failed to update holiday banner setting: ${response.statusText}`);
}
return response.json();
}
/**
* Get organization name (public endpoint)
*/
export async function getOrganizationName(): Promise<{ name: string }> {
const response = await fetch(`/api/system-settings/organization-name`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch organization name: ${response.statusText}`);
}
return response.json();
}
/**
* Update organization name (superadmin only)
*/
export async function updateOrganizationName(name: string): Promise<{ name: string; message: string }> {
const response = await fetch(`/api/system-settings/organization-name`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name }),
});
if (!response.ok) {
throw new Error(`Failed to update organization name: ${response.statusText}`);
}
return response.json();
}

161
dashboard/src/apiUsers.ts Normal file
View File

@@ -0,0 +1,161 @@
/**
* User management API client.
*
* Provides functions to manage users (CRUD operations).
* Access is role-based: admin can manage user/editor/admin, superadmin can manage all.
*/
export interface UserData {
id: number;
username: string;
role: 'user' | 'editor' | 'admin' | 'superadmin';
isActive: boolean;
lastLoginAt?: string;
lastPasswordChangeAt?: string;
lastFailedLoginAt?: string;
failedLoginAttempts?: number;
lockedUntil?: string;
deactivatedAt?: string;
createdAt?: string;
updatedAt?: string;
}
export interface CreateUserRequest {
username: string;
password: string;
role: 'user' | 'editor' | 'admin' | 'superadmin';
isActive?: boolean;
}
export interface UpdateUserRequest {
username?: string;
role?: 'user' | 'editor' | 'admin' | 'superadmin';
isActive?: boolean;
}
export interface ResetPasswordRequest {
password: string;
}
/**
* List all users (filtered by current user's role).
* Admin sees: user, editor, admin
* Superadmin sees: all including superadmin
*/
export async function listUsers(): Promise<UserData[]> {
const res = await fetch('/api/users', {
method: 'GET',
credentials: 'include',
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Failed to fetch users');
}
return res.json();
}
/**
* Get a single user by ID.
*/
export async function getUser(userId: number): Promise<UserData> {
const res = await fetch(`/api/users/${userId}`, {
method: 'GET',
credentials: 'include',
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Failed to fetch user');
}
return res.json();
}
/**
* Create a new user.
* Admin: can create user, editor, admin
* Superadmin: can create any role including superadmin
*/
export async function createUser(userData: CreateUserRequest): Promise<UserData & { message: string }> {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(userData),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to create user');
}
return data;
}
/**
* Update a user's details.
* Restrictions:
* - Cannot change own role
* - Cannot change own active status
* - Admin cannot edit superadmin users
*/
export async function updateUser(userId: number, userData: UpdateUserRequest): Promise<UserData & { message: string }> {
const res = await fetch(`/api/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(userData),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to update user');
}
return data;
}
/**
* Reset a user's password.
* Admin: cannot reset superadmin passwords
* Superadmin: can reset any password
*/
export async function resetUserPassword(userId: number, password: string): Promise<{ message: string }> {
const res = await fetch(`/api/users/${userId}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ password }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to reset password');
}
return data;
}
/**
* Permanently delete a user (superadmin only).
* Cannot delete own account.
*/
export async function deleteUser(userId: number): Promise<{ message: string }> {
const res = await fetch(`/api/users/${userId}`, {
method: 'DELETE',
credentials: 'include',
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to delete user');
}
return data;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +0,0 @@
import React from 'react';
const Benutzer: React.FC = () => (
<div>
<h2 className="text-xl font-bold mb-4">Benutzer</h2>
<p>Willkommen im Infoscreen-Management Benutzer.</p>
</div>
);
export default Benutzer;

View File

@@ -0,0 +1,569 @@
{
"main": {
"de": {
"identity": {
"language": "de"
},
"dates": {
"calendars": {
"gregorian": {
"months": {
"format": {
"abbreviated": {
"1": "Jan.",
"2": "Feb.",
"3": "März",
"4": "Apr.",
"5": "Mai",
"6": "Juni",
"7": "Juli",
"8": "Aug.",
"9": "Sept.",
"10": "Okt.",
"11": "Nov.",
"12": "Dez."
},
"narrow": {
"1": "J",
"2": "F",
"3": "M",
"4": "A",
"5": "M",
"6": "J",
"7": "J",
"8": "A",
"9": "S",
"10": "O",
"11": "N",
"12": "D"
},
"wide": {
"1": "Januar",
"2": "Februar",
"3": "März",
"4": "April",
"5": "Mai",
"6": "Juni",
"7": "Juli",
"8": "August",
"9": "September",
"10": "Oktober",
"11": "November",
"12": "Dezember"
}
},
"stand-alone": {
"abbreviated": {
"1": "Jan",
"2": "Feb",
"3": "Mär",
"4": "Apr",
"5": "Mai",
"6": "Jun",
"7": "Jul",
"8": "Aug",
"9": "Sep",
"10": "Okt",
"11": "Nov",
"12": "Dez"
},
"narrow": {
"1": "J",
"2": "F",
"3": "M",
"4": "A",
"5": "M",
"6": "J",
"7": "J",
"8": "A",
"9": "S",
"10": "O",
"11": "N",
"12": "D"
},
"wide": {
"1": "Januar",
"2": "Februar",
"3": "März",
"4": "April",
"5": "Mai",
"6": "Juni",
"7": "Juli",
"8": "August",
"9": "September",
"10": "Oktober",
"11": "November",
"12": "Dezember"
}
}
},
"days": {
"format": {
"abbreviated": {
"sun": "So.",
"mon": "Mo.",
"tue": "Di.",
"wed": "Mi.",
"thu": "Do.",
"fri": "Fr.",
"sat": "Sa."
},
"narrow": {
"sun": "S",
"mon": "M",
"tue": "D",
"wed": "M",
"thu": "D",
"fri": "F",
"sat": "S"
},
"short": {
"sun": "So.",
"mon": "Mo.",
"tue": "Di.",
"wed": "Mi.",
"thu": "Do.",
"fri": "Fr.",
"sat": "Sa."
},
"wide": {
"sun": "Sonntag",
"mon": "Montag",
"tue": "Dienstag",
"wed": "Mittwoch",
"thu": "Donnerstag",
"fri": "Freitag",
"sat": "Samstag"
}
},
"stand-alone": {
"abbreviated": {
"sun": "So",
"mon": "Mo",
"tue": "Di",
"wed": "Mi",
"thu": "Do",
"fri": "Fr",
"sat": "Sa"
},
"narrow": {
"sun": "S",
"mon": "M",
"tue": "D",
"wed": "M",
"thu": "D",
"fri": "F",
"sat": "S"
},
"short": {
"sun": "So.",
"mon": "Mo.",
"tue": "Di.",
"wed": "Mi.",
"thu": "Do.",
"fri": "Fr.",
"sat": "Sa."
},
"wide": {
"sun": "Sonntag",
"mon": "Montag",
"tue": "Dienstag",
"wed": "Mittwoch",
"thu": "Donnerstag",
"fri": "Freitag",
"sat": "Samstag"
}
}
},
"quarters": {
"format": {
"abbreviated": {
"1": "Q1",
"2": "Q2",
"3": "Q3",
"4": "Q4"
},
"narrow": {
"1": "1",
"2": "2",
"3": "3",
"4": "4"
},
"wide": {
"1": "1. Quartal",
"2": "2. Quartal",
"3": "3. Quartal",
"4": "4. Quartal"
}
},
"stand-alone": {
"abbreviated": {
"1": "Q1",
"2": "Q2",
"3": "Q3",
"4": "Q4"
},
"narrow": {
"1": "1",
"2": "2",
"3": "3",
"4": "4"
},
"wide": {
"1": "1. Quartal",
"2": "2. Quartal",
"3": "3. Quartal",
"4": "4. Quartal"
}
}
},
"dayPeriods": {
"format": {
"abbreviated": {
"midnight": "Mitternacht",
"am": "AM",
"pm": "PM",
"morning1": "morgens",
"morning2": "vorm.",
"afternoon1": "mittags",
"afternoon2": "nachm.",
"evening1": "abends",
"night1": "nachts"
},
"narrow": {
"midnight": "Mitternacht",
"am": "AM",
"pm": "PM",
"morning1": "morgens",
"morning2": "vorm.",
"afternoon1": "mittags",
"afternoon2": "nachm.",
"evening1": "abends",
"night1": "nachts"
},
"wide": {
"midnight": "Mitternacht",
"am": "AM",
"pm": "PM",
"morning1": "morgens",
"morning2": "vormittags",
"afternoon1": "mittags",
"afternoon2": "nachmittags",
"evening1": "abends",
"night1": "nachts"
}
},
"stand-alone": {
"abbreviated": {
"midnight": "Mitternacht",
"am": "AM",
"pm": "PM",
"morning1": "Morgen",
"morning2": "Vorm.",
"afternoon1": "Mittag",
"afternoon2": "Nachm.",
"evening1": "Abend",
"night1": "Nacht"
},
"narrow": {
"midnight": "Mitternacht",
"am": "AM",
"pm": "PM",
"morning1": "Morgen",
"morning2": "Vorm.",
"afternoon1": "Mittag",
"afternoon2": "Nachm.",
"evening1": "Abend",
"night1": "Nacht"
},
"wide": {
"midnight": "Mitternacht",
"am": "AM",
"pm": "PM",
"morning1": "Morgen",
"morning2": "Vormittag",
"afternoon1": "Mittag",
"afternoon2": "Nachmittag",
"evening1": "Abend",
"night1": "Nacht"
}
}
},
"eras": {
"eraNames": {
"0": "v. Chr.",
"0-alt-variant": "vor unserer Zeitrechnung",
"1": "n. Chr.",
"1-alt-variant": "unserer Zeitrechnung"
},
"eraAbbr": {
"0": "v. Chr.",
"0-alt-variant": "v. u. Z.",
"1": "n. Chr.",
"1-alt-variant": "u. Z."
},
"eraNarrow": {
"0": "v. Chr.",
"0-alt-variant": "v. u. Z.",
"1": "n. Chr.",
"1-alt-variant": "u. Z."
}
},
"dateFormats": {
"full": "EEEE, d. MMMM y",
"long": "d. MMMM y",
"medium": "dd.MM.y",
"short": "dd.MM.yy"
},
"dateSkeletons": {
"full": "yMMMMEEEEd",
"long": "yMMMMd",
"medium": "yMMdd",
"short": "yyMMdd"
},
"timeFormats": {
"full": "HH:mm:ss zzzz",
"long": "HH:mm:ss z",
"medium": "HH:mm:ss",
"short": "HH:mm"
},
"timeSkeletons": {
"full": "HHmmsszzzz",
"long": "HHmmssz",
"medium": "HHmmss",
"short": "HHmm"
},
"dateTimeFormats": {
"full": "{1}, {0}",
"long": "{1}, {0}",
"medium": "{1}, {0}",
"short": "{1}, {0}",
"availableFormats": {
"Bh": "h B",
"Bhm": "h:mm B",
"Bhms": "h:mm:ss B",
"d": "d",
"E": "ccc",
"EBhm": "E h:mm B",
"EBhms": "E h:mm:ss B",
"Ed": "E, d.",
"Ehm": "E h:mma",
"EHm": "E, HH:mm",
"Ehms": "E, h:mm:ssa",
"EHms": "E, HH:mm:ss",
"Gy": "y G",
"GyMd": "dd.MM.y G",
"GyMMM": "MMM y G",
"GyMMMd": "d. MMM y G",
"GyMMMEd": "E, d. MMM y G",
"h": "h 'Uhr' a",
"H": "HH 'Uhr'",
"hm": "h:mma",
"Hm": "HH:mm",
"hms": "h:mm:ssa",
"Hms": "HH:mm:ss",
"hmsv": "h:mm:ssa v",
"Hmsv": "HH:mm:ss v",
"hmv": "h:mma v",
"Hmv": "HH:mm v",
"M": "L",
"Md": "d.M.",
"MEd": "E, d.M.",
"MMd": "d.MM.",
"MMdd": "dd.MM.",
"MMM": "LLL",
"MMMd": "d. MMM",
"MMMEd": "E, d. MMM",
"MMMMd": "d. MMMM",
"MMMMEd": "E, d. MMMM",
"MMMMW-count-one": "'Woche' W 'im' MMMM",
"MMMMW-count-other": "'Woche' W 'im' MMMM",
"ms": "mm:ss",
"y": "y",
"yM": "M/y",
"yMd": "d.M.y",
"yMEd": "E, d.M.y",
"yMM": "MM.y",
"yMMdd": "dd.MM.y",
"yMMM": "MMM y",
"yMMMd": "d. MMM y",
"yMMMEd": "E, d. MMM y",
"yMMMM": "MMMM y",
"yQQQ": "QQQ y",
"yQQQQ": "QQQQ y",
"yw-count-one": "'Woche' w 'des' 'Jahres' Y",
"yw-count-other": "'Woche' w 'des' 'Jahres' Y"
},
"appendItems": {
"Day": "{0} ({2}: {1})",
"Day-Of-Week": "{0} {1}",
"Era": "{1} {0}",
"Hour": "{0} ({2}: {1})",
"Minute": "{0} ({2}: {1})",
"Month": "{0} ({2}: {1})",
"Quarter": "{0} ({2}: {1})",
"Second": "{0} ({2}: {1})",
"Timezone": "{0} {1}",
"Week": "{0} ({2}: {1})",
"Year": "{1} {0}"
},
"intervalFormats": {
"intervalFormatFallback": "{0}{1}",
"Bh": {
"B": "h 'Uhr' Bh 'Uhr' B",
"h": "hh 'Uhr' B"
},
"Bhm": {
"B": "h:mm 'Uhr' Bh:mm 'Uhr' B",
"h": "h:mmh:mm 'Uhr' B",
"m": "h:mmh:mm 'Uhr' B"
},
"d": {
"d": "d.d."
},
"Gy": {
"G": "y Gy G",
"y": "yy G"
},
"GyM": {
"G": "MM/y GMM/y G",
"M": "MM/yMM/y G",
"y": "MM/yMM/y G"
},
"GyMd": {
"d": "dd.dd.MM.y G",
"G": "dd.MM.y Gdd.MM.y G",
"M": "dd.MM.dd.MM.y G",
"y": "dd.MM.ydd.MM.y G"
},
"GyMEd": {
"d": "E, dd.MM.yE, dd.MM.y G",
"G": "E, dd.MM.y GE, dd.MM.y G",
"M": "E, dd.MM.E, dd.MM.y G",
"y": "E, dd.MM.yE, dd.MM.y G"
},
"GyMMM": {
"G": "MMM y GMMM y G",
"M": "MMMMMM y G",
"y": "MMM yMMM y G"
},
"GyMMMd": {
"d": "d.d. MMM y G",
"G": "d. MMM y Gd. MMM y G",
"M": "d. MMMd. MMM y G",
"y": "d. MMM yd. MMM y G"
},
"GyMMMEd": {
"d": "E, d.E, d. MMM y G",
"G": "E, d. MMM y GE E, d. MMM y G",
"M": "E, d. MMME, d. MMM y G",
"y": "E, d. MMM yE, d. MMM y G"
},
"h": {
"a": "h 'Uhr' ah 'Uhr' a",
"h": "hh 'Uhr' a"
},
"H": {
"H": "HHHH 'Uhr'"
},
"hm": {
"a": "h:mmah:mma",
"h": "h:mmh:mma",
"m": "h:mmh:mma"
},
"Hm": {
"H": "HH:mmHH:mm 'Uhr'",
"m": "HH:mmHH:mm 'Uhr'"
},
"hmv": {
"a": "h:mmah:mma v",
"h": "h:mmh:mma v",
"m": "h:mmh:mma v"
},
"Hmv": {
"H": "HH:mmHH:mm 'Uhr' v",
"m": "HH:mmHH:mm 'Uhr' v"
},
"hv": {
"a": "haha v",
"h": "hha v"
},
"Hv": {
"H": "HHHH 'Uhr' v"
},
"M": {
"M": "MMMM"
},
"Md": {
"d": "dd.dd.MM.",
"M": "dd.MM.dd.MM."
},
"MEd": {
"d": "E, dd.E, dd.MM.",
"M": "E, dd.MM.E, dd.MM."
},
"MMM": {
"M": "MMMMMM"
},
"MMMd": {
"d": "d.d. MMM",
"M": "d. MMMd. MMM"
},
"MMMEd": {
"d": "E, d.E, d. MMM",
"M": "E, d. MMME, d. MMM"
},
"MMMM": {
"M": "LLLLLLLL"
},
"y": {
"y": "yy"
},
"yM": {
"M": "M/yM/y",
"y": "M/yM/y"
},
"yMd": {
"d": "dd.dd.MM.y",
"M": "dd.MM.dd.MM.y",
"y": "dd.MM.ydd.MM.y"
},
"yMEd": {
"d": "E, dd.E, dd.MM.y",
"M": "E, dd.MM.E, dd.MM.y",
"y": "E, dd.MM.yE, dd.MM.y"
},
"yMMM": {
"M": "MMMMMM y",
"y": "MMM yMMM y"
},
"yMMMd": {
"d": "d.d. MMM y",
"M": "d. MMMd. MMM y",
"y": "d. MMM yd. MMM y"
},
"yMMMEd": {
"d": "E, d.E, d. MMM y",
"M": "E, d. MMME, d. MMM y",
"y": "E, d. MMM yE, d. MMM y"
},
"yMMMM": {
"M": "MMMMMMMM y",
"y": "MMMM yMMMM y"
}
}
},
"dateTimeFormats-atTime": {
"standard": {
"full": "{1} 'um' {0}",
"long": "{1} 'um' {0}",
"medium": "{1}, {0}",
"short": "{1}, {0}"
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,394 @@
{
"supplemental": {
"version": {
"_unicodeVersion": "16.0.0",
"_cldrVersion": "47"
},
"numberingSystems": {
"adlm": {
"_digits": "𞥐𞥑𞥒𞥓𞥔𞥕𞥖𞥗𞥘𞥙",
"_type": "numeric"
},
"ahom": {
"_digits": "𑜰𑜱𑜲𑜳𑜴𑜵𑜶𑜷𑜸𑜹",
"_type": "numeric"
},
"arab": {
"_digits": "٠١٢٣٤٥٦٧٨٩",
"_type": "numeric"
},
"arabext": {
"_digits": "۰۱۲۳۴۵۶۷۸۹",
"_type": "numeric"
},
"armn": {
"_rules": "armenian-upper",
"_type": "algorithmic"
},
"armnlow": {
"_rules": "armenian-lower",
"_type": "algorithmic"
},
"bali": {
"_digits": "᭐᭑᭒᭓᭔᭕᭖᭗᭘᭙",
"_type": "numeric"
},
"beng": {
"_digits": "০১২৩৪৫৬৭৮৯",
"_type": "numeric"
},
"bhks": {
"_digits": "𑱐𑱑𑱒𑱓𑱔𑱕𑱖𑱗𑱘𑱙",
"_type": "numeric"
},
"brah": {
"_digits": "𑁦𑁧𑁨𑁩𑁪𑁫𑁬𑁭𑁮𑁯",
"_type": "numeric"
},
"cakm": {
"_digits": "𑄶𑄷𑄸𑄹𑄺𑄻𑄼𑄽𑄾𑄿",
"_type": "numeric"
},
"cham": {
"_digits": "꩐꩑꩒꩓꩔꩕꩖꩗꩘꩙",
"_type": "numeric"
},
"cyrl": {
"_rules": "cyrillic-lower",
"_type": "algorithmic"
},
"deva": {
"_digits": "०१२३४५६७८९",
"_type": "numeric"
},
"diak": {
"_digits": "𑥐𑥑𑥒𑥓𑥔𑥕𑥖𑥗𑥘𑥙",
"_type": "numeric"
},
"ethi": {
"_rules": "ethiopic",
"_type": "algorithmic"
},
"fullwide": {
"_digits": "",
"_type": "numeric"
},
"gara": {
"_digits": "𐵀𐵁𐵂𐵃𐵄𐵅𐵆𐵇𐵈𐵉",
"_type": "numeric"
},
"geor": {
"_rules": "georgian",
"_type": "algorithmic"
},
"gong": {
"_digits": "𑶠𑶡𑶢𑶣𑶤𑶥𑶦𑶧𑶨𑶩",
"_type": "numeric"
},
"gonm": {
"_digits": "𑵐𑵑𑵒𑵓𑵔𑵕𑵖𑵗𑵘𑵙",
"_type": "numeric"
},
"grek": {
"_rules": "greek-upper",
"_type": "algorithmic"
},
"greklow": {
"_rules": "greek-lower",
"_type": "algorithmic"
},
"gujr": {
"_digits": "૦૧૨૩૪૫૬૭૮૯",
"_type": "numeric"
},
"gukh": {
"_digits": "𖄰𖄱𖄲𖄳𖄴𖄵𖄶𖄷𖄸𖄹",
"_type": "numeric"
},
"guru": {
"_digits": "੦੧੨੩੪੫੬੭੮੯",
"_type": "numeric"
},
"hanidays": {
"_rules": "zh/SpelloutRules/spellout-numbering-days",
"_type": "algorithmic"
},
"hanidec": {
"_digits": "〇一二三四五六七八九",
"_type": "numeric"
},
"hans": {
"_rules": "zh/SpelloutRules/spellout-cardinal",
"_type": "algorithmic"
},
"hansfin": {
"_rules": "zh/SpelloutRules/spellout-cardinal-financial",
"_type": "algorithmic"
},
"hant": {
"_rules": "zh_Hant/SpelloutRules/spellout-cardinal",
"_type": "algorithmic"
},
"hantfin": {
"_rules": "zh_Hant/SpelloutRules/spellout-cardinal-financial",
"_type": "algorithmic"
},
"hebr": {
"_rules": "hebrew",
"_type": "algorithmic"
},
"hmng": {
"_digits": "𖭐𖭑𖭒𖭓𖭔𖭕𖭖𖭗𖭘𖭙",
"_type": "numeric"
},
"hmnp": {
"_digits": "𞅀𞅁𞅂𞅃𞅄𞅅𞅆𞅇𞅈𞅉",
"_type": "numeric"
},
"java": {
"_digits": "꧐꧑꧒꧓꧔꧕꧖꧗꧘꧙",
"_type": "numeric"
},
"jpan": {
"_rules": "ja/SpelloutRules/spellout-cardinal",
"_type": "algorithmic"
},
"jpanfin": {
"_rules": "ja/SpelloutRules/spellout-cardinal-financial",
"_type": "algorithmic"
},
"jpanyear": {
"_rules": "ja/SpelloutRules/spellout-numbering-year-latn",
"_type": "algorithmic"
},
"kali": {
"_digits": "꤀꤁꤂꤃꤄꤅꤆꤇꤈꤉",
"_type": "numeric"
},
"kawi": {
"_digits": "𑽐𑽑𑽒𑽓𑽔𑽕𑽖𑽗𑽘𑽙",
"_type": "numeric"
},
"khmr": {
"_digits": "០១២៣៤៥៦៧៨៩",
"_type": "numeric"
},
"knda": {
"_digits": "೦೧೨೩೪೫೬೭೮೯",
"_type": "numeric"
},
"krai": {
"_digits": "𖵰𖵱𖵲𖵳𖵴𖵵𖵶𖵷𖵸𖵹",
"_type": "numeric"
},
"lana": {
"_digits": "᪀᪁᪂᪃᪄᪅᪆᪇᪈᪉",
"_type": "numeric"
},
"lanatham": {
"_digits": "᪐᪑᪒᪓᪔᪕᪖᪗᪘᪙",
"_type": "numeric"
},
"laoo": {
"_digits": "໐໑໒໓໔໕໖໗໘໙",
"_type": "numeric"
},
"latn": {
"_digits": "0123456789",
"_type": "numeric"
},
"lepc": {
"_digits": "᱀᱁᱂᱃᱄᱅᱆᱇᱈᱉",
"_type": "numeric"
},
"limb": {
"_digits": "᥆᥇᥈᥉᥊᥋᥌᥍᥎᥏",
"_type": "numeric"
},
"mathbold": {
"_digits": "𝟎𝟏𝟐𝟑𝟒𝟓𝟔𝟕𝟖𝟗",
"_type": "numeric"
},
"mathdbl": {
"_digits": "𝟘𝟙𝟚𝟛𝟜𝟝𝟞𝟟𝟠𝟡",
"_type": "numeric"
},
"mathmono": {
"_digits": "𝟶𝟷𝟸𝟹𝟺𝟻𝟼𝟽𝟾𝟿",
"_type": "numeric"
},
"mathsanb": {
"_digits": "𝟬𝟭𝟮𝟯𝟰𝟱𝟲𝟳𝟴𝟵",
"_type": "numeric"
},
"mathsans": {
"_digits": "𝟢𝟣𝟤𝟥𝟦𝟧𝟨𝟩𝟪𝟫",
"_type": "numeric"
},
"mlym": {
"_digits": "൦൧൨൩൪൫൬൭൮൯",
"_type": "numeric"
},
"modi": {
"_digits": "𑙐𑙑𑙒𑙓𑙔𑙕𑙖𑙗𑙘𑙙",
"_type": "numeric"
},
"mong": {
"_digits": "᠐᠑᠒᠓᠔᠕᠖᠗᠘᠙",
"_type": "numeric"
},
"mroo": {
"_digits": "𖩠𖩡𖩢𖩣𖩤𖩥𖩦𖩧𖩨𖩩",
"_type": "numeric"
},
"mtei": {
"_digits": "꯰꯱꯲꯳꯴꯵꯶꯷꯸꯹",
"_type": "numeric"
},
"mymr": {
"_digits": "၀၁၂၃၄၅၆၇၈၉",
"_type": "numeric"
},
"mymrepka": {
"_digits": "𑛚𑛛𑛜𑛝𑛞𑛟𑛠𑛡𑛢𑛣",
"_type": "numeric"
},
"mymrpao": {
"_digits": "𑛐𑛑𑛒𑛓𑛔𑛕𑛖𑛗𑛘𑛙",
"_type": "numeric"
},
"mymrshan": {
"_digits": "႐႑႒႓႔႕႖႗႘႙",
"_type": "numeric"
},
"mymrtlng": {
"_digits": "꧰꧱꧲꧳꧴꧵꧶꧷꧸꧹",
"_type": "numeric"
},
"nagm": {
"_digits": "𞓰𞓱𞓲𞓳𞓴𞓵𞓶𞓷𞓸𞓹",
"_type": "numeric"
},
"newa": {
"_digits": "𑑐𑑑𑑒𑑓𑑔𑑕𑑖𑑗𑑘𑑙",
"_type": "numeric"
},
"nkoo": {
"_digits": "߀߁߂߃߄߅߆߇߈߉",
"_type": "numeric"
},
"olck": {
"_digits": "᱐᱑᱒᱓᱔᱕᱖᱗᱘᱙",
"_type": "numeric"
},
"onao": {
"_digits": "𞗱𞗲𞗳𞗴𞗵𞗶𞗷𞗸𞗹𞗺",
"_type": "numeric"
},
"orya": {
"_digits": "୦୧୨୩୪୫୬୭୮୯",
"_type": "numeric"
},
"osma": {
"_digits": "𐒠𐒡𐒢𐒣𐒤𐒥𐒦𐒧𐒨𐒩",
"_type": "numeric"
},
"outlined": {
"_digits": "𜳰𜳱𜳲𜳳𜳴𜳵𜳶𜳷𜳸𜳹",
"_type": "numeric"
},
"rohg": {
"_digits": "𐴰𐴱𐴲𐴳𐴴𐴵𐴶𐴷𐴸𐴹",
"_type": "numeric"
},
"roman": {
"_rules": "roman-upper",
"_type": "algorithmic"
},
"romanlow": {
"_rules": "roman-lower",
"_type": "algorithmic"
},
"saur": {
"_digits": "꣐꣑꣒꣓꣔꣕꣖꣗꣘꣙",
"_type": "numeric"
},
"segment": {
"_digits": "🯰🯱🯲🯳🯴🯵🯶🯷🯸🯹",
"_type": "numeric"
},
"shrd": {
"_digits": "𑇐𑇑𑇒𑇓𑇔𑇕𑇖𑇗𑇘𑇙",
"_type": "numeric"
},
"sind": {
"_digits": "𑋰𑋱𑋲𑋳𑋴𑋵𑋶𑋷𑋸𑋹",
"_type": "numeric"
},
"sinh": {
"_digits": "෦෧෨෩෪෫෬෭෮෯",
"_type": "numeric"
},
"sora": {
"_digits": "𑃰𑃱𑃲𑃳𑃴𑃵𑃶𑃷𑃸𑃹",
"_type": "numeric"
},
"sund": {
"_digits": "᮰᮱᮲᮳᮴᮵᮶᮷᮸᮹",
"_type": "numeric"
},
"sunu": {
"_digits": "𑯰𑯱𑯲𑯳𑯴𑯵𑯶𑯷𑯸𑯹",
"_type": "numeric"
},
"takr": {
"_digits": "𑛀𑛁𑛂𑛃𑛄𑛅𑛆𑛇𑛈𑛉",
"_type": "numeric"
},
"talu": {
"_digits": "᧐᧑᧒᧓᧔᧕᧖᧗᧘᧙",
"_type": "numeric"
},
"taml": {
"_rules": "tamil",
"_type": "algorithmic"
},
"tamldec": {
"_digits": "௦௧௨௩௪௫௬௭௮௯",
"_type": "numeric"
},
"telu": {
"_digits": "౦౧౨౩౪౫౬౭౮౯",
"_type": "numeric"
},
"thai": {
"_digits": "๐๑๒๓๔๕๖๗๘๙",
"_type": "numeric"
},
"tibt": {
"_digits": "༠༡༢༣༤༥༦༧༨༩",
"_type": "numeric"
},
"tirh": {
"_digits": "𑓐𑓑𑓒𑓓𑓔𑓕𑓖𑓗𑓘𑓙",
"_type": "numeric"
},
"tnsa": {
"_digits": "𖫀𖫁𖫂𖫃𖫄𖫅𖫆𖫇𖫈𖫉",
"_type": "numeric"
},
"vaii": {
"_digits": "꘠꘡꘢꘣꘤꘥꘦꘧꘨꘩",
"_type": "numeric"
},
"wara": {
"_digits": "𑣠𑣡𑣢𑣣𑣤𑣥𑣦𑣧𑣨𑣩",
"_type": "numeric"
},
"wcho": {
"_digits": "𞋰𞋱𞋲𞋳𞋴𞋵𞋶𞋷𞋸𞋹",
"_type": "numeric"
}
}
}
}

View File

@@ -0,0 +1,164 @@
{
"main": {
"de": {
"identity": {
"language": "de"
},
"numbers": {
"defaultNumberingSystem": "latn",
"otherNumberingSystems": {
"native": "latn"
},
"minimumGroupingDigits": "1",
"symbols-numberSystem-latn": {
"decimal": ",",
"group": ".",
"list": ";",
"percentSign": "%",
"plusSign": "+",
"minusSign": "-",
"approximatelySign": "≈",
"exponential": "E",
"superscriptingExponent": "·",
"perMille": "‰",
"infinity": "∞",
"nan": "NaN",
"timeSeparator": ":"
},
"decimalFormats-numberSystem-latn": {
"standard": "#,##0.###",
"long": {
"decimalFormat": {
"1000-count-one": "0 Tausend",
"1000-count-other": "0 Tausend",
"10000-count-one": "00 Tausend",
"10000-count-other": "00 Tausend",
"100000-count-one": "000 Tausend",
"100000-count-other": "000 Tausend",
"1000000-count-one": "0 Million",
"1000000-count-other": "0 Millionen",
"10000000-count-one": "00 Millionen",
"10000000-count-other": "00 Millionen",
"100000000-count-one": "000 Millionen",
"100000000-count-other": "000 Millionen",
"1000000000-count-one": "0 Milliarde",
"1000000000-count-other": "0 Milliarden",
"10000000000-count-one": "00 Milliarden",
"10000000000-count-other": "00 Milliarden",
"100000000000-count-one": "000 Milliarden",
"100000000000-count-other": "000 Milliarden",
"1000000000000-count-one": "0 Billion",
"1000000000000-count-other": "0 Billionen",
"10000000000000-count-one": "00 Billionen",
"10000000000000-count-other": "00 Billionen",
"100000000000000-count-one": "000 Billionen",
"100000000000000-count-other": "000 Billionen"
}
},
"short": {
"decimalFormat": {
"1000-count-one": "0",
"1000-count-other": "0",
"10000-count-one": "0",
"10000-count-other": "0",
"100000-count-one": "0",
"100000-count-other": "0",
"1000000-count-one": "0 Mio'.'",
"1000000-count-other": "0 Mio'.'",
"10000000-count-one": "00 Mio'.'",
"10000000-count-other": "00 Mio'.'",
"100000000-count-one": "000 Mio'.'",
"100000000-count-other": "000 Mio'.'",
"1000000000-count-one": "0 Mrd'.'",
"1000000000-count-other": "0 Mrd'.'",
"10000000000-count-one": "00 Mrd'.'",
"10000000000-count-other": "00 Mrd'.'",
"100000000000-count-one": "000 Mrd'.'",
"100000000000-count-other": "000 Mrd'.'",
"1000000000000-count-one": "0 Bio'.'",
"1000000000000-count-other": "0 Bio'.'",
"10000000000000-count-one": "00 Bio'.'",
"10000000000000-count-other": "00 Bio'.'",
"100000000000000-count-one": "000 Bio'.'",
"100000000000000-count-other": "000 Bio'.'"
}
}
},
"scientificFormats-numberSystem-latn": {
"standard": "#E0"
},
"percentFormats-numberSystem-latn": {
"standard": "#,##0 %"
},
"currencyFormats-numberSystem-latn": {
"currencySpacing": {
"beforeCurrency": {
"currencyMatch": "[[:^S:]&[:^Z:]]",
"surroundingMatch": "[:digit:]",
"insertBetween": " "
},
"afterCurrency": {
"currencyMatch": "[[:^S:]&[:^Z:]]",
"surroundingMatch": "[:digit:]",
"insertBetween": " "
}
},
"standard": "#,##0.00 ¤",
"standard-alphaNextToNumber": "¤ #,##0.00",
"standard-noCurrency": "#,##0.00",
"accounting": "#,##0.00 ¤",
"accounting-alphaNextToNumber": "¤ #,##0.00",
"accounting-noCurrency": "#,##0.00",
"short": {
"standard": {
"1000-count-one": "0",
"1000-count-other": "0",
"10000-count-one": "0",
"10000-count-other": "0",
"100000-count-one": "0",
"100000-count-other": "0",
"1000000-count-one": "0 Mio'.' ¤",
"1000000-count-other": "0 Mio'.' ¤",
"10000000-count-one": "00 Mio'.' ¤",
"10000000-count-other": "00 Mio'.' ¤",
"100000000-count-one": "000 Mio'.' ¤",
"100000000-count-other": "000 Mio'.' ¤",
"1000000000-count-one": "0 Mrd'.' ¤",
"1000000000-count-other": "0 Mrd'.' ¤",
"10000000000-count-one": "00 Mrd'.' ¤",
"10000000000-count-other": "00 Mrd'.' ¤",
"100000000000-count-one": "000 Mrd'.' ¤",
"100000000000-count-other": "000 Mrd'.' ¤",
"1000000000000-count-one": "0 Bio'.' ¤",
"1000000000000-count-other": "0 Bio'.' ¤",
"10000000000000-count-one": "00 Bio'.' ¤",
"10000000000000-count-other": "00 Bio'.' ¤",
"100000000000000-count-one": "000 Bio'.' ¤",
"100000000000000-count-other": "000 Bio'.' ¤"
}
},
"currencyPatternAppendISO": "{0} ¤¤",
"unitPattern-count-other": "{0} {1}"
},
"miscPatterns-numberSystem-latn": {
"approximately": "≈{0}",
"atLeast": "{0}+",
"atMost": "≤{0}",
"range": "{0}{1}"
},
"minimalPairs": {
"pluralMinimalPairs-count-one": "{0} Tag",
"pluralMinimalPairs-count-other": "{0} Tage",
"other": "{0}. Abzweigung nach rechts nehmen",
"accusative": "… für {0} …",
"dative": "… mit {0} …",
"genitive": "Anstatt {0} …",
"nominative": "{0} kostet (kosten) € 3,50.",
"feminine": "Die {0} ist …",
"masculine": "Der {0} ist …",
"neuter": "Das {0} ist …"
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,266 @@
import React from 'react';
const Infoscreens: React.FC = () => (
<div>
<h2 className="text-xl font-bold mb-4">Infoscreens</h2>
<p>Willkommen im Infoscreen-Management Infoscreens.</p>
</div>
);
export default Infoscreens;
import SetupModeButton from './components/SetupModeButton';
import React, { useEffect, useState } from 'react';
import { useClientDelete } from './hooks/useClientDelete';
import { fetchClients, updateClient } from './apiClients';
import type { Client } from './apiClients';
import {
GridComponent,
ColumnsDirective,
ColumnDirective,
Page,
Inject,
Toolbar,
Search,
Sort,
Edit,
} from '@syncfusion/ej2-react-grids';
import { DialogComponent } from '@syncfusion/ej2-react-popups';
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
// Raumgruppen werden dynamisch aus der API geladen
// Details dialog renders via Syncfusion Dialog for consistent look & feel
function DetailsContent({ client, groupIdToName }: { client: Client; groupIdToName: Record<string | number, string> }) {
return (
<div className="e-card-content">
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
{Object.entries(client)
.filter(
([key]) =>
![
'index',
'is_active',
'type',
'column',
'group_name',
'foreignKeyData',
'hardware_token',
].includes(key)
)
.map(([key, value]) => (
<tr key={key}>
<td style={{ fontWeight: 600, padding: '6px 8px', width: '40%' }}>
{key === 'group_id'
? 'Raumgruppe'
: key === 'ip'
? 'IP-Adresse'
: key === 'registration_time'
? 'Registriert am'
: key === 'description'
? 'Beschreibung'
: key === 'last_alive'
? 'Letzter Kontakt'
: key === 'model'
? 'Modell'
: key === 'uuid'
? 'Client-Code'
: key === 'os_version'
? 'Betriebssystem'
: key === 'software_version'
? 'Clientsoftware'
: key === 'macs'
? 'MAC-Adressen'
: key.charAt(0).toUpperCase() + key.slice(1)}
</td>
<td style={{ padding: '6px 8px' }}>
{key === 'group_id'
? value !== undefined
? groupIdToName[value as string | number] || String(value)
: ''
: key === 'registration_time' && value
? new Date(
(value as string).endsWith('Z') ? (value as string) : String(value) + 'Z'
).toLocaleString()
: key === 'last_alive' && value
? String(value)
: key === 'macs' && typeof value === 'string'
? value.replace(/,\s*/g, ', ')
: String(value)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
const Clients: React.FC = () => {
const [clients, setClients] = useState<Client[]>([]);
const [groups, setGroups] = useState<{ id: number; name: string }[]>([]);
const [detailsClient, setDetailsClient] = useState<Client | null>(null);
const { showDialog, deleteClientId, handleDelete, confirmDelete, cancelDelete } = useClientDelete(
uuid => setClients(prev => prev.filter(c => c.uuid !== uuid))
);
useEffect(() => {
fetchClients().then(setClients);
// Gruppen auslesen
import('./apiGroups').then(mod => mod.fetchGroups()).then(setGroups);
}, []);
// Map group_id zu group_name
const groupIdToName: Record<string | number, string> = {};
groups.forEach(g => {
groupIdToName[g.id] = g.name;
});
// DataGrid data mit korrektem Gruppennamen und formatierten Zeitangaben
const gridData = clients.map(c => ({
...c,
group_name: c.group_id !== undefined ? groupIdToName[c.group_id] || String(c.group_id) : '',
last_alive: c.last_alive
? new Date(
(c.last_alive as string).endsWith('Z') ? (c.last_alive as string) : c.last_alive + 'Z'
).toLocaleString()
: '',
}));
// DataGrid row template für Details- und Entfernen-Button
const detailsButtonTemplate = (props: Client) => (
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<ButtonComponent cssClass="e-primary" onClick={() => setDetailsClient(props)}>
Details
</ButtonComponent>
<ButtonComponent
cssClass="e-danger"
onClick={e => {
e.preventDefault();
e.stopPropagation();
handleDelete(props.uuid);
}}
>
Entfernen
</ButtonComponent>
</div>
);
return (
<div id="dialog-target">
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
gap: 12,
flexWrap: 'wrap',
}}
>
<h2 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 700 }}>
Client-Übersicht
</h2>
<SetupModeButton />
</div>
{groups.length > 0 ? (
<>
<GridComponent
dataSource={gridData}
allowPaging={true}
pageSettings={{ pageSize: 10 }}
toolbar={['Search', 'Edit', 'Update', 'Cancel']}
allowSorting={true}
allowFiltering={true}
height={420}
editSettings={{
allowEditing: true,
allowAdding: false,
allowDeleting: false,
mode: 'Normal',
}}
actionComplete={async (args: {
requestType: string;
data: Record<string, unknown>;
}) => {
if (args.requestType === 'save') {
const { uuid, description, model } = args.data as {
uuid: string;
description: string;
model: string;
};
// API-Aufruf zum Speichern
await updateClient(uuid, { description, model });
// Nach dem Speichern neu laden
fetchClients().then(setClients);
}
}}
>
<ColumnsDirective>
<ColumnDirective
field="description"
headerText="Beschreibung"
allowEditing={true}
width="180"
/>
<ColumnDirective
field="group_name"
headerText="Raumgruppe"
allowEditing={false}
width="140"
/>
<ColumnDirective field="uuid" headerText="UUID" allowEditing={false} width="160" />
<ColumnDirective field="ip" headerText="IP-Adresse" allowEditing={false} width="100" />
<ColumnDirective
field="last_alive"
headerText="Last Alive"
allowEditing={false}
width="150"
/>
<ColumnDirective field="model" headerText="Model" allowEditing={true} width="140" />
<ColumnDirective
headerText="Aktion"
width="210"
template={detailsButtonTemplate}
textAlign="Center"
allowEditing={false}
/>
</ColumnsDirective>
<Inject services={[Page, Toolbar, Search, Sort, Edit]} />
</GridComponent>
</>
) : (
<span style={{ color: '#6b7280' }}>Raumgruppen werden geladen ...</span>
)}
{/* Details-Dialog */}
{detailsClient && (
<DialogComponent
visible={!!detailsClient}
header="Client-Details"
showCloseIcon={true}
target="#dialog-target"
width="560px"
close={() => setDetailsClient(null)}
footerTemplate={() => (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<ButtonComponent onClick={() => setDetailsClient(null)}>{'Schließen'}</ButtonComponent>
</div>
)}
>
<DetailsContent client={detailsClient} groupIdToName={groupIdToName} />
</DialogComponent>
)}
{/* Bestätigungs-Dialog für Löschen */}
{showDialog && deleteClientId && (
<DialogComponent
visible={showDialog}
header="Bestätigung"
content="Möchten Sie diesen Client wirklich entfernen?"
showCloseIcon={true}
width="400px"
target="#dialog-target"
buttons={[
{ click: confirmDelete, buttonModel: { content: 'Ja', isPrimary: true } },
{ click: cancelDelete, buttonModel: { content: 'Abbrechen' } },
]}
close={cancelDelete}
/>
)}
</div>
);
};
export default Clients;

View File

@@ -5,6 +5,8 @@ import { DatePickerComponent, TimePickerComponent } from '@syncfusion/ej2-react-
import { DropDownListComponent, MultiSelectComponent } from '@syncfusion/ej2-react-dropdowns';
import { CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
import CustomSelectUploadEventModal from './CustomSelectUploadEventModal';
import { updateEvent, detachEventOccurrence } from '../apiEvents';
// Holiday exceptions are now created in the backend
type CustomEventData = {
title: string;
@@ -17,14 +19,33 @@ type CustomEventData = {
weekdays: number[];
repeatUntil: Date | null;
skipHolidays: boolean;
media?: { id: string; path: string; name: string } | null;
slideshowInterval?: number;
pageProgress?: boolean;
autoProgress?: boolean;
websiteUrl?: string;
// Video-specific fields
autoplay?: boolean;
loop?: boolean;
volume?: number;
muted?: boolean;
};
// Typ für initialData erweitern, damit Id unterstützt wird
type CustomEventModalProps = {
open: boolean;
onClose: () => void;
onSave: (eventData: any) => void;
initialData?: any;
groupName: string | { id: string | null; name: string }; // <- angepasst
onSave: (eventData: CustomEventData) => void;
initialData?: Partial<CustomEventData> & {
Id?: string;
OccurrenceOfId?: string;
isSingleOccurrence?: boolean;
occurrenceDate?: Date;
};
groupName: string | { id: string | null; name: string };
groupColor?: string;
editMode?: boolean;
// Removed unused blockHolidays and isHolidayRange
};
const weekdayOptions = [
@@ -50,7 +71,9 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
onClose,
onSave,
initialData = {},
groupName, // <--- NEU
groupName,
groupColor,
editMode,
}) => {
const [title, setTitle] = React.useState(initialData.title || '');
const [startDate, setStartDate] = React.useState(initialData.startDate || null);
@@ -60,38 +83,125 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
const [endTime, setEndTime] = React.useState(initialData.endTime || new Date(0, 0, 0, 9, 30));
const [type, setType] = React.useState(initialData.type ?? 'presentation');
const [description, setDescription] = React.useState(initialData.description || '');
const [repeat, setRepeat] = React.useState(initialData.repeat || false);
const [weekdays, setWeekdays] = React.useState<number[]>(initialData.weekdays || []);
const [repeatUntil, setRepeatUntil] = React.useState(initialData.repeatUntil || null);
const [skipHolidays, setSkipHolidays] = React.useState(initialData.skipHolidays || false);
// Initialize recurrence state - force to false/empty for single occurrence editing
const isSingleOccurrence = initialData.isSingleOccurrence || false;
const [repeat, setRepeat] = React.useState(isSingleOccurrence ? false : (initialData.repeat || false));
const [weekdays, setWeekdays] = React.useState<number[]>(isSingleOccurrence ? [] : (initialData.weekdays || []));
const [repeatUntil, setRepeatUntil] = React.useState(isSingleOccurrence ? null : (initialData.repeatUntil || null));
// Default to true so recurrences skip holidays by default, but false for single occurrences
const [skipHolidays, setSkipHolidays] = React.useState(
isSingleOccurrence ? false : (initialData.skipHolidays !== undefined ? initialData.skipHolidays : true)
);
const [errors, setErrors] = React.useState<{ [key: string]: string }>({});
const [media, setMedia] = React.useState<{ id: string; path: string; name: string } | null>(null);
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
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);
const [slideshowInterval, setSlideshowInterval] = React.useState<number>(10);
const [websiteUrl, setWebsiteUrl] = React.useState<string>('');
// General settings state for presentation
// Removed unused generalLoaded and setGeneralLoaded
// Removed unused generalLoaded/generalSlideshowInterval/generalPageProgress/generalAutoProgress
// Per-event state
const [slideshowInterval, setSlideshowInterval] = React.useState<number>(
initialData.slideshowInterval ?? 10
);
const [pageProgress, setPageProgress] = React.useState<boolean>(
initialData.pageProgress ?? true
);
const [autoProgress, setAutoProgress] = React.useState<boolean>(
initialData.autoProgress ?? true
);
const [websiteUrl, setWebsiteUrl] = React.useState<string>(initialData.websiteUrl ?? '');
// Video-specific state with system defaults loading
const [autoplay, setAutoplay] = React.useState<boolean>(initialData.autoplay ?? true);
const [loop, setLoop] = React.useState<boolean>(initialData.loop ?? true);
const [volume, setVolume] = React.useState<number>(initialData.volume ?? 0.8);
const [muted, setMuted] = React.useState<boolean>(initialData.muted ?? false);
const [videoDefaultsLoaded, setVideoDefaultsLoaded] = React.useState<boolean>(false);
const [mediaModalOpen, setMediaModalOpen] = React.useState(false);
// Load system video defaults once when opening for a new video event
React.useEffect(() => {
if (open && !editMode && !videoDefaultsLoaded) {
(async () => {
try {
const api = await import('../apiSystemSettings');
const keys = ['video_autoplay', 'video_loop', 'video_volume', 'video_muted'] as const;
const [autoplayRes, loopRes, volumeRes, mutedRes] = await Promise.all(
keys.map(k => api.getSetting(k).catch(() => ({ value: null } as { value: string | null })))
);
// Only apply defaults if not already set from initialData
if (initialData.autoplay === undefined) {
setAutoplay(autoplayRes.value == null ? true : autoplayRes.value === 'true');
}
if (initialData.loop === undefined) {
setLoop(loopRes.value == null ? true : loopRes.value === 'true');
}
if (initialData.volume === undefined) {
const volParsed = volumeRes.value == null ? 0.8 : parseFloat(String(volumeRes.value));
setVolume(Number.isFinite(volParsed) ? volParsed : 0.8);
}
if (initialData.muted === undefined) {
setMuted(mutedRes.value == null ? false : mutedRes.value === 'true');
}
setVideoDefaultsLoaded(true);
} catch {
// Silently fall back to hard-coded defaults
setVideoDefaultsLoaded(true);
}
})();
}
}, [open, editMode, videoDefaultsLoaded, initialData]);
React.useEffect(() => {
if (open) {
const isSingleOccurrence = initialData.isSingleOccurrence || false;
setTitle(initialData.title || '');
setStartDate(initialData.startDate || null);
setStartTime(initialData.startTime || new Date(0, 0, 0, 9, 0));
setEndTime(initialData.endTime || new Date(0, 0, 0, 9, 30));
setType(initialData.type ?? 'presentation'); // Immer 'presentation' als Default
setType(initialData.type ?? 'presentation');
setDescription(initialData.description || '');
setRepeat(initialData.repeat || false);
setWeekdays(initialData.weekdays || []);
setRepeatUntil(initialData.repeatUntil || null);
setSkipHolidays(initialData.skipHolidays || false);
setMedia(null);
setSlideshowInterval(10);
setWebsiteUrl('');
// For single occurrence editing, force recurrence settings to be disabled
if (isSingleOccurrence) {
setRepeat(false);
setWeekdays([]);
setRepeatUntil(null);
setSkipHolidays(false);
} else {
setRepeat(initialData.repeat || false);
setWeekdays(initialData.weekdays || []);
setRepeatUntil(initialData.repeatUntil || null);
setSkipHolidays(initialData.skipHolidays !== undefined ? initialData.skipHolidays : true);
}
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
setMedia(initialData.media ?? null);
setSlideshowInterval(initialData.slideshowInterval ?? 10);
setPageProgress(initialData.pageProgress ?? true);
setAutoProgress(initialData.autoProgress ?? true);
setWebsiteUrl(initialData.websiteUrl ?? '');
// Video fields - use initialData values when editing
if (editMode) {
setAutoplay(initialData.autoplay ?? true);
setLoop(initialData.loop ?? true);
setVolume(initialData.volume ?? 0.8);
setMuted(initialData.muted ?? false);
}
}
}, [open, initialData]);
}, [open, initialData, editMode]);
React.useEffect(() => {
if (!mediaModalOpen && pendingMedia) {
@@ -108,6 +218,30 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
if (!endTime) newErrors.endTime = 'Endzeit ist erforderlich';
if (!type) newErrors.type = 'Termintyp ist erforderlich';
// Vergangenheitsprüfung - für Einzeltermine strikt, für Serien flexibler
const startDateTime =
startDate && startTime
? new Date(
startDate.getFullYear(),
startDate.getMonth(),
startDate.getDate(),
startTime.getHours(),
startTime.getMinutes()
)
: null;
const isPast = startDateTime && startDateTime < new Date();
if (isPast) {
if (isSingleOccurrence || !repeat) {
// Einzeltermine oder nicht-wiederkehrende Events dürfen nicht in der Vergangenheit liegen
newErrors.startDate = 'Termine in der Vergangenheit sind nicht erlaubt!';
} else if (repeat && repeatUntil && repeatUntil < new Date()) {
// Wiederkehrende Events sind erlaubt, wenn das End-Datum in der Zukunft liegt
newErrors.repeatUntil = 'Terminserien mit End-Datum in der Vergangenheit sind nicht erlaubt!';
}
// Andernfalls: Wiederkehrende Serie ohne End-Datum oder mit End-Datum in der Zukunft ist erlaubt
}
if (type === 'presentation') {
if (!media) newErrors.media = 'Bitte eine Präsentation auswählen';
if (!slideshowInterval || slideshowInterval < 1)
@@ -116,19 +250,40 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
if (type === 'website') {
if (!websiteUrl.trim()) newErrors.websiteUrl = 'Webseiten-URL ist erforderlich';
}
if (type === 'video') {
if (!media) newErrors.media = 'Bitte ein Video auswählen';
}
// Holiday blocking logic removed (blockHolidays, isHolidayRange no longer used)
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setErrors({});
// group_id ist jetzt wirklich die ID (z.B. als prop übergeben)
const group_id = typeof groupName === 'object' && groupName !== null ? groupName.id : groupName;
// Daten für das Backend zusammenstellen
const payload: any = {
// Build recurrence rule if repeat is enabled
let recurrenceRule = null;
let recurrenceEnd = null;
if (repeat && weekdays.length > 0) {
// Convert weekdays to RRULE format (0=Monday -> MO)
const rruleDays = weekdays.map(day => {
const dayNames = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'];
return dayNames[day];
}).join(',');
recurrenceRule = `FREQ=WEEKLY;BYDAY=${rruleDays}`;
if (repeatUntil) {
const untilDate = new Date(repeatUntil);
untilDate.setHours(23, 59, 59);
recurrenceEnd = untilDate.toISOString();
// Note: RRULE UNTIL should be in UTC format for consistency
const untilUTC = untilDate.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
recurrenceRule += `;UNTIL=${untilUTC}`;
}
}
const payload: CustomEventData & { [key: string]: unknown } = {
group_id,
title,
description,
@@ -152,49 +307,137 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
endTime.getMinutes()
).toISOString()
: null,
type,
startDate,
startTime,
endTime,
repeat: isSingleOccurrence ? false : repeat,
weekdays: isSingleOccurrence ? [] : weekdays,
repeatUntil: isSingleOccurrence ? null : repeatUntil,
skipHolidays: isSingleOccurrence ? false : skipHolidays,
event_type: type,
is_active: 1,
created_by: 1,
recurrence_rule: isSingleOccurrence ? null : recurrenceRule,
recurrence_end: isSingleOccurrence ? null : recurrenceEnd,
};
if (type === 'presentation') {
payload.event_media_id = media?.id;
payload.event_media_id = media?.id ? Number(media.id) : undefined;
payload.slideshow_interval = slideshowInterval;
payload.page_progress = pageProgress;
payload.auto_progress = autoProgress;
}
if (type === 'website') {
payload.website_url = websiteUrl;
}
if (type === 'video') {
payload.event_media_id = media?.id ? Number(media.id) : undefined;
payload.autoplay = autoplay;
payload.loop = loop;
payload.volume = volume;
payload.muted = muted;
}
try {
const res = await fetch('/api/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.ok) {
onSave(payload); // <--- HIER ergänzen!
onClose();
let res;
if (editMode && initialData && typeof initialData.Id === 'string') {
// Check if this is a recurring event occurrence that should be detached
const shouldDetach = isSingleOccurrence &&
initialData.OccurrenceOfId &&
!isNaN(Number(initialData.OccurrenceOfId));
if (shouldDetach) {
// DETACH single occurrence from recurring series
// Use occurrenceDate from initialData, or fall back to startDate
const sourceDate = initialData.occurrenceDate || startDate;
if (!sourceDate) {
setErrors({ api: 'Fehler: Kein Datum für Einzeltermin verfügbar' });
return;
}
const occurrenceDate = sourceDate instanceof Date
? sourceDate.toISOString().split('T')[0]
: new Date(sourceDate).toISOString().split('T')[0];
try {
// Use the master event ID (OccurrenceOfId) for detaching
const masterId = Number(initialData.OccurrenceOfId);
res = await detachEventOccurrence(masterId, occurrenceDate, payload);
} catch (error) {
console.error('Detach operation failed:', error);
setErrors({ api: `API Fehler: ${error instanceof Error ? error.message : String(error)}` });
return;
}
} else {
// UPDATE entire event/series OR standalone event
res = await updateEvent(initialData.Id, payload);
}
} else {
const err = await res.json();
setErrors({ api: err.error || 'Fehler beim Speichern' });
// CREATE
res = await fetch('/api/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
res = await res.json();
}
if (res.success) {
onSave(payload);
onClose(); // <--- Box nach erfolgreichem Speichern schließen
} else {
setErrors({ api: res.error || 'Fehler beim Speichern' });
}
} catch {
setErrors({ api: 'Netzwerkfehler beim Speichern' });
}
console.log('handleSave called');
};
// Vergangenheitsprüfung (außerhalb von handleSave, damit im Render verfügbar)
const startDateTime =
startDate && startTime
? new Date(
startDate.getFullYear(),
startDate.getMonth(),
startDate.getDate(),
startTime.getHours(),
startTime.getMinutes()
)
: null;
const isPast = !!(startDateTime && startDateTime < new Date());
// Button sollte nur für Einzeltermine in der Vergangenheit deaktiviert werden
// Wiederkehrende Serien können bearbeitet werden, auch wenn der Starttermin vergangen ist
const shouldDisableButton = isPast && (isSingleOccurrence || !repeat);
return (
<DialogComponent
target="#root"
visible={open}
width="800px"
header={() => (
<div>
Neuen Termin anlegen
<div
style={{
background: groupColor || '#f5f5f5',
padding: '8px 16px',
borderRadius: '6px 6px 0 0',
color: '#fff',
fontWeight: 600,
fontSize: 20,
display: 'flex',
alignItems: 'center',
}}
>
{editMode
? (isSingleOccurrence ? 'Einzeltermin bearbeiten' : 'Terminserie bearbeiten')
: 'Neuen Termin anlegen'}
{groupName && (
<span style={{ fontWeight: 400, fontSize: 16, marginLeft: 16, color: '#888' }}>
<span style={{ fontWeight: 400, fontSize: 16, marginLeft: 16, color: '#fff' }}>
für Raumgruppe: <b>{typeof groupName === 'object' ? groupName.name : groupName}</b>
</span>
)}
@@ -208,7 +451,11 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
<button className="e-btn e-danger" onClick={onClose}>
Schließen
</button>
<button className="e-btn e-success" onClick={handleSave}>
<button
className="e-btn e-success"
onClick={handleSave}
disabled={shouldDisableButton} // <--- Button nur für Einzeltermine in Vergangenheit deaktivieren
>
Termin(e) speichern
</button>
</div>
@@ -246,6 +493,38 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
{errors.startDate && (
<div style={{ color: 'red', fontSize: 12 }}>{errors.startDate}</div>
)}
{isPast && (isSingleOccurrence || !repeat) && (
<span
style={{
color: 'orange',
fontWeight: 600,
marginLeft: 8,
display: 'inline-block',
background: '#fff3cd',
borderRadius: 4,
padding: '2px 8px',
border: '1px solid #ffeeba',
}}
>
Termin liegt in der Vergangenheit!
</span>
)}
{isPast && repeat && !isSingleOccurrence && (
<span
style={{
color: 'blue',
fontWeight: 600,
marginLeft: 8,
display: 'inline-block',
background: '#e3f2fd',
borderRadius: 4,
padding: '2px 8px',
border: '1px solid #90caf9',
}}
>
Bearbeitung einer Terminserie (Startdatum kann in Vergangenheit liegen)
</span>
)}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<div style={{ flex: 1 }}>
@@ -276,11 +555,19 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
</div>
<div style={{ flex: 1, minWidth: 260 }}>
{/* ...Wiederholung, MultiSelect, Wiederholung bis, Ferien... */}
{isSingleOccurrence && (
<div style={{ marginBottom: 12, padding: '8px 12px', backgroundColor: '#e8f4fd', borderRadius: 4, border: '1px solid #bee5eb' }}>
<span style={{ fontSize: '14px', color: '#0c5460', fontWeight: 500 }}>
Bearbeitung eines Einzeltermins - Wiederholungsoptionen nicht verfügbar
</span>
</div>
)}
<div style={{ marginBottom: 12 }}>
<CheckBoxComponent
label="Wiederholender Termin"
checked={repeat}
change={e => setRepeat(e.checked)}
disabled={isSingleOccurrence}
/>
</div>
<div style={{ marginBottom: 12 }}>
@@ -291,7 +578,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
placeholder="Wochentage"
value={weekdays}
change={e => setWeekdays(e.value as number[])}
disabled={!repeat}
disabled={!repeat || isSingleOccurrence}
showDropDownIcon={true}
closePopupOnSelect={false}
/>
@@ -303,7 +590,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
floatLabelType="Auto"
value={repeatUntil ?? undefined}
change={e => setRepeatUntil(e.value)}
disabled={!repeat}
disabled={!repeat || isSingleOccurrence}
/>
</div>
<div style={{ marginBottom: 12 }}>
@@ -311,7 +598,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
label="Ferientage berücksichtigen"
checked={skipHolidays}
change={e => setSkipHolidays(e.checked)}
disabled={!repeat}
disabled={!repeat || isSingleOccurrence}
/>
</div>
</div>
@@ -360,6 +647,20 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
value={String(slideshowInterval)}
change={e => setSlideshowInterval(Number(e.value))}
/>
<div style={{ marginTop: 8 }}>
<CheckBoxComponent
label="Seitenfortschritt anzeigen"
checked={pageProgress}
change={e => setPageProgress(e.checked || false)}
/>
</div>
<div style={{ marginTop: 8 }}>
<CheckBoxComponent
label="Automatischer Fortschritt"
checked={autoProgress}
change={e => setAutoProgress(e.checked || false)}
/>
</div>
</div>
)}
{type === 'website' && (
@@ -372,6 +673,61 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
/>
</div>
)}
{type === 'video' && (
<div>
<div style={{ marginBottom: 8, marginTop: 16 }}>
<button
className="e-btn"
onClick={() => setMediaModalOpen(true)}
style={{ width: '100%' }}
>
Video auswählen/hochladen
</button>
</div>
<div style={{ marginBottom: 8 }}>
<b>Ausgewähltes Video:</b>{' '}
{media ? (
media.path
) : (
<span style={{ color: '#888' }}>Kein Video ausgewählt</span>
)}
</div>
<div style={{ marginTop: 8 }}>
<CheckBoxComponent
label="Automatisch abspielen"
checked={autoplay}
change={e => setAutoplay(e.checked || false)}
/>
</div>
<div style={{ marginTop: 8 }}>
<CheckBoxComponent
label="In Schleife abspielen"
checked={loop}
change={e => setLoop(e.checked || false)}
/>
</div>
<div style={{ marginTop: 8 }}>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500, fontSize: '14px' }}>
Lautstärke
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<TextBoxComponent
placeholder="0.0 - 1.0"
floatLabelType="Never"
type="number"
value={String(volume)}
change={e => setVolume(Math.max(0, Math.min(1, Number(e.value))))}
style={{ flex: 1 }}
/>
<CheckBoxComponent
label="Ton aus"
checked={muted}
change={e => setMuted(e.checked || false)}
/>
</div>
</div>
</div>
)}
</div>
</div>
</div>

View File

@@ -1,25 +1,56 @@
import React from 'react';
interface CustomMediaInfoPanelProps {
mediaId: string;
title: string;
description: string;
eventId?: string;
onSave: (data: { title: string; description: string; eventId?: string }) => void;
name: string;
size: number;
type: string;
dateModified: number;
description?: string | null;
}
const CustomMediaInfoPanel: React.FC<CustomMediaInfoPanelProps> = ({
mediaId,
title,
name,
size,
type,
dateModified,
description,
eventId,
onSave,
}) => {
// Hier kannst du Formularfelder und Logik für die Bearbeitung einbauen
function formatLocalDate(timestamp: number | undefined | null) {
if (!timestamp || isNaN(timestamp)) return '-';
const date = new Date(timestamp * 1000);
return date.toLocaleString('de-DE');
}
return (
<div>
<h3>Medien-Informationen bearbeiten</h3>
{/* Formularfelder für Titel, Beschreibung, Event-Zuordnung */}
<div
style={{
padding: 16,
border: '1px solid #eee',
borderRadius: 8,
background: '#fafafa',
maxWidth: 400,
}}
>
<h3 style={{ marginBottom: 12 }}>Datei-Eigenschaften</h3>
<div>
<b>Name:</b> {name || '-'}
</div>
<div>
<b>Typ:</b> {type || '-'}
</div>
<div>
<b>Größe:</b> {typeof size === 'number' && !isNaN(size) ? size + ' Bytes' : '-'}
</div>
<div>
<b>Geändert:</b> {formatLocalDate(dateModified)}
</div>
<div>
<b>Beschreibung:</b>{' '}
{description && description !== 'null' ? (
description
) : (
<span style={{ color: '#888' }}>Keine Beschreibung</span>
)}
</div>
</div>
);
};

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import { useAuth } from '../useAuth';
import { DialogComponent } from '@syncfusion/ej2-react-popups';
import {
FileManagerComponent,
@@ -19,6 +20,8 @@ type CustomSelectUploadEventModalProps = {
const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps> = props => {
const { open, onClose, onSelect } = props;
const { user } = useAuth();
const isSuperadmin = useMemo(() => user?.role === 'superadmin', [user]);
const [selectedFile, setSelectedFile] = useState<{
id: string;
@@ -63,6 +66,23 @@ const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps>
}
};
type FileItem = { name: string; isFile: boolean };
type ReadSuccessArgs = { action: string; result?: { files?: FileItem[] } };
type FileOpenArgs = { fileDetails?: FileItem; cancel?: boolean };
const handleSuccess = (args: ReadSuccessArgs) => {
if (isSuperadmin) return;
if (args && args.action === 'read' && args.result && Array.isArray(args.result.files)) {
args.result.files = args.result.files.filter((f: FileItem) => !(f.name === 'converted' && !f.isFile));
}
};
const handleFileOpen = (args: FileOpenArgs) => {
if (!isSuperadmin && args && args.fileDetails && args.fileDetails.name === 'converted' && !args.fileDetails.isFile) {
args.cancel = true;
}
};
return (
<DialogComponent
target="#root"
@@ -84,6 +104,9 @@ const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps>
)}
>
<FileManagerComponent
cssClass="e-bigger media-icons-xl"
success={handleSuccess}
fileOpen={handleFileOpen}
ajaxSettings={{
url: hostUrl + 'operations',
getImageUrl: hostUrl + 'get-image',

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Wrench } from 'lucide-react';
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
const SetupModeButton: React.FC = () => {
const navigate = useNavigate();
return (
<ButtonComponent
cssClass="e-warning"
onClick={() => navigate('/setup')}
title="Erweiterungsmodus starten"
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<Wrench size={14.4} />
Erweiterungsmodus
</span>
</ButtonComponent>
);
};
export default SetupModeButton;

View File

@@ -1,43 +1,911 @@
import React, { useEffect, useState } from 'react';
import { fetchClients } from './apiClients';
import type { Client } from './apiClients';
import { fetchGroupsWithClients, restartClient } from './apiClients';
import type { Group, Client } from './apiClients';
import { fetchEvents } from './apiEvents';
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
import { ToastComponent, MessageComponent } from '@syncfusion/ej2-react-notifications';
import { listHolidays } from './apiHolidays';
import { getActiveAcademicPeriod, type AcademicPeriod } from './apiAcademicPeriods';
import { getHolidayBannerSetting } from './apiSystemSettings';
const REFRESH_INTERVAL = 15000; // 15 Sekunden
type FilterType = 'all' | 'online' | 'offline' | 'warning';
interface ActiveEvent {
id: string;
title: string;
event_type: string;
start: string;
end: string;
recurrenceRule?: string;
isRecurring: boolean;
}
interface GroupEvents {
[groupId: number]: ActiveEvent | null;
}
const Dashboard: React.FC = () => {
const [clients, setClients] = useState<Client[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const [expandedCards, setExpandedCards] = useState<Set<number>>(new Set());
const [filter, setFilter] = useState<FilterType>('all');
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
const [activeEvents, setActiveEvents] = useState<GroupEvents>({});
const toastRef = React.useRef<ToastComponent>(null);
// Holiday status state
const [holidayBannerEnabled, setHolidayBannerEnabled] = useState<boolean>(true);
const [activePeriod, setActivePeriod] = useState<AcademicPeriod | null>(null);
const [holidayOverlapCount, setHolidayOverlapCount] = useState<number>(0);
const [holidayFirst, setHolidayFirst] = useState<string | null>(null);
const [holidayLast, setHolidayLast] = useState<string | null>(null);
const [holidayLoading, setHolidayLoading] = useState<boolean>(false);
const [holidayError, setHolidayError] = useState<string | null>(null);
// Optimiertes Update: Nur bei echten Datenänderungen wird das Grid aktualisiert
useEffect(() => {
fetchClients().then(setClients).catch(console.error);
let lastGroups: Group[] = [];
const fetchAndUpdate = async () => {
try {
const newGroups = await fetchGroupsWithClients();
// Vergleiche nur die relevanten Felder (id, clients, is_alive)
const changed =
lastGroups.length !== newGroups.length ||
lastGroups.some((g, i) => {
const ng = newGroups[i];
if (!ng || g.id !== ng.id || g.clients.length !== ng.clients.length) return true;
// Optional: Vergleiche tiefer, z.B. Alive-Status
for (let j = 0; j < g.clients.length; j++) {
if (
g.clients[j].uuid !== ng.clients[j].uuid ||
g.clients[j].is_alive !== ng.clients[j].is_alive
) {
return true;
}
}
return false;
});
if (changed) {
setGroups(newGroups);
lastGroups = newGroups;
setLastUpdate(new Date());
// Fetch active events for all groups
fetchActiveEventsForGroups(newGroups);
}
} catch (error) {
console.error('Fehler beim Laden der Gruppen:', error);
}
};
fetchAndUpdate();
const interval = setInterval(fetchAndUpdate, REFRESH_INTERVAL);
return () => clearInterval(interval);
}, []);
// Load academic period & holidays status
useEffect(() => {
const loadHolidayStatus = async () => {
// Check if banner is enabled first
try {
const bannerSetting = await getHolidayBannerSetting();
setHolidayBannerEnabled(bannerSetting.enabled);
if (!bannerSetting.enabled) {
return; // Skip loading if disabled
}
} catch (e) {
console.error('Fehler beim Laden der Banner-Einstellung:', e);
// Continue with default (enabled)
}
setHolidayLoading(true);
setHolidayError(null);
try {
const period = await getActiveAcademicPeriod();
setActivePeriod(period || null);
const holidayData = await listHolidays();
const list = holidayData.holidays || [];
if (period) {
// Check for holidays overlapping with active period
const ps = new Date(period.start_date + 'T00:00:00');
const pe = new Date(period.end_date + 'T23:59:59');
const overlapping = list.filter(h => {
const hs = new Date(h.start_date + 'T00:00:00');
const he = new Date(h.end_date + 'T23:59:59');
return hs <= pe && he >= ps;
});
setHolidayOverlapCount(overlapping.length);
if (overlapping.length > 0) {
const sorted = overlapping.slice().sort((a, b) => a.start_date.localeCompare(b.start_date));
setHolidayFirst(sorted[0].start_date);
setHolidayLast(sorted[sorted.length - 1].end_date);
} else {
setHolidayFirst(null);
setHolidayLast(null);
}
} else {
setHolidayOverlapCount(0);
setHolidayFirst(null);
setHolidayLast(null);
}
} catch (e) {
const msg = e instanceof Error ? e.message : 'Ferienstatus konnte nicht geladen werden';
setHolidayError(msg);
} finally {
setHolidayLoading(false);
}
};
loadHolidayStatus();
}, []);
// Fetch currently active events for all groups
const fetchActiveEventsForGroups = async (groupsList: Group[]) => {
const now = new Date();
const eventsMap: GroupEvents = {};
for (const group of groupsList) {
try {
const events = await fetchEvents(String(group.id), false, {
start: new Date(now.getTime() - 60000), // 1 minute ago
end: new Date(now.getTime() + 60000), // 1 minute ahead
expand: true
});
// Find the first active event
if (events && events.length > 0) {
const activeEvent = events[0];
eventsMap[group.id] = {
id: activeEvent.id,
title: activeEvent.subject || 'Unbenannter Event',
event_type: activeEvent.type || 'unknown',
start: activeEvent.startTime, // Keep as string, will be parsed in format functions
end: activeEvent.endTime,
recurrenceRule: activeEvent.recurrenceRule,
isRecurring: !!activeEvent.recurrenceRule
};
} else {
eventsMap[group.id] = null;
}
} catch {
console.error(`Fehler beim Laden der Events für Gruppe ${group.id}`);
eventsMap[group.id] = null;
}
}
setActiveEvents(eventsMap);
};
// Toggle card expansion
const toggleCard = (groupId: number) => {
setExpandedCards(prev => {
const newSet = new Set(prev);
if (newSet.has(groupId)) {
newSet.delete(groupId);
} else {
newSet.add(groupId);
}
return newSet;
});
};
// Health-Statistik berechnen
const getHealthStats = (group: Group) => {
const total = group.clients.length;
const alive = group.clients.filter((c: Client) => c.is_alive).length;
const offline = total - alive;
const ratio = total === 0 ? 0 : alive / total;
let statusColor = '#e74c3c'; // danger red
let statusText = 'Kritisch';
if (ratio === 1) {
statusColor = '#27ae60'; // success green
statusText = 'Optimal';
} else if (ratio >= 0.5) {
statusColor = '#f39c12'; // warning orange
statusText = 'Teilweise';
}
return { total, alive, offline, ratio, statusColor, statusText };
};
// Neustart-Logik
const handleRestartClient = async (uuid: string, description: string) => {
try {
const result = await restartClient(uuid);
toastRef.current?.show({
title: 'Neustart erfolgreich',
content: `${description || uuid}: ${result.message}`,
cssClass: 'e-toast-success',
icon: 'e-success toast-icons',
});
} catch (error: unknown) {
const message = error && typeof error === 'object' && 'message' in error
? (error as { message: string }).message
: 'Unbekannter Fehler beim Neustart';
toastRef.current?.show({
title: 'Fehler beim Neustart',
content: message,
cssClass: 'e-toast-danger',
icon: 'e-error toast-icons',
});
}
};
// Bulk restart für offline Clients einer Gruppe
const handleRestartAllOffline = async (group: Group) => {
const offlineClients = group.clients.filter(c => !c.is_alive);
if (offlineClients.length === 0) {
toastRef.current?.show({
title: 'Keine Offline-Geräte',
content: `Alle Infoscreens in "${group.name}" sind online.`,
cssClass: 'e-toast-info',
icon: 'e-info toast-icons',
});
return;
}
const confirmed = window.confirm(
`Möchten Sie ${offlineClients.length} offline Infoscreen(s) in "${group.name}" neu starten?`
);
if (!confirmed) return;
let successCount = 0;
let failCount = 0;
for (const client of offlineClients) {
try {
await restartClient(client.uuid);
successCount++;
} catch {
failCount++;
}
}
toastRef.current?.show({
title: 'Bulk-Neustart abgeschlossen',
content: `${successCount} erfolgreich, ✗ ${failCount} fehlgeschlagen`,
cssClass: failCount > 0 ? 'e-toast-warning' : 'e-toast-success',
icon: failCount > 0 ? 'e-warning toast-icons' : 'e-success toast-icons',
});
};
// Berechne Gesamtstatistiken
const getGlobalStats = () => {
const allClients = groups.flatMap(g => g.clients);
const total = allClients.length;
const online = allClients.filter(c => c.is_alive).length;
const offline = total - online;
const ratio = total === 0 ? 0 : online / total;
// Warnungen: Gruppen mit teilweise offline Clients
const warningGroups = groups.filter(g => {
const groupStats = getHealthStats(g);
return groupStats.ratio > 0 && groupStats.ratio < 1;
}).length;
return { total, online, offline, ratio, warningGroups };
};
// Berechne "Zeit seit letztem Lebenszeichen"
const getTimeSinceLastAlive = (lastAlive: string | null | undefined): string => {
if (!lastAlive) return 'Nie';
try {
const dateStr = lastAlive.endsWith('Z') ? lastAlive : lastAlive + 'Z';
const date = new Date(dateStr);
if (isNaN(date.getTime())) return 'Unbekannt';
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'Gerade eben';
if (diffMins < 60) return `vor ${diffMins} Min.`;
if (diffHours < 24) return `vor ${diffHours} Std.`;
return `vor ${diffDays} Tag${diffDays > 1 ? 'en' : ''}`;
} catch {
return 'Unbekannt';
}
};
// Manuelle Aktualisierung
const handleManualRefresh = async () => {
try {
const newGroups = await fetchGroupsWithClients();
setGroups(newGroups);
setLastUpdate(new Date());
await fetchActiveEventsForGroups(newGroups);
toastRef.current?.show({
title: 'Aktualisiert',
content: 'Daten wurden erfolgreich aktualisiert',
cssClass: 'e-toast-success',
icon: 'e-success toast-icons',
timeOut: 2000,
});
} catch {
toastRef.current?.show({
title: 'Fehler',
content: 'Daten konnten nicht aktualisiert werden',
cssClass: 'e-toast-danger',
icon: 'e-error toast-icons',
});
}
};
// Get event type icon
const getEventTypeIcon = (eventType: string): string => {
switch (eventType) {
case 'presentation': return '📊';
case 'website': return '🌐';
case 'webuntis': return '📅';
case 'video': return '🎬';
case 'message': return '💬';
default: return '📄';
}
};
// Get event type label
const getEventTypeLabel = (eventType: string): string => {
switch (eventType) {
case 'presentation': return 'Präsentation';
case 'website': return 'Website';
case 'webuntis': return 'WebUntis';
case 'video': return 'Video';
case 'message': return 'Nachricht';
default: return 'Inhalt';
}
};
// Format time for display
const formatEventTime = (dateStr: string): string => {
try {
// API returns UTC ISO strings without 'Z', so append 'Z' to parse as UTC
const utcString = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z';
const date = new Date(utcString);
if (isNaN(date.getTime())) return '—';
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
} catch {
return '—';
}
};
// Format date for display
const formatEventDate = (dateStr: string): string => {
try {
// API returns UTC ISO strings without 'Z', so append 'Z' to parse as UTC
const utcString = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z';
const date = new Date(utcString);
if (isNaN(date.getTime())) return '—';
const today = new Date();
const isToday = date.toDateString() === today.toDateString();
if (isToday) {
return 'Heute';
}
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
} catch {
return '—';
}
};
// Filter Gruppen basierend auf Status
const getFilteredGroups = () => {
return groups.filter(group => {
const stats = getHealthStats(group);
switch (filter) {
case 'online':
return stats.ratio === 1;
case 'offline':
return stats.ratio === 0;
case 'warning':
return stats.ratio > 0 && stats.ratio < 1;
default:
return true;
}
});
};
// Format date for holiday display
const formatDate = (iso: string | null) => {
if (!iso) return '-';
try {
const d = new Date(iso + 'T00:00:00');
return d.toLocaleDateString('de-DE');
} catch { return iso; }
};
// Holiday Status Banner Component
const HolidayStatusBanner = () => {
if (holidayLoading) {
return (
<MessageComponent severity="Info" variant="Filled">
Lade Ferienstatus ...
</MessageComponent>
);
}
if (holidayError) {
return (
<MessageComponent severity="Error" variant="Filled">
Fehler beim Laden des Ferienstatus: {holidayError}
</MessageComponent>
);
}
if (!activePeriod) {
return (
<MessageComponent severity="Warning" variant="Outlined">
Keine aktive akademische Periode gesetzt Ferienplan nicht verknüpft.
</MessageComponent>
);
}
if (holidayOverlapCount > 0) {
return (
<MessageComponent severity="Success" variant="Filled">
Ferienplan vorhanden für <strong>{activePeriod.display_name || activePeriod.name}</strong>: {holidayOverlapCount} Zeitraum{holidayOverlapCount === 1 ? '' : 'e'}
{holidayFirst && holidayLast && (
<> ({formatDate(holidayFirst)} {formatDate(holidayLast)})</>
)}
</MessageComponent>
);
}
return (
<MessageComponent severity="Warning" variant="Filled">
Kein Ferienplan für <strong>{activePeriod.display_name || activePeriod.name}</strong> importiert. Jetzt unter Einstellungen 📅 Kalender hochladen.
</MessageComponent>
);
};
return (
<div>
<header className="mb-8 pb-4 border-b-2 border-[#d6c3a6]">
<h2 className="text-3xl font-extrabold mb-2">Dashboard</h2>
<ToastComponent
ref={toastRef}
position={{ X: 'Right', Y: 'Top' }}
timeOut={4000}
/>
<header style={{ marginBottom: 24, paddingBottom: 16, borderBottom: '2px solid #d6c3a6' }}>
<h2 style={{ fontSize: '1.75rem', fontWeight: 800, margin: 0, marginBottom: 8 }}>
Dashboard
</h2>
<p style={{ color: '#666', fontSize: '0.95rem', margin: 0 }}>
Übersicht aller Raumgruppen und deren Infoscreens
</p>
</header>
<h3 className="text-lg font-semibold mt-6 mb-4">Infoscreens</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{clients.map(client => (
<div key={client.uuid} className="bg-white rounded shadow p-4 flex flex-col items-center">
<h4 className="text-lg font-bold mb-2">{client.location || 'Unbekannter Standort'}</h4>
<img
src={`/screenshots/${client.uuid}`}
alt={`Screenshot ${client.location}`}
className="w-full h-48 object-contain bg-gray-100 mb-2"
onError={e => (e.currentTarget.style.display = 'none')}
/>
<div className="text-sm text-gray-700 mb-1">
<span className="font-semibold">IP:</span> {client.ip_address}
</div>
<div className="text-sm text-gray-700">
<span className="font-semibold">Letztes Lebenszeichen:</span>{' '}
{client.last_alive ? new Date(client.last_alive).toLocaleString() : '-'}
{/* Holiday Status Banner */}
{holidayBannerEnabled && (
<div style={{ marginBottom: '20px' }}>
<HolidayStatusBanner />
</div>
)}
{/* Global Statistics Summary */}
{(() => {
const globalStats = getGlobalStats();
return (
<div className="e-card" style={{
marginBottom: '24px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
borderRadius: '8px',
padding: '24px'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '20px',
alignItems: 'center'
}}>
<div>
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '4px' }}>
Gesamt Infoscreens
</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>
{globalStats.total}
</div>
</div>
<div>
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '4px' }}>
🟢 Online
</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>
{globalStats.online}
<span style={{ fontSize: '1rem', marginLeft: '8px', opacity: 0.8 }}>
({globalStats.total > 0 ? Math.round(globalStats.ratio * 100) : 0}%)
</span>
</div>
</div>
<div>
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '4px' }}>
🔴 Offline
</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>
{globalStats.offline}
</div>
</div>
<div>
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '4px' }}>
Gruppen mit Warnungen
</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>
{globalStats.warningGroups}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '8px' }}>
Zuletzt aktualisiert: {getTimeSinceLastAlive(lastUpdate.toISOString())}
</div>
<ButtonComponent
cssClass="e-small e-info"
iconCss="e-icons e-refresh"
onClick={handleManualRefresh}
>
Aktualisieren
</ButtonComponent>
</div>
</div>
</div>
))}
{clients.length === 0 && (
<div className="col-span-full text-center text-gray-400">Keine Clients gefunden.</div>
)}
);
})()}
{/* Filter Buttons */}
<div style={{
display: 'flex',
gap: '12px',
marginBottom: '24px',
flexWrap: 'wrap'
}}>
<ButtonComponent
cssClass={filter === 'all' ? 'e-primary' : 'e-outline'}
onClick={() => setFilter('all')}
>
Alle anzeigen ({groups.length})
</ButtonComponent>
<ButtonComponent
cssClass={filter === 'online' ? 'e-success' : 'e-outline'}
onClick={() => setFilter('online')}
>
🟢 Nur Online ({groups.filter(g => getHealthStats(g).ratio === 1).length})
</ButtonComponent>
<ButtonComponent
cssClass={filter === 'offline' ? 'e-danger' : 'e-outline'}
onClick={() => setFilter('offline')}
>
🔴 Nur Offline ({groups.filter(g => getHealthStats(g).ratio === 0).length})
</ButtonComponent>
<ButtonComponent
cssClass={filter === 'warning' ? 'e-warning' : 'e-outline'}
onClick={() => setFilter('warning')}
>
Mit Warnungen ({groups.filter(g => {
const stats = getHealthStats(g);
return stats.ratio > 0 && stats.ratio < 1;
}).length})
</ButtonComponent>
</div>
{/* Group Cards */}
{(() => {
const filteredGroups = getFilteredGroups();
if (filteredGroups.length === 0) {
return (
<div className="e-card" style={{ padding: '40px', textAlign: 'center' }}>
<div style={{ color: '#999', fontSize: '1.1rem' }}>
{filter === 'all'
? 'Keine Raumgruppen gefunden'
: `Keine Gruppen mit Filter "${filter}" gefunden`
}
</div>
</div>
);
}
return (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))',
gap: '24px'
}}>
{filteredGroups
.sort((a, b) => {
// 'Nicht zugeordnet' always comes last
if (a.name === 'Nicht zugeordnet') return 1;
if (b.name === 'Nicht zugeordnet') return -1;
// Otherwise, sort alphabetically
return a.name.localeCompare(b.name);
})
.map((group) => {
const stats = getHealthStats(group);
const isExpanded = expandedCards.has(group.id);
return (
<div key={group.id} className="e-card" style={{
borderRadius: '8px',
overflow: 'hidden',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
transition: 'all 0.3s ease',
border: `2px solid ${stats.statusColor}20`
}}>
{/* Card Header */}
<div className="e-card-header" style={{
background: `linear-gradient(135deg, ${stats.statusColor}15, ${stats.statusColor}05)`,
borderBottom: `3px solid ${stats.statusColor}`,
padding: '16px 20px'
}}>
<div className="e-card-header-title" style={{
fontSize: '1.25rem',
fontWeight: '700',
color: '#333',
marginBottom: '8px'
}}>
{group.name}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
<span className={`e-badge e-badge-${stats.ratio === 1 ? 'success' : stats.ratio >= 0.5 ? 'warning' : 'danger'}`}>
{stats.statusText}
</span>
<span style={{ color: '#666', fontSize: '0.9rem' }}>
{stats.total} {stats.total === 1 ? 'Infoscreen' : 'Infoscreens'}
</span>
</div>
</div>
{/* Card Content - Statistics */}
<div className="e-card-content" style={{ padding: '20px' }}>
{/* Currently Active Event */}
{activeEvents[group.id] ? (
<div style={{
marginBottom: '16px',
padding: '12px',
backgroundColor: '#f0f7ff',
borderLeft: '4px solid #2196F3',
borderRadius: '4px'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px'
}}>
<div style={{
fontSize: '0.75rem',
color: '#666',
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}>
🎯 Aktuell angezeigt
</div>
{activeEvents[group.id]?.isRecurring && (
<span style={{
fontSize: '0.75rem',
backgroundColor: '#e3f2fd',
color: '#1976d2',
padding: '2px 8px',
borderRadius: '12px',
fontWeight: '600'
}}>
🔄 Wiederkehrend
</span>
)}
</div>
<div style={{
fontSize: '0.95rem',
fontWeight: '600',
color: '#333',
marginBottom: '6px',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}>
<span>{getEventTypeIcon(activeEvents[group.id]?.event_type || 'unknown')}</span>
<span style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1
}}>
{activeEvents[group.id]?.title || 'Unbenannter Event'}
</span>
</div>
<div style={{
fontSize: '0.8rem',
color: '#666',
marginBottom: '6px'
}}>
{getEventTypeLabel(activeEvents[group.id]?.event_type || 'unknown')}
</div>
<div style={{
fontSize: '0.8rem',
color: '#555',
display: 'flex',
alignItems: 'center',
gap: '8px',
flexWrap: 'wrap'
}}>
<span style={{ fontWeight: '500' }}>
📅 {activeEvents[group.id]?.start ? formatEventDate(activeEvents[group.id]!.start) : 'Kein Datum'}
</span>
<span></span>
<span>
🕐 {activeEvents[group.id]?.start && activeEvents[group.id]?.end
? `${formatEventTime(activeEvents[group.id]!.start)} - ${formatEventTime(activeEvents[group.id]!.end)}`
: 'Keine Zeit'}
</span>
</div>
</div>
) : (
<div style={{
marginBottom: '16px',
padding: '12px',
backgroundColor: '#f5f5f5',
borderLeft: '4px solid #999',
borderRadius: '4px'
}}>
<div style={{
fontSize: '0.85rem',
color: '#666',
fontStyle: 'italic'
}}>
📭 Kein aktiver Event
</div>
</div>
)}
{/* Health Bar */}
<div style={{ marginBottom: '20px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: '8px',
fontSize: '0.85rem',
color: '#666'
}}>
<span>🟢 Online: {stats.alive}</span>
<span>🔴 Offline: {stats.offline}</span>
</div>
<div style={{
height: '8px',
backgroundColor: '#e0e0e0',
borderRadius: '4px',
overflow: 'hidden'
}}>
<div style={{
height: '100%',
width: `${stats.ratio * 100}%`,
backgroundColor: stats.statusColor,
transition: 'width 0.5s ease'
}} />
</div>
</div>
{/* Action Buttons */}
<div style={{ display: 'flex', gap: '8px', marginBottom: isExpanded ? '16px' : '0' }}>
<ButtonComponent
cssClass="e-outline"
iconCss={isExpanded ? 'e-icons e-chevron-up' : 'e-icons e-chevron-down'}
onClick={() => toggleCard(group.id)}
style={{ flex: 1 }}
>
{isExpanded ? 'Details ausblenden' : 'Details anzeigen'}
</ButtonComponent>
{stats.offline > 0 && (
<ButtonComponent
cssClass="e-danger e-small"
iconCss="e-icons e-refresh"
onClick={() => handleRestartAllOffline(group)}
title={`${stats.offline} offline Gerät(e) neu starten`}
style={{ flexShrink: 0 }}
>
Offline neustarten
</ButtonComponent>
)}
</div>
{/* Expanded Client List */}
{isExpanded && (
<div style={{
marginTop: '16px',
maxHeight: '400px',
overflowY: 'auto',
border: '1px solid #e0e0e0',
borderRadius: '4px'
}}>
{group.clients.length === 0 ? (
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
Keine Infoscreens in dieser Gruppe
</div>
) : (
group.clients.map((client, index) => (
<div
key={client.uuid}
style={{
padding: '12px 16px',
borderBottom: index < group.clients.length - 1 ? '1px solid #f0f0f0' : 'none',
backgroundColor: client.is_alive ? '#f8fff9' : '#fff5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px'
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontWeight: '600',
fontSize: '0.95rem',
marginBottom: '4px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{client.description || client.hostname || 'Unbenannt'}
</div>
<div style={{
fontSize: '0.85rem',
color: '#666',
display: 'flex',
gap: '12px',
flexWrap: 'wrap'
}}>
<span>📍 {client.ip || 'Keine IP'}</span>
{client.hostname && (
<span title={client.hostname} style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '150px'
}}>
🖥 {client.hostname}
</span>
)}
</div>
<div style={{
fontSize: '0.75rem',
color: client.is_alive ? '#27ae60' : '#e74c3c',
marginTop: '4px',
fontWeight: '500'
}}>
{client.is_alive
? `✓ Aktiv ${getTimeSinceLastAlive(client.last_alive)}`
: `⚠ Offline seit ${getTimeSinceLastAlive(client.last_alive)}`
}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }}>
<span className={`e-badge e-badge-${client.is_alive ? 'success' : 'danger'}`}>
{client.is_alive ? 'Online' : 'Offline'}
</span>
<ButtonComponent
cssClass="e-small e-primary"
iconCss="e-icons e-refresh"
onClick={() => handleRestartClient(client.uuid, client.description || client.hostname || 'Infoscreen')}
title="Neustart"
/>
</div>
</div>
))
)}
</div>
)}
</div>
</div>
);
})}
</div>
);
})()}
</div>
);
};

View File

@@ -1,8 +0,0 @@
import React from 'react';
const Einstellungen: React.FC = () => (
<div>
<h2 className="text-xl font-bold mb-4">Einstellungen</h2>
<p>Willkommen im Infoscreen-Management Einstellungen.</p>
</div>
);
export default Einstellungen;

View File

@@ -0,0 +1,36 @@
import { useState } from 'react';
import { deleteClient } from '../apiClients';
export function useClientDelete(onDeleted?: (uuid: string) => void) {
const [showDialog, setShowDialog] = useState(false);
const [deleteClientId, setDeleteClientId] = useState<string | null>(null);
// Details-Modal separat im Parent verwalten!
const handleDelete = (uuid: string) => {
setDeleteClientId(uuid);
setShowDialog(true);
};
const confirmDelete = async () => {
if (deleteClientId) {
await deleteClient(deleteClientId);
setShowDialog(false);
if (onDeleted) onDeleted(deleteClientId);
setDeleteClientId(null);
}
};
const cancelDelete = () => {
setShowDialog(false);
setDeleteClientId(null);
};
return {
showDialog,
deleteClientId,
handleDelete,
confirmDelete,
cancelDelete,
};
}

View File

@@ -1,6 +1,6 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Tailwind removed: base/components/utilities directives no longer used. */
/* Custom overrides moved to theme-overrides.css to load after Syncfusion styles */
/* :root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;

View File

@@ -5,6 +5,11 @@ import { fetchGroups, createGroup, deleteGroup, renameGroup } from './apiGroups'
import type { Client } from './apiClients';
import type { KanbanComponent as KanbanComponentType } from '@syncfusion/ej2-react-kanban';
import { DialogComponent } from '@syncfusion/ej2-react-popups';
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
import { TextBoxComponent } from '@syncfusion/ej2-react-inputs';
import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns';
import type { ChangedEventArgs as TextBoxChangedArgs } from '@syncfusion/ej2-react-inputs';
import type { ChangeEventArgs as DropDownChangeArgs } from '@syncfusion/ej2-react-dropdowns';
import { useToast } from './components/ToastProvider';
import { L10n } from '@syncfusion/ej2-base';
@@ -41,10 +46,10 @@ const de = {
rename: 'Umbenennen',
confirmDelete: 'Löschbestätigung',
reallyDelete: (name: string) => `Möchten Sie die Gruppe <b>${name}</b> wirklich löschen?`,
clientsMoved: 'Alle Clients werden in "Nicht zugeordnet" verschoben.',
clientsMoved: 'Alle Clients werden nach "Nicht zugeordnet" verschoben.',
groupCreated: 'Gruppe angelegt',
groupDeleted: 'Gruppe gelöscht. Clients in "Nicht zugeordnet" verschoben',
groupRenamed: 'Gruppenname geändert',
groupDeleted: 'Gruppe gelöscht. Alle Clients wurden nach "Nicht zugeordnet" verschoben.',
groupRenamed: 'Gruppe umbenannt',
selectGroup: 'Gruppe wählen',
newName: 'Neuer Name',
warning: 'Achtung:',
@@ -72,7 +77,7 @@ L10n.load({
const Infoscreen_groups: React.FC = () => {
const toast = useToast();
const [clients, setClients] = useState<KanbanClient[]>([]);
const [groups, setGroups] = useState<{ keyField: string; headerText: string }[]>([]);
const [groups, setGroups] = useState<{ keyField: string; headerText: string; id?: number }[]>([]);
const [showDialog, setShowDialog] = useState(false);
const [newGroupName, setNewGroupName] = useState('');
const [draggedCard, setDraggedCard] = useState<{ id: string; fromColumn: string } | null>(null);
@@ -106,8 +111,13 @@ const Infoscreen_groups: React.FC = () => {
data.map((c, i) => ({
...c,
Id: c.uuid,
Status: groupMap[c.group_id] || 'Nicht zugeordnet',
Summary: c.location || `Client ${i + 1}`,
Status:
c.group_id === 1
? 'Nicht zugeordnet'
: typeof c.group_id === 'number' && groupMap[c.group_id]
? groupMap[c.group_id]
: 'Nicht zugeordnet',
Summary: c.description || `Client ${i + 1}`,
}))
);
});
@@ -125,9 +135,31 @@ const Infoscreen_groups: React.FC = () => {
timeOut: 5000,
showCloseButton: false,
});
setGroups([...groups, { keyField: newGroup.name, headerText: newGroup.name }]);
setGroups([
...groups,
{ keyField: newGroup.name, headerText: newGroup.name, id: newGroup.id },
]);
setNewGroupName('');
setShowDialog(false);
// Update group order to include the new group
try {
const orderResponse = await fetch('/api/groups/order');
if (orderResponse.ok) {
const orderData = await orderResponse.json();
const currentOrder = orderData.order || [];
// Add new group ID to the end if not already present
if (!currentOrder.includes(newGroup.id)) {
await fetch('/api/groups/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order: [...currentOrder, newGroup.id] }),
});
}
}
} catch (err) {
console.error('Failed to update group order:', err);
}
} catch (err) {
toast.show({
content: (err as Error).message,
@@ -141,12 +173,19 @@ const Infoscreen_groups: React.FC = () => {
// Löschen einer Gruppe
const handleDeleteGroup = async (groupName: string) => {
try {
// Find the group ID before deleting
const groupToDelete = groups.find(g => g.headerText === groupName);
const deletedGroupId = groupToDelete?.id;
// Clients der Gruppe in "Nicht zugeordnet" verschieben
const groupClients = clients.filter(c => c.Status === groupName);
if (groupClients.length > 0) {
// Ermittle die ID der Zielgruppe "Nicht zugeordnet"
const target = groups.find(g => g.headerText === 'Nicht zugeordnet');
if (!target || !target.id) throw new Error('Zielgruppe "Nicht zugeordnet" nicht gefunden');
await updateClientGroup(
groupClients.map(c => c.Id),
'Nicht zugeordnet'
target.id
);
}
await deleteGroup(groupName);
@@ -156,6 +195,27 @@ const Infoscreen_groups: React.FC = () => {
timeOut: 5000,
showCloseButton: false,
});
// Update group order to remove the deleted group
if (deletedGroupId) {
try {
const orderResponse = await fetch('/api/groups/order');
if (orderResponse.ok) {
const orderData = await orderResponse.json();
const currentOrder = orderData.order || [];
// Remove deleted group ID from order
const updatedOrder = currentOrder.filter((id: number) => id !== deletedGroupId);
await fetch('/api/groups/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order: updatedOrder }),
});
}
} catch (err) {
console.error('Failed to update group order:', err);
}
}
// Gruppen und Clients neu laden
const groupData = await fetchGroups();
const groupMap = Object.fromEntries(groupData.map((g: Group) => [g.id, g.name]));
@@ -165,8 +225,11 @@ const Infoscreen_groups: React.FC = () => {
data.map((c, i) => ({
...c,
Id: c.uuid,
Status: groupMap[c.group_id] || 'Nicht zugeordnet',
Summary: c.location || `Client ${i + 1}`,
Status:
typeof c.group_id === 'number' && groupMap[c.group_id]
? groupMap[c.group_id]
: 'Nicht zugeordnet',
Summary: c.description || `Client ${i + 1}`,
}))
);
} catch (err) {
@@ -199,8 +262,11 @@ const Infoscreen_groups: React.FC = () => {
data.map((c, i) => ({
...c,
Id: c.uuid,
Status: groupMap[c.group_id] || 'Nicht zugeordnet',
Summary: c.location || `Client ${i + 1}`,
Status:
typeof c.group_id === 'number' && groupMap[c.group_id]
? groupMap[c.group_id]
: 'Nicht zugeordnet',
Summary: c.description || `Client ${i + 1}`,
}))
);
} catch (err) {
@@ -260,7 +326,10 @@ const Infoscreen_groups: React.FC = () => {
const clientIds = dropped.map((card: KanbanClient) => card.Id);
try {
await updateClientGroup(clientIds, targetGroupName);
// Ermittle Zielgruppen-ID anhand des Namens
const target = groups.find(g => g.headerText === targetGroupName);
if (!target || !target.id) throw new Error('Zielgruppe nicht gefunden');
await updateClientGroup(clientIds, target.id);
fetchGroups().then((groupData: Group[]) => {
const groupMap = Object.fromEntries(groupData.map(g => [g.id, g.name]));
setGroups(
@@ -275,8 +344,11 @@ const Infoscreen_groups: React.FC = () => {
data.map((c, i) => ({
...c,
Id: c.uuid,
Status: groupMap[c.group_id] || 'Nicht zugeordnet',
Summary: c.location || `Client ${i + 1}`,
Status:
typeof c.group_id === 'number' && groupMap[c.group_id]
? groupMap[c.group_id]
: 'Nicht zugeordnet',
Summary: c.description || `Client ${i + 1}`,
}))
);
// Nach dem Laden: Karten deselektieren
@@ -289,7 +361,12 @@ const Infoscreen_groups: React.FC = () => {
});
});
} catch {
alert('Fehler beim Aktualisieren der Clients');
toast.show({
content: 'Fehler beim Aktualisieren der Clients',
cssClass: 'e-toast-danger',
timeOut: 0,
showCloseButton: true,
});
}
setDraggedCard(null);
};
@@ -302,26 +379,24 @@ const Infoscreen_groups: React.FC = () => {
return (
<div id="dialog-target">
<h2 className="text-xl font-bold mb-4">{de.title}</h2>
<div className="flex gap-2 mb-4">
<button
className="px-4 py-2 bg-blue-500 text-white rounded"
onClick={() => setShowDialog(true)}
>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, marginBottom: 16 }}>{de.title}</h2>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '12px',
marginBottom: '16px',
}}
>
<ButtonComponent cssClass="e-primary" onClick={() => setShowDialog(true)}>
{de.newGroup}
</button>
<button
className="px-4 py-2 bg-yellow-500 text-white rounded"
onClick={() => setRenameDialog({ open: true, oldName: '', newName: '' })}
>
</ButtonComponent>
<ButtonComponent cssClass="e-warning" onClick={() => setRenameDialog({ open: true, oldName: '', newName: '' })}>
{de.renameGroup}
</button>
<button
className="px-4 py-2 bg-red-500 text-white rounded"
onClick={() => setDeleteDialog({ open: true, groupName: '' })}
>
</ButtonComponent>
<ButtonComponent cssClass="e-danger" onClick={() => setDeleteDialog({ open: true, groupName: '' })}>
{de.deleteGroup}
</button>
</ButtonComponent>
</div>
<KanbanComponent
locale="de"
@@ -339,155 +414,155 @@ const Infoscreen_groups: React.FC = () => {
columns={kanbanColumns}
/>
{showDialog && (
<div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center">
<div className="bg-white p-6 rounded shadow">
<h3 className="mb-2 font-bold">{de.newGroup}</h3>
<input
className="border p-2 mb-2 w-full"
value={newGroupName}
onChange={e => setNewGroupName(e.target.value)}
placeholder="Raumname"
/>
<div className="flex gap-2">
<button className="bg-blue-500 text-white px-4 py-2 rounded" onClick={handleAddGroup}>
<DialogComponent
visible={showDialog}
header={de.newGroup}
close={() => setShowDialog(false)}
target="#dialog-target"
width="420px"
footerTemplate={() => (
<div className="flex gap-2 justify-end">
<ButtonComponent cssClass="e-primary" onClick={handleAddGroup} disabled={!newGroupName.trim()}>
{de.add}
</button>
<button
className="bg-gray-300 px-4 py-2 rounded"
onClick={() => setShowDialog(false)}
>
{de.cancel}
</button>
</ButtonComponent>
<ButtonComponent onClick={() => setShowDialog(false)}>{de.cancel}</ButtonComponent>
</div>
)}
>
<div className="mt-2">
<TextBoxComponent
value={newGroupName}
placeholder="Raumname"
floatLabelType="Auto"
change={(args: TextBoxChangedArgs) => setNewGroupName(String(args.value ?? ''))}
/>
</div>
</div>
</DialogComponent>
)}
{renameDialog.open && (
<div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center">
<div className="bg-white p-6 rounded shadow">
<h3 className="mb-2 font-bold">{de.renameGroup}</h3>
<select
className="border p-2 mb-2 w-full"
value={renameDialog.oldName}
onChange={e =>
setRenameDialog({
...renameDialog,
oldName: e.target.value,
newName: e.target.value,
})
}
>
<option value="">{de.selectGroup}</option>
{groups
.filter(g => g.headerText !== 'Nicht zugeordnet')
.map(g => (
<option key={g.keyField} value={g.headerText}>
{g.headerText}
</option>
))}
</select>
<input
className="border p-2 mb-2 w-full"
value={renameDialog.newName}
onChange={e => setRenameDialog({ ...renameDialog, newName: e.target.value })}
placeholder={de.newName}
/>
<div className="flex gap-2">
<button
className="bg-blue-500 text-white px-4 py-2 rounded"
<DialogComponent
visible={renameDialog.open}
header={de.renameGroup}
showCloseIcon={true}
close={() => setRenameDialog({ open: false, oldName: '', newName: '' })}
target="#dialog-target"
width="480px"
footerTemplate={() => (
<div className="flex gap-2 justify-end">
<ButtonComponent
cssClass="e-primary"
onClick={handleRenameGroup}
disabled={!renameDialog.oldName || !renameDialog.newName}
>
{de.rename}
</button>
<button
className="bg-gray-300 px-4 py-2 rounded"
onClick={() => setRenameDialog({ open: false, oldName: '', newName: '' })}
>
</ButtonComponent>
<ButtonComponent onClick={() => setRenameDialog({ open: false, oldName: '', newName: '' })}>
{de.cancel}
</button>
</ButtonComponent>
</div>
)}
>
<div className="flex flex-col gap-3 mt-2">
<DropDownListComponent
placeholder={de.selectGroup}
dataSource={groups.filter(g => g.headerText !== 'Nicht zugeordnet').map(g => g.headerText)}
value={renameDialog.oldName}
change={(e: DropDownChangeArgs) =>
setRenameDialog({
...renameDialog,
oldName: String(e.value ?? ''),
newName: String(e.value ?? ''),
})
}
/>
<TextBoxComponent
placeholder={de.newName}
value={renameDialog.newName}
floatLabelType="Auto"
change={(args: TextBoxChangedArgs) =>
setRenameDialog({ ...renameDialog, newName: String(args.value ?? '') })
}
/>
</div>
</div>
</DialogComponent>
)}
{deleteDialog.open && (
<div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center">
<div className="bg-white p-6 rounded shadow">
<h3 className="mb-2 font-bold">{de.deleteGroup}</h3>
<select
className="border p-2 mb-2 w-full"
value={deleteDialog.groupName}
onChange={e => setDeleteDialog({ ...deleteDialog, groupName: e.target.value })}
>
<option value="">{de.selectGroup}</option>
{groups
.filter(g => g.headerText !== 'Nicht zugeordnet')
.map(g => (
<option key={g.keyField} value={g.headerText}>
{g.headerText}
</option>
))}
</select>
<p>{de.clientsMoved}</p>
{deleteDialog.groupName && (
<div className="bg-yellow-100 text-yellow-800 p-2 rounded mb-2 text-sm">
<strong>{de.warning}</strong> Möchten Sie die Gruppe <b>{deleteDialog.groupName}</b>{' '}
wirklich löschen?
</div>
)}
<div className="flex gap-2 mt-2">
<button
className="bg-red-500 text-white px-4 py-2 rounded"
<DialogComponent
visible={deleteDialog.open}
header={de.deleteGroup}
showCloseIcon={true}
close={() => setDeleteDialog({ open: false, groupName: '' })}
target="#dialog-target"
width="520px"
footerTemplate={() => (
<div className="flex gap-2 justify-end">
<ButtonComponent
cssClass="e-danger"
onClick={() => setShowDeleteConfirm(true)}
disabled={!deleteDialog.groupName}
>
{de.deleteGroup}
</button>
<button
className="bg-gray-300 px-4 py-2 rounded"
onClick={() => setDeleteDialog({ open: false, groupName: '' })}
</ButtonComponent>
<ButtonComponent onClick={() => setDeleteDialog({ open: false, groupName: '' })}>
{de.cancel}
</ButtonComponent>
</div>
)}
>
<div className="flex flex-col gap-3 mt-2">
<DropDownListComponent
placeholder={de.selectGroup}
dataSource={groups.filter(g => g.headerText !== 'Nicht zugeordnet').map(g => g.headerText)}
value={deleteDialog.groupName}
change={(e: DropDownChangeArgs) =>
setDeleteDialog({ ...deleteDialog, groupName: String(e.value ?? '') })
}
/>
<p className="text-sm text-gray-600">{de.clientsMoved}</p>
{deleteDialog.groupName && (
<div className="bg-yellow-100 text-yellow-800 p-2 rounded text-sm">
<strong>{de.warning}</strong> Möchten Sie die Gruppe <b>{deleteDialog.groupName}</b> wirklich löschen?
</div>
)}
</div>
</DialogComponent>
)}
{showDeleteConfirm && deleteDialog.groupName && (
<DialogComponent
width="380px"
header={de.confirmDelete}
visible={showDeleteConfirm}
showCloseIcon={true}
close={() => setShowDeleteConfirm(false)}
target="#dialog-target"
footerTemplate={() => (
<div className="flex gap-2 justify-end">
<ButtonComponent
cssClass="e-danger"
onClick={() => {
handleDeleteGroup(deleteDialog.groupName!);
setShowDeleteConfirm(false);
}}
>
{de.yesDelete}
</ButtonComponent>
<ButtonComponent
onClick={() => {
setShowDeleteConfirm(false);
setDeleteDialog({ open: false, groupName: '' });
}}
>
{de.cancel}
</button>
</ButtonComponent>
</div>
</div>
{showDeleteConfirm && deleteDialog.groupName && (
<DialogComponent
width="350px"
header={de.confirmDelete}
visible={showDeleteConfirm}
close={() => setShowDeleteConfirm(false)}
footerTemplate={() => (
<div className="flex gap-2 justify-end">
<button
className="bg-red-500 text-white px-4 py-2 rounded"
onClick={() => {
handleDeleteGroup(deleteDialog.groupName);
setShowDeleteConfirm(false);
}}
>
{de.yesDelete}
</button>
<button
className="bg-gray-300 px-4 py-2 rounded"
onClick={() => {
setShowDeleteConfirm(false);
setDeleteDialog({ open: false, groupName: '' });
}}
>
{de.cancel}
</button>
</div>
)}
>
<div>
Möchten Sie die Gruppe <b>{deleteDialog.groupName}</b> wirklich löschen?
<br />
<span className="text-sm text-gray-500">{de.clientsMoved}</span>
</div>
</DialogComponent>
)}
</div>
>
<div>
Möchten Sie die Gruppe <b>{deleteDialog.groupName}</b> wirklich löschen?
<br />
<span className="text-sm text-gray-500">{de.clientsMoved}</span>
</div>
</DialogComponent>
)}
</div>
);

98
dashboard/src/login.tsx Normal file
View File

@@ -0,0 +1,98 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from './useAuth';
export default function Login() {
const { login, loading, error, logout } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [message, setMessage] = useState<string | null>(null);
const isDev = import.meta.env.MODE !== 'production';
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setMessage(null);
try {
await login(username, password);
setMessage('Login erfolgreich');
// Redirect to dashboard after successful login
navigate('/');
} catch (err) {
setMessage(err instanceof Error ? err.message : 'Login fehlgeschlagen');
}
};
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<form onSubmit={handleSubmit} style={{ width: 360, padding: 24, border: '1px solid #ddd', borderRadius: 8, background: '#fff' }}>
<h2 style={{ marginTop: 0 }}>Anmeldung</h2>
{message && <div style={{ color: message.includes('erfolgreich') ? 'green' : 'crimson', marginBottom: 12 }}>{message}</div>}
{error && <div style={{ color: 'crimson', marginBottom: 12 }}>{error}</div>}
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', marginBottom: 4 }}>Benutzername</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
style={{ width: '100%', padding: 8 }}
autoFocus
/>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', marginBottom: 4 }}>Passwort</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
style={{ width: '100%', padding: 8 }}
/>
</div>
<button type="submit" disabled={loading} style={{ width: '100%', padding: 10 }}>
{loading ? 'Anmelden ...' : 'Anmelden'}
</button>
{isDev && (
<button
type="button"
onClick={async () => {
setMessage(null);
try {
const res = await fetch('/api/auth/dev-login-superadmin', {
method: 'POST',
credentials: 'include',
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Dev-Login fehlgeschlagen');
setMessage('Dev-Login erfolgreich (Superadmin)');
// Refresh the page/state; the RequireAuth will render the app
window.location.href = '/';
} catch (err) {
setMessage(err instanceof Error ? err.message : 'Dev-Login fehlgeschlagen');
}
}}
disabled={loading}
style={{ width: '100%', padding: 10, marginTop: 10 }}
>
Dev-Login (Superadmin)
</button>
)}
<button
type="button"
onClick={async () => {
try {
await logout();
setMessage('Abgemeldet.');
} catch {
// ignore
}
}}
style={{ width: '100%', padding: 10, marginTop: 10, background: '#f5f5f5' }}
>
Abmelden & zurück zur Anmeldung
</button>
</form>
</div>
);
}

41
dashboard/src/logout.tsx Normal file
View File

@@ -0,0 +1,41 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from './useAuth';
const Logout: React.FC = () => {
const navigate = useNavigate();
const { logout } = useAuth();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
(async () => {
try {
await logout();
} catch (err) {
if (mounted) {
const msg = err instanceof Error ? err.message : 'Logout fehlgeschlagen';
setError(msg);
}
} finally {
// Weiter zur Login-Seite, auch wenn Logout-Request fehlschlägt
navigate('/login', { replace: true });
}
})();
return () => {
mounted = false;
};
}, [logout, navigate]);
return (
<div className="flex items-center justify-center h-screen">
<div className="text-center">
<h2 className="text-2xl font-bold mb-4">Abmeldung</h2>
<p>{error ? `Hinweis: ${error}` : 'Sie werden abgemeldet …'}</p>
<p style={{ marginTop: 16 }}>Falls nichts passiert: <a href="/login">Zur Login-Seite</a></p>
</div>
</div>
);
};
export default Logout;

View File

@@ -2,13 +2,36 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
import { AuthProvider } from './useAuth';
import { registerLicense } from '@syncfusion/ej2-base';
import '@syncfusion/ej2-base/styles/material3.css';
import '@syncfusion/ej2-navigations/styles/material3.css';
import '@syncfusion/ej2-buttons/styles/material3.css';
import '@syncfusion/ej2-inputs/styles/material3.css';
import '@syncfusion/ej2-dropdowns/styles/material3.css';
import '@syncfusion/ej2-popups/styles/material3.css';
import '@syncfusion/ej2-kanban/styles/material3.css';
// Additional components used across the app
import '@syncfusion/ej2-grids/styles/material3.css';
import '@syncfusion/ej2-react-schedule/styles/material3.css';
import '@syncfusion/ej2-react-filemanager/styles/material3.css';
import '@syncfusion/ej2-notifications/styles/material3.css';
import '@syncfusion/ej2-layouts/styles/material3.css';
import '@syncfusion/ej2-lists/styles/material3.css';
import '@syncfusion/ej2-calendars/styles/material3.css';
import '@syncfusion/ej2-splitbuttons/styles/material3.css';
import '@syncfusion/ej2-icons/styles/material3.css';
import './theme-overrides.css';
// Setze hier deinen Lizenzschlüssel ein
registerLicense('ORg4AjUWIQA/Gnt3VVhhQlJDfV5AQmBIYVp/TGpJfl96cVxMZVVBJAtUQF1hTH5VdENiXX1dcHxUQWNVWkd2');
registerLicense(
'ORg4AjUWIQA/Gnt3VVhhQlJDfV5AQmBIYVp/TGpJfl96cVxMZVVBJAtUQF1hTH5VdENiXX1dcHxUQWNVWkd2'
);
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<AuthProvider>
<App />
</AuthProvider>
</StrictMode>
);

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useState, useRef, useMemo } from 'react';
import CustomMediaInfoPanel from './components/CustomMediaInfoPanel';
import {
FileManagerComponent,
@@ -7,52 +8,178 @@ import {
DetailsView,
Toolbar,
} from '@syncfusion/ej2-react-filemanager';
import { useAuth } from './useAuth';
const hostUrl = '/api/eventmedia/filemanager/'; // Dein Backend-Endpunkt für FileManager
interface MediaItem {
id: string;
file_path: string;
url: string;
description: string;
eventId?: string;
}
const Media: React.FC = () => {
const [mediaList, setMediaList] = useState<MediaItem[]>([]);
const [selectedMedia, setSelectedMedia] = useState<MediaItem | null>(null);
const { user } = useAuth();
const isSuperadmin = useMemo(() => user?.role === 'superadmin', [user]);
// State für die angezeigten Dateidetails
const [fileDetails] = useState<null | {
name: string;
size: number;
type: string;
dateModified: number;
description?: string | null;
}>(null);
// Ansicht: 'LargeIcons', 'Details'
const [viewMode, setViewMode] = useState<'LargeIcons' | 'Details'>('LargeIcons');
const fileManagerRef = useRef<FileManagerComponent | null>(null);
// Medien vom Server laden
useEffect(() => {
fetch('/api/eventmedia')
.then(res => res.json())
.then(setMediaList);
}, []);
// Hilfsfunktion für Datum in Browser-Zeitzone
function formatLocalDate(timestamp: number) {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
return date.toLocaleString('de-DE'); // Zeigt lokale Zeit des Browsers
}
// Ansicht umschalten, ohne Remount
React.useEffect(() => {
if (fileManagerRef.current) {
const element = fileManagerRef.current.element as HTMLElement & { ej2_instances?: unknown[] };
if (element && element.ej2_instances && element.ej2_instances[0]) {
// Typisiere Instanz als unknown, da kein offizieller Typ vorhanden
const instanz = element.ej2_instances[0] as { view: string; dataBind: () => void };
instanz.view = viewMode;
instanz.dataBind();
}
}
}, [viewMode]);
// Speichern von Metadaten/Event-Zuordnung
const handleSave = async (data: { title: string; description: string; eventId?: string }) => {
if (!selectedMedia) return;
await fetch(`/api/eventmedia/${selectedMedia.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
// Nach dem Speichern neu laden
const res = await fetch('/api/eventmedia');
setMediaList(await res.json());
type FileItem = { name: string; isFile: boolean };
type ReadSuccessArgs = { action: string; result?: { files?: FileItem[] } };
type FileOpenArgs = { fileDetails?: FileItem; cancel?: boolean };
// Hide "converted" for non-superadmins after data load
const handleSuccess = (args: ReadSuccessArgs) => {
if (isSuperadmin) return;
if (args && args.action === 'read' && args.result && Array.isArray(args.result.files)) {
args.result.files = args.result.files.filter((f: FileItem) => !(f.name === 'converted' && !f.isFile));
}
};
// Prevent opening the "converted" folder for non-superadmins
const handleFileOpen = (args: FileOpenArgs) => {
if (!isSuperadmin && args && args.fileDetails && args.fileDetails.name === 'converted' && !args.fileDetails.isFile) {
args.cancel = true;
}
};
return (
<div>
<h2 className="text-xl font-bold mb-4">Medien</h2>
{/* Ansicht-Umschalter */}
<div style={{ marginBottom: 12 }}>
<button
className={viewMode === 'LargeIcons' ? 'e-btn e-active' : 'e-btn'}
onClick={() => setViewMode('LargeIcons')}
style={{ marginRight: 8 }}
>
Icons
</button>
<button
className={viewMode === 'Details' ? 'e-btn e-active' : 'e-btn'}
onClick={() => setViewMode('Details')}
>
Details
</button>
</div>
{/* Debug-Ausgabe entfernt, da ReactNode erwartet wird */}
<FileManagerComponent
ref={fileManagerRef}
cssClass="e-bigger media-icons-xl"
success={handleSuccess}
fileOpen={handleFileOpen}
ajaxSettings={{
url: hostUrl + 'operations',
getImageUrl: hostUrl + 'get-image',
uploadUrl: hostUrl + 'upload',
downloadUrl: hostUrl + 'download',
}}
// Increase upload settings: default maxFileSize for Syncfusion FileManager is ~30_000_000 (30 MB).
// Set `maxFileSize` in bytes and `allowedExtensions` for video types you want to accept.
// We disable autoUpload so we can validate duration client-side before sending.
uploadSettings={{
maxFileSize: 1.5 * 1024 * 1024 * 1024, // 1.5 GB - enough for 10min Full HD video at high bitrate
allowedExtensions: '.pdf,.ppt,.pptx,.odp,.mp4,.webm,.ogg,.mov,.mkv,.avi,.wmv,.flv,.mpg,.mpeg,.jpg,.jpeg,.png,.gif,.bmp,.tiff,.svg',
autoUpload: false,
minFileSize: 0, // Allow all file sizes (no minimum)
// chunkSize can be added later once server supports chunk assembly
}}
// Validate video duration (max 10 minutes) before starting upload.
created={() => {
try {
const el = fileManagerRef.current?.element as any;
const inst = el && el.ej2_instances && el.ej2_instances[0];
const maxSeconds = 10 * 60; // 10 minutes
if (inst && inst.uploadObj) {
// Override the selected handler to validate files before upload
const originalSelected = inst.uploadObj.selected;
inst.uploadObj.selected = async (args: any) => {
const filesData = args && (args.filesData || args.files) ? (args.filesData || args.files) : [];
const tooLong: string[] = [];
// Helper to get native File object
const getRawFile = (fd: any) => fd && (fd.rawFile || fd.file || fd) as File;
const checks = Array.from(filesData).map((fd: any) => {
const file = getRawFile(fd);
if (!file) return Promise.resolve(true);
// Only check video MIME types or common extensions
if (!file.type.startsWith('video') && !/\.(mp4|webm|ogg|mov|mkv)$/i.test(file.name)) {
return Promise.resolve(true);
}
return new Promise<boolean>((resolve) => {
const url = URL.createObjectURL(file);
const video = document.createElement('video');
video.preload = 'metadata';
video.src = url;
const clean = () => {
try { URL.revokeObjectURL(url); } catch { /* noop */ }
};
video.onloadedmetadata = function () {
clean();
if (video.duration && video.duration <= maxSeconds) {
resolve(true);
} else {
tooLong.push(`${file.name} (${Math.round(video.duration||0)}s)`);
resolve(false);
}
};
video.onerror = function () {
clean();
// If metadata can't be read, allow upload and let server verify
resolve(true);
};
});
});
const results = await Promise.all(checks);
const allOk = results.every(Boolean);
if (!allOk) {
// Cancel the automatic upload and show error to user
args.cancel = true;
const msg = `Upload blocked: the following videos exceed ${maxSeconds} seconds:\n` + tooLong.join('\n');
// Use alert for now; replace with project's toast system if available
alert(msg);
return;
}
// All files OK — proceed with original selected handler if present,
// otherwise start upload programmatically
if (typeof originalSelected === 'function') {
try { originalSelected.call(inst.uploadObj, args); } catch { /* noop */ }
}
// If autoUpload is false we need to start upload manually
try {
inst.uploadObj.upload(args && (args.filesData || args.files));
} catch { /* ignore — uploader may handle starting itself */ }
};
}
} catch (e) {
// Non-fatal: if we can't hook uploader, uploads will behave normally
console.error('Could not attach video-duration hook to uploader', e);
}
}}
toolbarSettings={{
items: [
'NewFolder',
@@ -71,18 +198,27 @@ const Media: React.FC = () => {
layout: ['SortBy', 'Refresh', '|', 'View', 'Details'],
}}
allowMultiSelection={false}
view={viewMode}
detailsViewSettings={{
columns: [
{ field: 'name', headerText: 'Name', minWidth: '120', width: '200' },
{ field: 'size', headerText: 'Größe', minWidth: '80', width: '100' },
{
field: 'dateModified',
headerText: 'Upload-Datum',
minWidth: '120',
width: '180',
template: (data: { dateModified: number }) => formatLocalDate(data.dateModified),
},
{ field: 'type', headerText: 'Typ', minWidth: '80', width: '100' },
],
}}
menuClick={() => {}}
>
<Inject services={[NavigationPane, DetailsView, Toolbar]} />
</FileManagerComponent>
{selectedMedia && (
<CustomMediaInfoPanel
mediaId={selectedMedia.id}
title={selectedMedia.url}
description={selectedMedia.description}
eventId={selectedMedia.eventId}
onSave={handleSave}
/>
)}
{/* Details-Panel anzeigen, wenn Details verfügbar sind */}
{fileDetails && <CustomMediaInfoPanel {...fileDetails} />}
</div>
);
};

View File

@@ -0,0 +1,243 @@
import React, { useState, useEffect } from 'react';
import { PagerComponent } from '@syncfusion/ej2-react-grids';
interface ProgramInfo {
appName: string;
version: string;
copyright: string;
supportContact: string;
description: string;
techStack: Record<string, string>;
openSourceComponents: {
frontend: { name: string; license: string }[];
backend: { name: string; license: string }[];
};
buildInfo: {
buildDate: string;
commitId: string;
};
changelog: {
version: string;
date: string;
changes: string[];
}[];
}
const Programminfo: React.FC = () => {
const [info, setInfo] = useState<ProgramInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState<number>(1);
const pageSize = 5;
useEffect(() => {
let isMounted = true;
fetch('/program-info.json')
.then(res => {
if (!res.ok) throw new Error('Netzwerk-Antwort war nicht ok');
return res.json();
})
.then((data: ProgramInfo) => {
if (isMounted) setInfo(data);
})
.catch(err => {
console.error('Fehler beim Laden der Programminformationen:', err);
if (isMounted) setError('Informationen konnten nicht geladen werden.');
});
return () => {
isMounted = false;
};
}, []);
if (error) {
return (
<div>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, marginBottom: '1rem', color: '#dc2626' }}>Fehler</h2>
<p>{error}</p>
</div>
);
}
if (!info) {
return (
<div>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, marginBottom: '1rem' }}>Programminfo</h2>
<p>Lade Informationen...</p>
</div>
);
}
const monoFont = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<div>
<h2 style={{ fontSize: '1.75rem', fontWeight: 700, marginBottom: '0.5rem' }}>{info.appName}</h2>
<p style={{ color: '#4b5563' }}>{info.description}</p>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '2rem' }}>
{/* Allgemeine Infos & Build */}
<div style={{ flex: '1 1 360px', minWidth: '320px' }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-title">Allgemein</div>
</div>
</div>
<div className="e-card-content">
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<p>
<strong>Version:</strong> {info.version}
</p>
<p>
<strong>Copyright:</strong> {info.copyright}
</p>
<p>
<strong>Support:</strong>{' '}
<a href={`mailto:${info.supportContact}`} style={{ color: '#2563eb', textDecoration: 'none' }}>
{info.supportContact}
</a>
</p>
<hr style={{ margin: '1rem 0' }} />
<h4 style={{ fontWeight: 600 }}>Build-Informationen</h4>
<p>
<strong>Build-Datum:</strong> {new Date(info.buildInfo.buildDate).toLocaleString('de-DE')}
</p>
<p>
<strong>Commit-ID:</strong>{' '}
<span style={{ fontFamily: monoFont, fontSize: '0.875rem', background: '#f3f4f6', padding: '0.125rem 0.25rem', borderRadius: '0.25rem' }}>
{info.buildInfo.commitId}
</span>
</p>
</div>
</div>
</div>
</div>
{/* Technischer Stack */}
<div style={{ flex: '1 1 360px', minWidth: '320px' }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-title">Technologie-Stack</div>
</div>
</div>
<div className="e-card-content">
<ul style={{ paddingLeft: '1rem', margin: 0, display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{Object.entries(info.techStack).map(([key, value]) => (
<li key={key}>
<span style={{ fontWeight: 600, textTransform: 'capitalize' }}>{key}:</span> {value}
</li>
))}
</ul>
</div>
</div>
</div>
</div>
{/* Changelog */}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '0.5rem' }}>
<h3 style={{ fontSize: '1.25rem', fontWeight: 600, margin: 0 }}>Änderungsprotokoll (Changelog)</h3>
<div style={{ marginLeft: 'auto' }}>
<span style={{ color: '#6b7280', fontSize: '0.875rem' }}>
Insgesamt {info.changelog.length} Einträge
</span>
</div>
</div>
<div style={{ marginBottom: '0.75rem' }}>
<PagerComponent
totalRecordsCount={info.changelog.length}
pageSize={pageSize}
pageCount={5}
currentPage={currentPage}
click={(args: { currentPage: number }) => setCurrentPage(args.currentPage)}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{info.changelog
.slice((currentPage - 1) * pageSize, (currentPage - 1) * pageSize + pageSize)
.map(log => (
<div key={log.version} className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-title">
Version {log.version}{' '}
<span style={{ fontSize: '0.875rem', fontWeight: 400, color: '#6b7280' }}>
- {new Date(log.date).toLocaleDateString('de-DE')}
</span>
</div>
</div>
</div>
<div className="e-card-content">
<ul style={{ color: '#374151', paddingLeft: '1rem', margin: 0, display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
{log.changes.map((change, index) => (
<li key={index}>{change}</li>
))}
</ul>
</div>
</div>
))}
</div>
<div style={{ marginTop: '0.75rem' }}>
<PagerComponent
totalRecordsCount={info.changelog.length}
pageSize={pageSize}
pageCount={5}
currentPage={currentPage}
click={(args: { currentPage: number }) => setCurrentPage(args.currentPage)}
/>
</div>
</div>
{/* Open Source Komponenten */}
<div>
<h3 style={{ fontSize: '1.25rem', fontWeight: 600, marginBottom: '1rem' }}>Verwendete Open-Source-Komponenten</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '2rem' }}>
{info.openSourceComponents.frontend && (
<div style={{ flex: '1 1 360px', minWidth: '320px' }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-title">Frontend</div>
</div>
</div>
<div className="e-card-content">
<ul style={{ paddingLeft: '1rem', margin: 0, display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
{info.openSourceComponents.frontend.map(item => (
<li key={item.name}>
{item.name} ({item.license}-Lizenz)
</li>
))}
</ul>
</div>
</div>
</div>
)}
{info.openSourceComponents.backend && (
<div style={{ flex: '1 1 360px', minWidth: '320px' }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-title">Backend</div>
</div>
</div>
<div className="e-card-content">
<ul style={{ paddingLeft: '1rem', margin: 0, display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
{info.openSourceComponents.backend.map(item => (
<li key={item.name}>
{item.name} ({item.license}-Lizenz)
</li>
))}
</ul>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default Programminfo;

View File

@@ -0,0 +1,177 @@
/* Ressourcen - Timeline Schedule Styles */
.ressourcen-container {
padding: 20px;
background-color: #f5f5f5;
min-height: 100vh;
}
.ressourcen-title {
font-size: 28px;
font-weight: 600;
margin-bottom: 20px;
color: #333;
}
.ressourcen-controls {
display: flex;
flex-wrap: wrap;
gap: 15px;
margin-bottom: 30px;
align-items: center;
background-color: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 4px rgb(0 0 0 / 10%);
}
.ressourcen-control-group {
display: flex;
align-items: center;
gap: 10px;
}
.ressourcen-label {
font-weight: 500;
color: #555;
white-space: nowrap;
}
.ressourcen-button-group {
display: flex;
gap: 8px;
}
.ressourcen-button {
border-radius: 4px !important;
font-weight: 500;
}
/* Group Order Panel */
.ressourcen-order-panel {
background: white;
padding: 15px;
margin-bottom: 15px;
border-radius: 8px;
box-shadow: 0 2px 4px rgb(0 0 0 / 10%);
}
.ressourcen-order-header {
width: 100%;
}
.ressourcen-order-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 250px;
overflow-y: auto;
padding: 8px;
background-color: #f9f9f9;
border-radius: 4px;
}
.ressourcen-order-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
background: white;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 13px;
}
.ressourcen-order-position {
font-weight: 600;
color: #666;
min-width: 24px;
text-align: right;
}
.ressourcen-order-name {
flex: 1;
color: #333;
}
.ressourcen-order-buttons {
display: flex;
gap: 4px;
}
.ressourcen-order-buttons .e-btn {
min-width: 32px !important;
}
.ressourcen-loading {
text-align: center;
padding: 40px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgb(0 0 0 / 10%);
}
.ressourcen-loading p {
font-size: 16px;
color: #666;
}
.ressourcen-timeline-wrapper {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Scheduler Timeline Styling */
.e-schedule {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
}
.e-schedule .e-timeline-view {
border: none;
}
.e-schedule .e-date-header {
background-color: #f9f9f9;
border-bottom: 1px solid #e0e0e0;
}
.e-schedule .e-header-cells {
font-weight: 600;
color: #333;
}
.ressourcen-timeline-wrapper .e-schedule {
flex: 1;
height: 100% !important;
}
.e-schedule .e-work-cells {
background-color: #fafafa;
border-color: #f0f0f0;
}
/* Set compact row height */
.e-schedule .e-timeline-view .e-content-wrap table tbody tr {
height: 65px;
}
.e-schedule .e-timeline-view .e-content-wrap .e-work-cells {
height: 65px;
}
/* Event bar styling */
.e-schedule .e-appointment {
border-radius: 4px;
color: white;
line-height: normal;
}
.e-schedule .e-appointment .e-subject {
font-size: 12px;
font-weight: 500;
}

View File

@@ -1,8 +1,356 @@
import React from 'react';
const Ressourcen: React.FC = () => (
<div>
<h2 className="text-xl font-bold mb-4">Ressourcen</h2>
<p>Willkommen im Infoscreen-Management Ressourcen.</p>
</div>
);
import React, { useEffect, useState } from 'react';
import {
ScheduleComponent,
ViewsDirective,
ViewDirective,
Inject,
TimelineViews,
Resize,
DragAndDrop,
ResourcesDirective,
ResourceDirective,
} from '@syncfusion/ej2-react-schedule';
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
import { fetchGroupsWithClients, type Group } from './apiClients';
import { fetchEvents } from './apiEvents';
import { getGroupColor } from './groupColors';
import './ressourcen.css';
interface ScheduleEvent {
Id: number;
Subject: string;
StartTime: Date;
EndTime: Date;
ResourceId: number;
EventType?: string;
}
type TimelineView = 'day' | 'week';
const Ressourcen: React.FC = () => {
const [scheduleData, setScheduleData] = useState<ScheduleEvent[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const [groupOrder, setGroupOrder] = useState<number[]>([]);
const [showOrderPanel, setShowOrderPanel] = useState<boolean>(false);
const [timelineView] = useState<TimelineView>('day');
const [viewDate] = useState<Date>(() => {
const now = new Date();
now.setHours(0, 0, 0, 0);
return now;
});
const [loading, setLoading] = useState<boolean>(false);
const scheduleRef = React.useRef<ScheduleComponent>(null);
// Calculate dynamic height based on number of groups
const calculatedHeight = React.useMemo(() => {
const rowHeight = 65; // px per row
const headerHeight = 100; // approx header height
const totalHeight = groups.length * rowHeight + headerHeight;
return `${totalHeight}px`;
}, [groups.length]);
// Load groups on mount
useEffect(() => {
const loadGroups = async () => {
try {
console.log('[Ressourcen] Loading groups...');
const fetchedGroups = await fetchGroupsWithClients();
console.log('[Ressourcen] Fetched groups:', fetchedGroups);
// Filter out "Nicht zugeordnet" but show all other groups even if empty
const filteredGroups = fetchedGroups.filter(
(group) => group.name !== 'Nicht zugeordnet'
);
console.log('[Ressourcen] Filtered groups:', filteredGroups);
setGroups(filteredGroups);
} catch (error) {
console.error('Fehler beim Laden der Gruppen:', error);
}
};
loadGroups();
}, []);
// Helper: Parse ISO date string
const parseUTCDate = React.useCallback((dateStr: string): Date => {
const utcStr = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z';
return new Date(utcStr);
}, []);
// Calculate date range based on view
const getDateRange = React.useCallback((): { start: Date; end: Date } => {
const start = new Date(viewDate);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
if (timelineView === 'day') {
end.setHours(23, 59, 59, 999);
} else if (timelineView === 'week') {
end.setDate(start.getDate() + 6);
end.setHours(23, 59, 59, 999);
}
return { start, end };
}, [viewDate, timelineView]);
// Load events for all groups
useEffect(() => {
if (groups.length === 0) {
console.log('[Ressourcen] No groups to load events for');
setScheduleData([]);
return;
}
const loadEventsForAllGroups = async () => {
setLoading(true);
console.log('[Ressourcen] Loading events for', groups.length, 'groups');
try {
const { start, end } = getDateRange();
const events: ScheduleEvent[] = [];
let eventId = 1;
// Create events for each group
for (const group of groups) {
try {
console.log(`[Ressourcen] Fetching events for group "${group.name}" (ID: ${group.id})`);
const apiEvents = await fetchEvents(group.id.toString(), false, {
start,
end,
});
console.log(`[Ressourcen] Got ${apiEvents?.length || 0} events for group "${group.name}"`);
if (Array.isArray(apiEvents) && apiEvents.length > 0) {
const event = apiEvents[0];
const eventTitle = event.subject || event.title || 'Unnamed Event';
const eventType = event.type || event.event_type || 'other';
const eventStart = event.startTime || event.start;
const eventEnd = event.endTime || event.end;
if (eventStart && eventEnd) {
const parsedStart = parseUTCDate(eventStart);
const parsedEnd = parseUTCDate(eventEnd);
// Capitalize first letter of event type
const formattedType = eventType.charAt(0).toUpperCase() + eventType.slice(1);
events.push({
Id: eventId++,
Subject: `${formattedType} - ${eventTitle}`,
StartTime: parsedStart,
EndTime: parsedEnd,
ResourceId: group.id,
EventType: eventType,
});
console.log(`[Ressourcen] Group "${group.name}" has event: ${eventTitle}`);
}
}
} catch (error) {
console.error(`Fehler beim Laden von Ereignissen für Gruppe ${group.name}:`, error);
}
}
console.log('[Ressourcen] Final events:', events);
setScheduleData(events);
} finally {
setLoading(false);
}
};
loadEventsForAllGroups();
}, [groups, timelineView, viewDate, parseUTCDate, getDateRange]);
// Load saved group order from backend on mount
useEffect(() => {
const loadGroupOrder = async () => {
try {
console.log('[Ressourcen] Loading saved group order from backend...');
const response = await fetch('/api/groups/order');
if (response.ok) {
const data = await response.json();
console.log('[Ressourcen] Retrieved group order:', data);
if (data.order && Array.isArray(data.order)) {
// Filter order to only include IDs that exist in current groups
const existingGroupIds = groups.map(g => g.id);
const validOrder = data.order.filter((id: number) => existingGroupIds.includes(id));
// Add any missing group IDs that aren't in the saved order
const missingIds = existingGroupIds.filter(id => !validOrder.includes(id));
const finalOrder = [...validOrder, ...missingIds];
console.log('[Ressourcen] Synced order:', finalOrder);
setGroupOrder(finalOrder);
} else {
// No saved order, use default (current group order)
setGroupOrder(groups.map(g => g.id));
}
} else {
console.log('[Ressourcen] No saved order found, using default');
setGroupOrder(groups.map(g => g.id));
}
} catch (error) {
console.error('[Ressourcen] Error loading group order:', error);
// Fall back to default order
setGroupOrder(groups.map(g => g.id));
}
};
if (groups.length > 0 && groupOrder.length === 0) {
loadGroupOrder();
}
}, [groups, groupOrder.length]);
// Move group up in order
const moveGroupUp = (groupId: number) => {
const index = groupOrder.indexOf(groupId);
if (index > 0) {
const newOrder = [...groupOrder];
[newOrder[index - 1], newOrder[index]] = [newOrder[index], newOrder[index - 1]];
setGroupOrder(newOrder);
}
};
// Move group down in order
const moveGroupDown = (groupId: number) => {
const index = groupOrder.indexOf(groupId);
if (index < groupOrder.length - 1) {
const newOrder = [...groupOrder];
[newOrder[index], newOrder[index + 1]] = [newOrder[index + 1], newOrder[index]];
setGroupOrder(newOrder);
}
};
// Save group order to backend
const saveGroupOrder = async () => {
try {
console.log('[Ressourcen] Saving group order:', groupOrder);
const response = await fetch('/api/groups/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order: groupOrder }),
});
if (!response.ok) throw new Error('Failed to save group order');
console.log('[Ressourcen] Group order saved successfully');
} catch (error) {
console.error('Fehler beim Speichern der Reihenfolge:', error);
}
};
// Get sorted groups based on current order
const sortedGroups = React.useMemo(() => {
if (groupOrder.length === 0) return groups;
// Map order to actual groups
const ordered = groupOrder
.map(id => groups.find(g => g.id === id))
.filter((g): g is Group => g !== undefined);
// Add any groups not in the order (new groups)
const orderedIds = new Set(ordered.map(g => g.id));
const unordered = groups.filter(g => !orderedIds.has(g.id));
return [...ordered, ...unordered];
}, [groups, groupOrder]);
return (
<div className="ressourcen-container">
<h1 className="ressourcen-title">📊 Ressourcen - Übersicht</h1>
<div style={{ marginBottom: '15px' }}>
<ButtonComponent
cssClass={showOrderPanel ? 'e-success' : 'e-outline'}
onClick={() => setShowOrderPanel(!showOrderPanel)}
>
{showOrderPanel ? '✓ Reihenfolge' : 'Reihenfolge ändern'}
</ButtonComponent>
</div>
{/* Group Order Control Panel */}
{showOrderPanel && (
<div className="ressourcen-order-panel">
<div className="ressourcen-order-header">
<h3 style={{ margin: '0 0 12px 0', fontSize: '14px', fontWeight: 600 }}>
📋 Reihenfolge der Gruppen
</h3>
<div className="ressourcen-order-list">
{sortedGroups.map((group, index) => (
<div key={group.id} className="ressourcen-order-item">
<span className="ressourcen-order-position">{index + 1}.</span>
<span className="ressourcen-order-name">{group.name}</span>
<div className="ressourcen-order-buttons">
<ButtonComponent
cssClass="e-outline e-small"
onClick={() => moveGroupUp(group.id)}
disabled={index === 0}
title="Nach oben"
style={{ padding: '4px 8px', minWidth: '32px' }}
>
</ButtonComponent>
<ButtonComponent
cssClass="e-outline e-small"
onClick={() => moveGroupDown(group.id)}
disabled={index === sortedGroups.length - 1}
title="Nach unten"
style={{ padding: '4px 8px', minWidth: '32px' }}
>
</ButtonComponent>
</div>
</div>
))}
</div>
<ButtonComponent
cssClass="e-success"
onClick={saveGroupOrder}
style={{ marginTop: '12px', width: '100%' }}
>
💾 Reihenfolge speichern
</ButtonComponent>
</div>
</div>
)}
{/* Timeline Schedule */}
{loading ? (
<div className="ressourcen-loading">
<p>Wird geladen...</p>
</div>
) : (
<div className="ressourcen-timeline-wrapper">
<ScheduleComponent
ref={scheduleRef}
height={calculatedHeight}
width="100%"
eventSettings={{ dataSource: scheduleData }}
selectedDate={viewDate}
currentView={timelineView === 'day' ? 'TimelineDay' : 'TimelineWeek'}
group={{ resources: ['Groups'], allowGroupEdit: false }}
timeScale={{ interval: 60, slotCount: 1 }}
rowAutoHeight={false}
>
<ViewsDirective>
<ViewDirective option="TimelineDay" displayName="Tag"></ViewDirective>
<ViewDirective option="TimelineWeek" displayName="Woche"></ViewDirective>
</ViewsDirective>
<ResourcesDirective>
<ResourceDirective
field="ResourceId"
title="Gruppe"
name="Groups"
allowMultiple={false}
dataSource={sortedGroups.map((g) => ({
text: g.name,
id: g.id,
color: getGroupColor(g.id.toString(), groups.map(grp => ({ id: grp.id.toString() }))),
}))}
textField="text"
idField="id"
colorField="color"
/>
</ResourcesDirective>
<Inject services={[TimelineViews, Resize, DragAndDrop]} />
</ScheduleComponent>
</div>
)}
</div>
);
};
export default Ressourcen;

954
dashboard/src/settings.tsx Normal file
View File

@@ -0,0 +1,954 @@
import React from 'react';
import { TabComponent, TabItemDirective, TabItemsDirective } from '@syncfusion/ej2-react-navigations';
import { NumericTextBoxComponent, TextBoxComponent } from '@syncfusion/ej2-react-inputs';
import { ButtonComponent, CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
import { ToastComponent } from '@syncfusion/ej2-react-notifications';
import { listHolidays, uploadHolidaysCsv, type Holiday } from './apiHolidays';
import { getSupplementTableSettings, updateSupplementTableSettings, getHolidayBannerSetting, updateHolidayBannerSetting } from './apiSystemSettings';
import { useAuth } from './useAuth';
import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns';
import { listAcademicPeriods, getActiveAcademicPeriod, setActiveAcademicPeriod, type AcademicPeriod } from './apiAcademicPeriods';
import { Link } from 'react-router-dom';
// Minimal event type for Syncfusion Tab 'selected' callback
type TabSelectedEvent = { selectedIndex?: number };
const Einstellungen: React.FC = () => {
// Presentation settings state
const [presentationInterval, setPresentationInterval] = React.useState(10);
const [presentationPageProgress, setPresentationPageProgress] = React.useState(true);
const [presentationAutoProgress, setPresentationAutoProgress] = React.useState(true);
const [presentationBusy, setPresentationBusy] = React.useState(false);
// Load settings from backend
const loadPresentationSettings = React.useCallback(async () => {
try {
const keys = [
'presentation_interval',
'presentation_page_progress',
'presentation_auto_progress',
];
const results = await Promise.all(keys.map(k => import('./apiSystemSettings').then(m => m.getSetting(k))));
setPresentationInterval(Number(results[0].value) || 10);
setPresentationPageProgress(results[1].value === 'true');
setPresentationAutoProgress(results[2].value === 'true');
} catch {
showToast('Fehler beim Laden der Präsentations-Einstellungen', 'e-toast-danger');
}
}, []);
React.useEffect(() => {
loadPresentationSettings();
}, [loadPresentationSettings]);
const onSavePresentationSettings = async () => {
setPresentationBusy(true);
try {
const api = await import('./apiSystemSettings');
await Promise.all([
api.updateSetting('presentation_interval', String(presentationInterval)),
api.updateSetting('presentation_page_progress', presentationPageProgress ? 'true' : 'false'),
api.updateSetting('presentation_auto_progress', presentationAutoProgress ? 'true' : 'false'),
]);
showToast('Präsentations-Einstellungen gespeichert', 'e-toast-success');
} catch {
showToast('Fehler beim Speichern der Präsentations-Einstellungen', 'e-toast-danger');
} finally {
setPresentationBusy(false);
}
};
const { user } = useAuth();
const toastRef = React.useRef<ToastComponent>(null);
// Holidays state
const [file, setFile] = React.useState<File | null>(null);
const [busy, setBusy] = React.useState(false);
const [message, setMessage] = React.useState<string | null>(null);
const [holidays, setHolidays] = React.useState<Holiday[]>([]);
const [periods, setPeriods] = React.useState<AcademicPeriod[]>([]);
const [activePeriodId, setActivePeriodId] = React.useState<number | null>(null);
const periodOptions = React.useMemo(() =>
periods.map(p => ({ id: p.id, name: p.display_name || p.name })),
[periods]
);
// Supplement table state
const [supplementUrl, setSupplementUrl] = React.useState('');
const [supplementEnabled, setSupplementEnabled] = React.useState(false);
const [supplementBusy, setSupplementBusy] = React.useState(false);
// Holiday banner state
const [holidayBannerEnabled, setHolidayBannerEnabled] = React.useState(true);
const [holidayBannerBusy, setHolidayBannerBusy] = React.useState(false);
// Video defaults state (Admin+)
const [videoAutoplay, setVideoAutoplay] = React.useState<boolean>(true);
const [videoLoop, setVideoLoop] = React.useState<boolean>(true);
const [videoVolume, setVideoVolume] = React.useState<number>(0.8);
const [videoMuted, setVideoMuted] = React.useState<boolean>(false);
const [videoBusy, setVideoBusy] = React.useState<boolean>(false);
// Organization name state (Superadmin only)
const [organizationName, setOrganizationName] = React.useState<string>('');
const [organizationBusy, setOrganizationBusy] = React.useState<boolean>(false);
// Advanced options: Scheduler refresh interval (Superadmin)
const [refreshSeconds, setRefreshSeconds] = React.useState<number>(0);
const [refreshBusy, setRefreshBusy] = React.useState<boolean>(false);
const showToast = (content: string, cssClass: string = 'e-toast-success') => {
if (toastRef.current) {
toastRef.current.show({
content,
cssClass,
timeOut: 3000,
});
}
};
const refresh = React.useCallback(async () => {
try {
const data = await listHolidays();
setHolidays(data.holidays);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Ferien';
setMessage(msg);
showToast(msg, 'e-toast-danger');
}
}, []);
const loadSupplementSettings = React.useCallback(async () => {
try {
const data = await getSupplementTableSettings();
setSupplementUrl(data.url || '');
setSupplementEnabled(data.enabled || false);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Vertretungsplan-Einstellungen';
showToast(msg, 'e-toast-danger');
}
}, []);
const loadHolidayBannerSetting = React.useCallback(async () => {
try {
const data = await getHolidayBannerSetting();
setHolidayBannerEnabled(data.enabled);
} catch (e) {
console.error('Fehler beim Laden der Ferienbanner-Einstellung:', e);
}
}, []);
// Load video default settings (with fallbacks)
const loadVideoSettings = React.useCallback(async () => {
try {
const api = await import('./apiSystemSettings');
const keys = ['video_autoplay', 'video_loop', 'video_volume', 'video_muted'] as const;
const [autoplay, loop, volume, muted] = await Promise.all(
keys.map(k => api.getSetting(k).catch(() => ({ value: null } as { value: string | null })))
);
setVideoAutoplay(autoplay.value == null ? true : autoplay.value === 'true');
setVideoLoop(loop.value == null ? true : loop.value === 'true');
const volParsed = volume.value == null ? 0.8 : parseFloat(String(volume.value));
setVideoVolume(Number.isFinite(volParsed) ? volParsed : 0.8);
setVideoMuted(muted.value == null ? false : muted.value === 'true');
} catch (e) {
// Fallback to defaults on any error
setVideoAutoplay(true);
setVideoLoop(true);
setVideoVolume(0.8);
setVideoMuted(false);
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Video-Standards';
showToast(msg, 'e-toast-warning');
}
}, []);
// Load organization name (superadmin only, but endpoint is public)
const loadOrganizationName = React.useCallback(async () => {
try {
const api = await import('./apiSystemSettings');
const data = await api.getOrganizationName();
setOrganizationName(data.name || '');
} catch (e) {
console.error('Fehler beim Laden des Organisationsnamens:', e);
}
}, []);
// Load scheduler refresh seconds (superadmin)
const loadRefreshSeconds = React.useCallback(async () => {
try {
const api = await import('./apiSystemSettings');
const setting = await api.getSetting('refresh_seconds');
const parsed = Number(setting.value ?? 0);
setRefreshSeconds(Number.isFinite(parsed) ? parsed : 0);
} catch {
// Default to 0 if not present
setRefreshSeconds(0);
}
}, []);
const loadAcademicPeriods = React.useCallback(async () => {
try {
const [list, active] = await Promise.all([
listAcademicPeriods(),
getActiveAcademicPeriod(),
]);
setPeriods(list);
setActivePeriodId(active ? active.id : null);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Schuljahre/Perioden';
showToast(msg, 'e-toast-danger');
}
}, []);
React.useEffect(() => {
refresh();
loadHolidayBannerSetting(); // Everyone can see this
if (user) {
// Academic periods for all users
loadAcademicPeriods();
// System settings only for admin/superadmin (will render only if allowed)
if (['admin', 'superadmin'].includes(user.role)) {
loadSupplementSettings();
loadPresentationSettings();
loadVideoSettings();
}
// Organization name only for superadmin
if (user.role === 'superadmin') {
loadOrganizationName();
loadRefreshSeconds();
}
}
}, [refresh, loadSupplementSettings, loadAcademicPeriods, loadPresentationSettings, loadVideoSettings, loadHolidayBannerSetting, loadOrganizationName, loadRefreshSeconds, user]);
const onUpload = async () => {
if (!file) return;
setBusy(true);
setMessage(null);
try {
const res = await uploadHolidaysCsv(file);
const msg = `Import erfolgreich: ${res.inserted} neu, ${res.updated} aktualisiert.`;
setMessage(msg);
showToast(msg, 'e-toast-success');
await refresh();
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Import.';
setMessage(msg);
showToast(msg, 'e-toast-danger');
} finally {
setBusy(false);
}
};
const onSaveSupplementSettings = async () => {
setSupplementBusy(true);
try {
await updateSupplementTableSettings(supplementUrl, supplementEnabled);
showToast('Vertretungsplan-Einstellungen erfolgreich gespeichert', 'e-toast-success');
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Speichern der Einstellungen';
showToast(msg, 'e-toast-danger');
} finally {
setSupplementBusy(false);
}
};
const onTestSupplementUrl = () => {
if (supplementUrl) {
window.open(supplementUrl, '_blank');
} else {
showToast('Bitte geben Sie zuerst eine URL ein', 'e-toast-warning');
}
};
const onSaveHolidayBannerSetting = async () => {
setHolidayBannerBusy(true);
try {
await updateHolidayBannerSetting(holidayBannerEnabled);
showToast('Ferienbanner-Einstellung gespeichert', 'e-toast-success');
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Speichern';
showToast(msg, 'e-toast-danger');
} finally {
setHolidayBannerBusy(false);
}
};
const onSaveVideoSettings = async () => {
setVideoBusy(true);
try {
const api = await import('./apiSystemSettings');
await Promise.all([
api.updateSetting('video_autoplay', videoAutoplay ? 'true' : 'false'),
api.updateSetting('video_loop', videoLoop ? 'true' : 'false'),
api.updateSetting('video_volume', String(videoVolume)),
api.updateSetting('video_muted', videoMuted ? 'true' : 'false'),
]);
showToast('Video-Standardeinstellungen gespeichert', 'e-toast-success');
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Speichern der Video-Standards';
showToast(msg, 'e-toast-danger');
} finally {
setVideoBusy(false);
}
};
const onSaveOrganizationName = async () => {
setOrganizationBusy(true);
try {
const api = await import('./apiSystemSettings');
await api.updateOrganizationName(organizationName);
showToast('Organisationsname gespeichert', 'e-toast-success');
// Trigger a custom event to notify App.tsx to refresh the header
window.dispatchEvent(new Event('organizationNameUpdated'));
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Speichern des Organisationsnamens';
showToast(msg, 'e-toast-danger');
} finally {
setOrganizationBusy(false);
}
};
const onSaveRefreshSeconds = async () => {
setRefreshBusy(true);
try {
const api = await import('./apiSystemSettings');
await api.updateSetting('refresh_seconds', String(refreshSeconds));
showToast('Scheduler-Refresh-Intervall gespeichert', 'e-toast-success');
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Speichern des Intervalls';
showToast(msg, 'e-toast-danger');
} finally {
setRefreshBusy(false);
}
};
// Determine which tabs to show based on role
const isAdmin = !!(user && ['admin', 'superadmin'].includes(user.role));
const isSuperadmin = !!(user && user.role === 'superadmin');
// Preserve selected nested-tab indices to avoid resets on parent re-render
const [academicTabIndex, setAcademicTabIndex] = React.useState(0);
const [displayTabIndex, setDisplayTabIndex] = React.useState(0);
const [mediaTabIndex, setMediaTabIndex] = React.useState(0);
const [eventsTabIndex, setEventsTabIndex] = React.useState(0);
const [usersTabIndex, setUsersTabIndex] = React.useState(0);
const [systemTabIndex, setSystemTabIndex] = React.useState(0);
// ---------- Leaf content functions (second-level tabs) ----------
// Academic Calendar
// (Old separate Import/List tab contents removed in favor of combined tab)
// Combined Import + List tab content
const HolidaysImportAndListContent = () => (
<div style={{ padding: 20 }}>
{/* Import Card */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Schulferien importieren</div>
</div>
</div>
<div className="e-card-content">
<p style={{ marginBottom: 12, fontSize: '14px', color: '#666' }}>
Unterstützte Formate:
<br /> CSV mit Kopfzeile: <code>name</code>, <code>start_date</code>, <code>end_date</code>, optional <code>region</code>
<br /> TXT/CSV ohne Kopfzeile mit Spalten: interner Name, <strong>Name</strong>, <strong>Start (YYYYMMDD)</strong>, <strong>Ende (YYYYMMDD)</strong>, optional interne Info (ignoriert)
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<input type="file" accept=".csv,text/csv,.txt,text/plain" onChange={e => setFile(e.target.files?.[0] ?? null)} />
<ButtonComponent cssClass="e-primary" onClick={onUpload} disabled={!file || busy}>
{busy ? 'Importiere…' : 'CSV/TXT importieren'}
</ButtonComponent>
</div>
{message && <div style={{ marginTop: 8, fontSize: '14px' }}>{message}</div>}
</div>
</div>
{/* List Card */}
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Importierte Ferien</div>
</div>
</div>
<div className="e-card-content">
{holidays.length === 0 ? (
<div style={{ fontSize: '14px', color: '#666' }}>Keine Einträge vorhanden.</div>
) : (
<ul style={{ fontSize: '14px', listStyle: 'disc', paddingLeft: 24 }}>
{holidays.slice(0, 20).map(h => (
<li key={h.id}>
{h.name}: {h.start_date} {h.end_date}
{h.region ? ` (${h.region})` : ''}
</li>
))}
</ul>
)}
</div>
</div>
{/* Dashboard Display Settings Card */}
<div className="e-card" style={{ marginTop: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Dashboard-Anzeige</div>
</div>
</div>
<div className="e-card-content">
<div style={{ marginBottom: 16 }}>
<CheckBoxComponent
label="Ferienstatus-Banner auf Dashboard anzeigen"
checked={holidayBannerEnabled}
change={(e) => setHolidayBannerEnabled(e.checked || false)}
/>
<div style={{ fontSize: '12px', color: '#666', marginTop: 4, marginLeft: 24 }}>
Zeigt eine Information an, ob ein Ferienplan für die aktive Periode importiert wurde.
</div>
</div>
<ButtonComponent
cssClass="e-primary"
onClick={onSaveHolidayBannerSetting}
disabled={holidayBannerBusy}
>
{holidayBannerBusy ? 'Speichere…' : 'Einstellung speichern'}
</ButtonComponent>
</div>
</div>
</div>
);
const AcademicPeriodsContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Akademische Perioden</div>
</div>
</div>
<div className="e-card-content">
{periods.length === 0 ? (
<div style={{ fontSize: '14px', color: '#666' }}>Keine Perioden gefunden.</div>
) : (
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ minWidth: 260 }}>
<DropDownListComponent
dataSource={periodOptions}
fields={{ text: 'name', value: 'id' }}
value={activePeriodId ?? undefined}
change={(e) => setActivePeriodId(Number(e.value))}
placeholder="Aktive Periode wählen"
popupHeight="250px"
/>
</div>
<ButtonComponent
cssClass="e-primary"
disabled={activePeriodId == null}
onClick={async () => {
if (activePeriodId == null) return;
try {
const p = await setActiveAcademicPeriod(activePeriodId);
showToast(`Aktive Periode gesetzt: ${p.display_name || p.name}`, 'e-toast-success');
await loadAcademicPeriods();
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Setzen der aktiven Periode';
showToast(msg, 'e-toast-danger');
}
}}
>
Als aktiv setzen
</ButtonComponent>
</div>
)}
</div>
</div>
</div>
);
// Display & Clients
const DisplayDefaultsContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Standard-Einstellungen</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Anzeige-Defaults (z. B. Heartbeat-Timeout, Screenshot-Aufbewahrung, Standard-Gruppe).
</div>
</div>
</div>
);
const ClientsConfigContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Client-Konfiguration</div>
</div>
</div>
<div className="e-card-content">
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Link to="/clients"><ButtonComponent>Infoscreen-Clients öffnen</ButtonComponent></Link>
<Link to="/infoscr_groups"><ButtonComponent>Raumgruppen öffnen</ButtonComponent></Link>
</div>
</div>
</div>
</div>
);
const UploadSettingsContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Upload-Einstellungen</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Upload-Limits, erlaubte Dateitypen und Standardspeicherorte.
</div>
</div>
</div>
);
const ConversionStatusContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Konvertierungsstatus</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter Konversionsstatus-Übersicht kann hier integriert werden (siehe API /api/conversions/...).
</div>
</div>
</div>
);
// Events
const WebUntisSettingsContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">WebUntis / Vertretungsplan</div>
</div>
</div>
<div className="e-card-content">
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
Vertretungsplan URL
</label>
<TextBoxComponent
placeholder="https://example.com/vertretungsplan"
value={supplementUrl}
change={(e) => setSupplementUrl(e.value || '')}
cssClass="e-outline"
width="100%"
/>
<div style={{ fontSize: '12px', color: '#666', marginTop: 4 }}>
Diese URL wird verwendet, um die Stundenplan-Änderungstabelle (Vertretungsplan) anzuzeigen.
</div>
</div>
<div style={{ marginBottom: 16 }}>
<CheckBoxComponent
label="Vertretungsplan aktiviert"
checked={supplementEnabled}
change={(e) => setSupplementEnabled(e.checked || false)}
/>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<ButtonComponent
cssClass="e-primary"
onClick={onSaveSupplementSettings}
disabled={supplementBusy}
>
{supplementBusy ? 'Speichere…' : 'Einstellungen speichern'}
</ButtonComponent>
<ButtonComponent
cssClass="e-outline"
onClick={onTestSupplementUrl}
disabled={!supplementUrl}
>
Vorschau öffnen
</ButtonComponent>
</div>
</div>
</div>
</div>
);
const PresentationsDefaultsContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Präsentationen</div>
</div>
</div>
<div className="e-card-content" style={{ fontSize: 14 }}>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
Slide-Show Intervall (Sekunden)
</label>
<NumericTextBoxComponent
min={1}
max={600}
step={1}
value={presentationInterval}
format="n0"
change={(e: { value: number }) => setPresentationInterval(Number(e.value) || 10)}
placeholder="Intervall in Sekunden"
width="120px"
/>
</div>
<div style={{ marginBottom: 16 }}>
<CheckBoxComponent
label="Seitenfortschritt anzeigen (Fortschrittsbalken)"
checked={presentationPageProgress}
change={e => setPresentationPageProgress(e.checked || false)}
/>
</div>
<div style={{ marginBottom: 16 }}>
<CheckBoxComponent
label="Präsentationsfortschritt anzeigen (Fortschrittsbalken)"
checked={presentationAutoProgress}
change={e => setPresentationAutoProgress(e.checked || false)}
/>
</div>
<ButtonComponent
cssClass="e-primary"
onClick={onSavePresentationSettings}
style={{ marginTop: 8 }}
disabled={presentationBusy}
>
{presentationBusy ? 'Speichere…' : 'Einstellungen speichern'}
</ButtonComponent>
</div>
</div>
</div>
);
const WebsitesDefaultsContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Webseiten</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Standardwerte (Lade-Timeout, Intervall, Sandbox/Embedding) für Webseiten.
</div>
</div>
</div>
);
const VideosDefaultsContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Videos</div>
</div>
</div>
<div className="e-card-content" style={{ fontSize: 14 }}>
<div style={{ marginBottom: 16 }}>
<CheckBoxComponent
label="Automatisch abspielen"
checked={videoAutoplay}
change={e => setVideoAutoplay(!!e.checked)}
/>
</div>
<div style={{ marginBottom: 16 }}>
<CheckBoxComponent
label="In Schleife abspielen"
checked={videoLoop}
change={e => setVideoLoop(!!e.checked)}
/>
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>Lautstärke</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<NumericTextBoxComponent
min={0}
max={1}
step={0.05}
value={videoVolume}
format="n2"
change={(e: { value: number }) => {
const v = typeof e.value === 'number' ? e.value : Number(e.value);
if (Number.isFinite(v)) setVideoVolume(Math.max(0, Math.min(1, v)));
}}
placeholder="0.01.0"
width="140px"
/>
<CheckBoxComponent
label="Ton aus"
checked={videoMuted}
change={e => setVideoMuted(!!e.checked)}
/>
</div>
</div>
<ButtonComponent
cssClass="e-primary"
onClick={onSaveVideoSettings}
disabled={videoBusy}
>
{videoBusy ? 'Speichere…' : 'Einstellungen speichern'}
</ButtonComponent>
</div>
</div>
</div>
);
const MessagesDefaultsContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Mitteilungen</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Standardwerte (Dauer, Layout/Styling) für Mitteilungen.
</div>
</div>
</div>
);
const OtherDefaultsContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Sonstige</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für sonstige Eventtypen.
</div>
</div>
</div>
);
// Users
const UsersQuickActionsContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Schnellaktionen</div>
</div>
</div>
<div className="e-card-content">
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Link to="/benutzer"><ButtonComponent cssClass="e-primary">Benutzerverwaltung öffnen</ButtonComponent></Link>
<ButtonComponent disabled title="Demnächst">Benutzer einladen</ButtonComponent>
</div>
</div>
</div>
</div>
);
// System
const OrganizationInfoContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Organisationsinformationen</div>
</div>
</div>
<div className="e-card-content" style={{ padding: '16px' }}>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 8, fontWeight: 600 }}>
Organisationsname
</label>
<TextBoxComponent
placeholder="z.B. Meine Schule GmbH"
value={organizationName}
change={(e) => setOrganizationName(e.value || '')}
cssClass="e-outline"
floatLabelType="Never"
style={{ width: '100%', maxWidth: '500px' }}
/>
<div style={{ marginTop: 8, color: '#666', fontSize: 13 }}>
Dieser Name wird im Header des Dashboards angezeigt.
</div>
</div>
<ButtonComponent
cssClass="e-primary"
onClick={onSaveOrganizationName}
disabled={organizationBusy}
>
{organizationBusy ? 'Speichern...' : 'Speichern'}
</ButtonComponent>
</div>
</div>
</div>
);
const AdvancedConfigContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Erweiterte Konfiguration</div>
</div>
</div>
<div className="e-card-content" style={{ padding: '16px' }}>
<div style={{ marginBottom: 20 }}>
<label style={{ display: 'block', marginBottom: 8, fontWeight: 600 }}>
Scheduler: Refresh-Intervall (Sekunden)
</label>
<NumericTextBoxComponent
value={refreshSeconds}
min={0}
step={1}
format="n0"
change={(e) => setRefreshSeconds(Number(e.value ?? 0))}
cssClass="e-outline"
width={200}
/>
<div style={{ marginTop: 8, color: '#666', fontSize: 13 }}>
Legt fest, wie oft der Scheduler Ereignisse an Clients republiziert, auch wenn sich nichts geändert hat.
<br />
<strong>0:</strong> Deaktiviert (Scheduler sendet nur bei Änderungen).
<br />
<strong>&gt;0:</strong> Sekunden zwischen Republizierungen (z.B. 600 = alle 10 Min).
<br />
Der Scheduler liest die Datenbank immer alle 30 Sekunden neu ab und sendet dann bei Bedarf die Änderungen an den Terminen.
</div>
<div style={{ marginTop: 12 }}>
<ButtonComponent cssClass="e-primary" onClick={onSaveRefreshSeconds} disabled={refreshBusy}>
{refreshBusy ? 'Speichern...' : 'Speichern'}
</ButtonComponent>
</div>
</div>
</div>
</div>
</div>
);
// ---------- Nested Tab wrappers (first-level tabs -> second-level content) ----------
const AcademicCalendarTabs = () => (
<TabComponent
heightAdjustMode="Auto"
selectedItem={academicTabIndex}
selected={(e: TabSelectedEvent) => setAcademicTabIndex(e.selectedIndex ?? 0)}
>
<TabItemsDirective>
<TabItemDirective header={{ text: '📥 Import & Liste' }} content={HolidaysImportAndListContent} />
<TabItemDirective header={{ text: '🗂️ Perioden' }} content={AcademicPeriodsContent} />
</TabItemsDirective>
</TabComponent>
);
const DisplayClientsTabs = () => (
<TabComponent
heightAdjustMode="Auto"
selectedItem={displayTabIndex}
selected={(e: TabSelectedEvent) => setDisplayTabIndex(e.selectedIndex ?? 0)}
>
<TabItemsDirective>
<TabItemDirective header={{ text: '⚙️ Defaults' }} content={DisplayDefaultsContent} />
<TabItemDirective header={{ text: '🖥️ Clients' }} content={ClientsConfigContent} />
</TabItemsDirective>
</TabComponent>
);
const MediaFilesTabs = () => (
<TabComponent
heightAdjustMode="Auto"
selectedItem={mediaTabIndex}
selected={(e: TabSelectedEvent) => setMediaTabIndex(e.selectedIndex ?? 0)}
>
<TabItemsDirective>
<TabItemDirective header={{ text: '⬆️ Upload' }} content={UploadSettingsContent} />
<TabItemDirective header={{ text: '🔄 Konvertierung' }} content={ConversionStatusContent} />
</TabItemsDirective>
</TabComponent>
);
const EventsTabs = () => (
<TabComponent
heightAdjustMode="Auto"
selectedItem={eventsTabIndex}
selected={(e: TabSelectedEvent) => setEventsTabIndex(e.selectedIndex ?? 0)}
>
<TabItemsDirective>
<TabItemDirective header={{ text: '📘 WebUntis' }} content={WebUntisSettingsContent} />
<TabItemDirective header={{ text: '🖼️ Präsentation' }} content={PresentationsDefaultsContent} />
<TabItemDirective header={{ text: '🌐 Webseiten' }} content={WebsitesDefaultsContent} />
<TabItemDirective header={{ text: '🎬 Videos' }} content={VideosDefaultsContent} />
<TabItemDirective header={{ text: '💬 Mitteilungen' }} content={MessagesDefaultsContent} />
<TabItemDirective header={{ text: '🔧 Sonstige' }} content={OtherDefaultsContent} />
</TabItemsDirective>
</TabComponent>
);
const UsersTabs = () => (
<TabComponent
heightAdjustMode="Auto"
selectedItem={usersTabIndex}
selected={(e: TabSelectedEvent) => setUsersTabIndex(e.selectedIndex ?? 0)}
>
<TabItemsDirective>
<TabItemDirective header={{ text: '⚡ Schnellaktionen' }} content={UsersQuickActionsContent} />
</TabItemsDirective>
</TabComponent>
);
const SystemTabs = () => (
<TabComponent
heightAdjustMode="Auto"
selectedItem={systemTabIndex}
selected={(e: TabSelectedEvent) => setSystemTabIndex(e.selectedIndex ?? 0)}
>
<TabItemsDirective>
<TabItemDirective header={{ text: '🏢 Organisation' }} content={OrganizationInfoContent} />
<TabItemDirective header={{ text: '🧩 Erweiterte Optionen' }} content={AdvancedConfigContent} />
</TabItemsDirective>
</TabComponent>
);
// ---------- Top-level (root) tabs ----------
return (
<div style={{ padding: 20 }}>
<ToastComponent ref={toastRef} position={{ X: 'Right', Y: 'Top' }} />
<h2 style={{ marginBottom: 20, fontSize: '24px', fontWeight: 600 }}>Einstellungen</h2>
<TabComponent heightAdjustMode="Auto">
<TabItemsDirective>
<TabItemDirective header={{ text: '📅 Akademischer Kalender' }} content={AcademicCalendarTabs} />
{isAdmin && (
<TabItemDirective header={{ text: '🖥️ Anzeige & Clients' }} content={DisplayClientsTabs} />
)}
{isAdmin && (
<TabItemDirective header={{ text: '🎬 Medien & Dateien' }} content={MediaFilesTabs} />
)}
{isAdmin && (
<TabItemDirective header={{ text: '🗓️ Events' }} content={EventsTabs} />
)}
{isAdmin && (
<TabItemDirective header={{ text: '👥 Benutzer' }} content={UsersTabs} />
)}
{isSuperadmin && (
<TabItemDirective header={{ text: '⚙️ System' }} content={SystemTabs} />
)}
</TabItemsDirective>
</TabComponent>
</div>
);
};
export default Einstellungen;

View File

@@ -0,0 +1,15 @@
/* FileManager icon size overrides (loaded after Syncfusion styles) */
.e-filemanager.media-icons-xl .e-large-icons .e-list-icon {
font-size: 40px; /* default ~24px */
}
.e-filemanager.media-icons-xl .e-large-icons .e-fe-folder,
.e-filemanager.media-icons-xl .e-large-icons .e-fe-file {
font-size: 40px;
}
/* Details (grid) view icons */
.e-filemanager.media-icons-xl .e-fe-grid-icon .e-fe-folder,
.e-filemanager.media-icons-xl .e-fe-grid-icon .e-fe-file {
font-size: 24px;
}

Some files were not shown because too many files have changed in this diff Show More