44 Commits

Author SHA1 Message Date
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
167 changed files with 15232 additions and 8106 deletions

36
.env.example Normal file
View File

@@ -0,0 +1,36 @@
# 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
# 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)
HEARTBEAT_GRACE_PERIOD_DEV=15
HEARTBEAT_GRACE_PERIOD_PROD=180
# Scheduler
# Optional: force periodic republish even without changes
# REFRESH_SECONDS=0
# Default admin bootstrap (server/init_defaults.py)
DEFAULT_ADMIN_USERNAME=infoscreen_admin
DEFAULT_ADMIN_PASSWORD=Info_screen_admin25!

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

@@ -0,0 +1,154 @@
# 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.
## 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 in `listener/listener.py`.
- Scheduler: Publishes active events (per group) to MQTT retained topics in `scheduler/scheduler.py`.
- Nginx: Reverse proxy routes `/api/*` and `/screenshots/*` to API; everything else to dashboard (`nginx.conf`).
## 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`.
- Listener also creates its own engine for writes to `clients`.
- 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.
- 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`)
- Enums: `EventType` (presentation, website, video, message, webuntis), `MediaType` (file/website types), and `AcademicPeriodType` (schuljahr, semester, trimester).
- Tables: `clients`, `client_groups`, `events`, `event_media`, `users`, `academic_periods`, `school_holidays`.
- Academic periods: `academic_periods` table supports educational institution cycles (school years, semesters). Events and media can be optionally linked via `academic_period_id` (nullable for backward compatibility).
- Times are stored as timezone-aware; treat comparisons in UTC (see scheduler and routes/events).
- 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`.
- Events: `server/routes/events.py` serializes enum values to strings and normalizes times to UTC.
- Media: `server/routes/eventmedia.py` implements a simple file manager API rooted at `server/media/`.
- Academic periods: `server/routes/academic_periods.py` exposes:
- `GET /api/academic_periods` — list all periods
- `GET /api/academic_periods/active` — currently active period
- `POST /api/academic_periods/active` — set active period (deactivates others)
- `GET /api/academic_periods/for_date?date=YYYY-MM-DD` — period covering given date
## Frontend patterns (dashboard)
- Vite React app; proxies `/api` and `/screenshots` to API in dev (`vite.config.ts`).
- Uses Syncfusion components; Vite config pre-bundles specific packages to avoid alias issues.
- Environment: `VITE_API_URL` provided at build/run; in dev compose, proxy handles `/api` so local fetches can use relative `/api/...` paths.
- 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 timestamps match each occurrence start time (UTC) so Syncfusion excludes instances on holidays reliably.
- Frontend manually expands recurring events due to Syncfusion EXDATE handling bugs. Daily/Weekly recurrence patterns are expanded client-side with proper EXDATE filtering and DST timezone tolerance (2-hour window).
- Single occurrence editing: Users can detach individual occurrences from recurring series via confirmation dialog. The detach operation creates `EventException` records, generates EXDATE entries, and creates standalone events without affecting the master series.
- UI: Events with `SkipHolidays` render a TentTree icon directly after the main event icon in the scheduler event template. Icon color: black.
- 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.
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).
- `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 ~15s dev / 180s prod).
- REFRESH_SECONDS — Optional scheduler republish interval; `0` disables periodic refresh.
## Conventions & gotchas
- Always compare datetimes in UTC; some DB values may be naive—normalize before comparing (see `routes/events.py`).
- Use retained MQTT messages for state that clients must recover after reconnect (events per group, client group_id).
- 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, and
4) Return JSON-safe values (serialize enums and datetimes).
- When extending media types, update `MediaType` and any logic in `eventmedia` and dashboard that depends on it.
- Academic periods: Events/media can be optionally associated with periods for educational organization. Only one period should be active at a time (`is_active=True`).
- 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 event with `RecurrenceRule` + `RecurrenceException`.
- Ensure EXDATE tokens include the occurrence start time (HH:mm:ss) in UTC to match manual expansion logic.
- 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.
## 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`.
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

23
.gitignore vendored
View File

@@ -1,3 +1,25 @@
# OS/Editor
.DS_Store
Thumbs.db
.vscode/
.idea/
# Python
__pycache__/
*.pyc
.pytest_cache/
# Node
node_modules/
dashboard/node_modules/
dashboard/.vite/
# Env files (never commit secrets)
.env
.env.local
# Docker
*.log
# Python-related
__pycache__/
*.py[cod]
@@ -75,3 +97,4 @@ dashboard/sidebar_test.py
dashboard/assets/responsive-sidebar.css
certs/
sync.ffs_db
.pnpm-store/

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`

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.

147
DATABASE_GUIDE.md Normal file
View File

@@ -0,0 +1,147 @@
# 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 (Alembic revision: `b5a6c3d4e7f8`)
- **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
- **`alembic_version`** - Migration tracking
### 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.

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"

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"

465
README.md Normal file
View File

@@ -0,0 +1,465 @@
# 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 │ │ Listener │
│ (React/Vite) │◄──►│ (Flask) │◄──►│ (MQTT Client) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ MariaDB │ │
│ │ (Database) │ │
│ └─────────────────┘ │
│ │
└────────────────────┬───────────────────────────┘
┌─────────────────┐
│ MQTT Broker │
│ (Mosquitto) │
└─────────────────┘
┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Scheduler │ │ Worker │ │ Infoscreen │
│ (Events) │ │ (Conversions) │ │ Clients │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
## 🌟 Key Features
### 📊 **Dashboard Management**
- 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 System**
- **Presentations**: PowerPoint/LibreOffice → PDF conversion via Gotenberg
- **Websites**: URL-based content display
- **Videos**: Media file streaming
- **Messages**: Text announcements
- **WebUntis**: Educational schedule integration
- **Recurrence & Holidays**: Recurring events can be configured to skip holidays. The backend generates EXDATEs (RecurrenceException) for holiday occurrences so the calendar never shows those instances. 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
```
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)
### 🔧 **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.
- 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/`)
- **Technology**: Python + paho-mqtt
- **Purpose**: MQTT message processing, client discovery
- **Features**: Heartbeat monitoring, automatic client registration
### ⏰ **Scheduler** (`scheduler/`)
- **Technology**: Python + SQLAlchemy
- **Purpose**: Event publishing, group-based content distribution
- **Features**: Time-based event activation, MQTT publishing
### 🔄 **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
- `infoscreen/{uuid}/group_id` - Client group assignment
## 📁 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/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
### Health & Monitoring
- `GET /health` - Service health check
- `GET /api/screenshots/{uuid}.jpg` - Client screenshots
## 🎨 Frontend Features
### Recurrence & holidays
- The frontend manually expands recurring events due to Syncfusion EXDATE handling limitations.
- The API supplies `RecurrenceException` (EXDATE) with exact occurrence start times (UTC) so holiday instances are excluded.
- Events with "skip holidays" display a TentTree icon next to the main event icon.
- Single occurrence editing: Users can detach individual occurrences via confirmation dialog, creating standalone events while preserving the master series.
### 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
### Pages Overview
- **Dashboard**: System overview and statistics
- **Clients**: Device management and monitoring
- **Groups**: Client group organization
- **Events**: Schedule management
- **Media**: File upload and conversion
- **Settings**: System configuration
- **Holidays**: Academic calendar management
- **Program info**: Version, build info, tech stack and paginated changelog (reads `dashboard/public/program-info.json`)
## 🔒 Security & Authentication
- **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
All services include Docker health checks:
- API: HTTP endpoint monitoring
- Database: Connection and initialization status
- MQTT: Pub/sub functionality test
- 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**
```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.

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

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,39 @@
"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-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-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-splitbuttons": "^30.2.0",
"cldr-data": "^36.0.4",
"lucide-react": "^0.522.0",
"react": "^19.1.0",
@@ -28,9 +45,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 +63,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,107 @@
{
"appName": "Infoscreen-Management",
"version": "2025.1.0-alpha.8",
"copyright": "© 2025 Third-Age-Applications",
"supportContact": "support@third-age-applications.com",
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
"techStack": {
"Frontend": "React, Vite, TypeScript",
"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-09-20T11:00:00Z",
"commitId": "8d1df7199cb7"
},
"changelog": [
{
"version": "2025.1.0-alpha.8",
"date": "2025-10-11",
"changes": [
"🎨 Theme: Umstellung auf Syncfusion Material 3; zentrale CSS-Imports in main.tsx",
"🧹 Cleanup: Tailwind CSS komplett entfernt (Pakete, PostCSS, Stylelint, Konfigurationsdateien)",
"🧩 Gruppenverwaltung: \"infoscreen_groups\" auf Syncfusion-Komponenten (Buttons, Dialoge, DropDownList, TextBox) umgestellt; Abstände verbessert",
"🔔 Benachrichtigungen: Vereinheitlichte Toast-/Dialog-Texte; letzte Alert-Verwendung ersetzt",
"📖 Doku: README und Copilot-Anweisungen angepasst (Material 3, zentrale Styles, kein Tailwind)"
]
},
{
"version": "2025.1.0-alpha.7",
"date": "2025-09-21",
"changes": [
"🧭 UI: Periode-Auswahl (Syncfusion) neben Gruppenauswahl; kompaktes Layout",
"✅ Anzeige: Abzeichen für vorhandenen Ferienplan + Zähler Ferien im Blick",
"🛠️ API: Endpunkte für akademische Perioden (list, active GET/POST, for_date)",
"📅 Scheduler: Standardmäßig keine Terminierung in Ferien; Block-Darstellung wie Ganztagesereignis; schwarze Textfarbe",
"📤 Ferien: Upload von TXT/CSV (headless TXT nutzt Spalten 24)",
"🔧 UX: Schalter in einer Reihe; Dropdown-Breiten optimiert"
]
},
{
"version": "2025.1.0-alpha.6",
"date": "2025-09-20",
"changes": [
"🗓️ NEU: Akademische Perioden System - Unterstützung für Schuljahre, Semester und Trimester",
"🏗️ DATENBANK: Neue 'academic_periods' Tabelle für zeitbasierte Organisation",
"🔗 ERWEITERT: Events und Medien können jetzt optional einer akademischen Periode zugeordnet werden",
"📊 ARCHITEKTUR: Vollständig rückwärtskompatible Implementierung für schrittweise Einführung",
"🎯 BILDUNG: Fokus auf Schulumgebung mit Erweiterbarkeit für Hochschulen",
"⚙️ TOOLS: Automatische Erstellung von Standard-Schuljahren für österreichische Schulen"
]
},
{
"version": "2025.1.0-alpha.5",
"date": "2025-09-14",
"changes": [
"Komplettes Redesign des Backend-Handlings der Gruppenzuordnungen von neuen Clients und der Schritte bei Änderung der Gruppenzuordnung."
]
},
{
"version": "2025.1.0-alpha.4",
"date": "2025-09-01",
"changes": [
"Grundstruktur für Deployment getestet und optimiert.",
"FIX: Programmfehler beim Umschalten der Ansicht auf der Medien-Seite behoben."
]
},
{
"version": "2025.1.0-alpha.3",
"date": "2025-08-30",
"changes": [
"NEU: Programminfo-Seite mit dynamischen Daten, Build-Infos und Changelog.",
"NEU: Logout-Funktionalität implementiert.",
"FIX: Breite der Sidebar im eingeklappten Zustand korrigiert."
]
},
{
"version": "2025.1.0-alpha.2",
"date": "2025-08-29",
"changes": [
"INFO: Analyse und Anzeige der verwendeten Open-Source-Bibliotheken."
]
},
{
"version": "2025.1.0-alpha.1",
"date": "2025-08-28",
"changes": [
"Initiales Setup des Projekts und der Grundstruktur."
]
}
]
}

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,8 @@
import React, { useState } from 'react';
import { BrowserRouter as Router, Routes, Route, Link, Outlet } from 'react-router-dom';
import { SidebarComponent } from '@syncfusion/ej2-react-navigations';
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
import { TooltipComponent } from '@syncfusion/ej2-react-popups';
import logo from './assets/logo.png';
import './App.css';
@@ -14,6 +17,8 @@ import {
Monitor,
MonitorDotIcon,
LogOut,
Wrench,
Info,
} from 'lucide-react';
import { ToastProvider } from './components/ToastProvider';
@@ -22,124 +27,14 @@ const sidebarItems = [
{ 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: 'Infoscreen-Clients', path: '/clients', icon: Monitor },
{ name: 'Erweiterungsmodus', path: '/setup', icon: Wrench },
{ name: 'Medien', path: '/medien', icon: Image },
{ name: 'Benutzer', path: '/benutzer', icon: User },
{ name: 'Einstellungen', path: '/einstellungen', icon: Settings },
{ name: 'Programminfo', path: '/programminfo', icon: Info },
];
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';
@@ -149,3 +44,294 @@ import Infoscreen_groups from './infoscreen_groups';
import Media from './media';
import Benutzer from './benutzer';
import Einstellungen from './einstellungen';
import SetupMode from './SetupMode';
import Programminfo from './programminfo';
import Logout from './logout';
// 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);
let sidebarRef: SidebarComponent | null;
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));
}, []);
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 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.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>
<span className="ml-auto text-lg font-medium" style={{ color: '#78591c' }}>
[Organisationsname]
</span>
</header>
<main className="page-content">
<Outlet />
</main>
</div>
</div>
);
};
const App: React.FC = () => {
// Automatische Navigation zu /clients bei leerer Beschreibung entfernt
return (
<ToastProvider>
<Routes>
<Route path="/" element={<Layout />}>
<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 />} />
</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;
}

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,13 +8,30 @@ 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 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) {
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}`, {
method: 'DELETE',
@@ -23,3 +40,58 @@ export async function deleteEvent(eventId: string) {
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 };
}

File diff suppressed because it is too large Load Diff

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,27 @@ type CustomEventData = {
weekdays: number[];
repeatUntil: Date | null;
skipHolidays: boolean;
media?: { id: string; path: string; name: string } | null; // <--- ergänzt
slideshowInterval?: number; // <--- ergänzt
websiteUrl?: string; // <--- ergänzt
};
// 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;
blockHolidays?: boolean;
isHolidayRange?: (start: Date, end: Date) => boolean;
};
const weekdayOptions = [
@@ -50,7 +65,11 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
onClose,
onSave,
initialData = {},
groupName, // <--- NEU
groupName,
groupColor,
editMode,
blockHolidays,
isHolidayRange,
}) => {
const [title, setTitle] = React.useState(initialData.title || '');
const [startDate, setStartDate] = React.useState(initialData.startDate || null);
@@ -60,36 +79,59 @@ 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>('');
const [slideshowInterval, setSlideshowInterval] = React.useState<number>(
initialData.slideshowInterval ?? 10
);
const [websiteUrl, setWebsiteUrl] = React.useState<string>(initialData.websiteUrl ?? '');
const [mediaModalOpen, setMediaModalOpen] = React.useState(false);
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);
setWebsiteUrl(initialData.websiteUrl ?? '');
}
}, [open, initialData]);
@@ -108,6 +150,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)
@@ -117,6 +183,34 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
if (!websiteUrl.trim()) newErrors.websiteUrl = 'Webseiten-URL ist erforderlich';
}
// Holiday blocking: prevent creating when range overlaps
if (
!editMode &&
blockHolidays &&
startDate &&
startTime &&
endTime &&
typeof isHolidayRange === 'function'
) {
const s = new Date(
startDate.getFullYear(),
startDate.getMonth(),
startDate.getDate(),
startTime.getHours(),
startTime.getMinutes()
);
const e = new Date(
startDate.getFullYear(),
startDate.getMonth(),
startDate.getDate(),
endTime.getHours(),
endTime.getMinutes()
);
if (isHolidayRange(s, e)) {
newErrors.startDate = 'Dieser Zeitraum liegt in den Ferien und ist gesperrt.';
}
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
@@ -124,11 +218,30 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
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,9 +265,20 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
endTime.getMinutes()
).toISOString()
: null,
type,
startDate,
startTime,
endTime,
// Initialize required fields
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') {
@@ -167,34 +291,102 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
}
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 +400,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 +442,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 +504,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 +527,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 +539,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 +547,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
label="Ferientage berücksichtigen"
checked={skipHolidays}
change={e => setSkipHolidays(e.checked)}
disabled={!repeat}
disabled={!repeat || isSingleOccurrence}
/>
</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

@@ -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,204 @@
import React, { useEffect, useState } from 'react';
import { fetchClients } from './apiClients';
import type { Client } from './apiClients';
import React, { useEffect, useState, useRef } from 'react';
import { fetchGroupsWithClients, restartClient } from './apiClients';
import type { Group, Client } from './apiClients';
import {
GridComponent,
ColumnsDirective,
ColumnDirective,
Page,
DetailRow,
Inject,
Sort,
} from '@syncfusion/ej2-react-grids';
const REFRESH_INTERVAL = 15000; // 15 Sekunden
// Typ für Collapse-Event
// type DetailRowCollapseArgs = {
// data?: { id?: string | number };
// };
// Typ für DataBound-Event
type DetailRowDataBoundArgs = {
data?: { id?: string | number };
};
const Dashboard: React.FC = () => {
const [clients, setClients] = useState<Client[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const [expandedGroupIds, setExpandedGroupIds] = useState<string[]>([]);
const gridRef = useRef<GridComponent | null>(null);
// Funktion für das Schließen einer Gruppe (Collapse)
// const onDetailCollapse = (args: DetailRowCollapseArgs) => {
// if (args && args.data && args.data.id) {
// const groupId = String(args.data.id);
// setExpandedGroupIds(prev => prev.filter(id => String(id) !== groupId));
// }
// };
// // Registriere das Event nach dem Mount am Grid
// useEffect(() => {
// if (gridRef.current) {
// gridRef.current.detailCollapse = onDetailCollapse;
// }
// }, []);
// Optimiertes Update: Nur bei echten Datenänderungen wird das Grid aktualisiert
useEffect(() => {
fetchClients().then(setClients).catch(console.error);
}, []);
let lastGroups: Group[] = [];
const fetchAndUpdate = async () => {
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;
setTimeout(() => {
expandedGroupIds.forEach(id => {
const rowIndex = newGroups.findIndex(g => String(g.id) === String(id));
if (rowIndex !== -1 && gridRef.current) {
gridRef.current.detailRowModule.expand(rowIndex);
}
});
}, 100);
}
};
fetchAndUpdate();
const interval = setInterval(fetchAndUpdate, REFRESH_INTERVAL);
return () => clearInterval(interval);
}, [expandedGroupIds]);
// Health-Badge
const getHealthBadge = (group: Group) => {
const total = group.clients.length;
const alive = group.clients.filter((c: Client) => c.is_alive).length;
const ratio = total === 0 ? 0 : alive / total;
let color = 'danger';
let text = `${alive} / ${total} offline`;
if (ratio === 1) {
color = 'success';
text = `${alive} / ${total} alive`;
} else if (ratio >= 0.5) {
color = 'warning';
text = `${alive} / ${total} teilw. alive`;
}
return <span className={`e-badge e-badge-${color}`}>{text}</span>;
};
// Einfache Tabelle für Clients einer Gruppe
const getClientTable = (group: Group) => (
<div style={{ maxHeight: 300, overflowY: 'auto', marginBottom: '5px' }}>
<GridComponent dataSource={group.clients} allowSorting={true} height={'auto'}>
<ColumnsDirective>
<ColumnDirective field="description" headerText="Beschreibung" width="150" />
<ColumnDirective field="ip" headerText="IP" width="120" />
{/* <ColumnDirective
field="last_alive"
headerText="Letztes Lebenszeichen"
width="180"
template={(props: { last_alive: string | null }) => {
if (!props.last_alive) return '-';
const dateStr = props.last_alive.endsWith('Z')
? props.last_alive
: props.last_alive + 'Z';
const date = new Date(dateStr);
return isNaN(date.getTime()) ? props.last_alive : date.toLocaleString();
}}
/> */}
<ColumnDirective
field="is_alive"
headerText="Alive"
width="100"
template={(props: { is_alive: boolean }) => (
<span className={`e-badge e-badge-${props.is_alive ? 'success' : 'danger'}`}>
{props.is_alive ? 'alive' : 'offline'}
</span>
)}
sortComparer={(a, b) => (a === b ? 0 : a ? -1 : 1)}
/>
<ColumnDirective
headerText="Aktionen"
width="150"
template={(props: { uuid: string }) => (
<button className="e-btn e-primary" onClick={() => handleRestartClient(props.uuid)}>
Neustart
</button>
)}
/>
</ColumnsDirective>
<Inject services={[Sort]} />
</GridComponent>
</div>
);
// Neustart-Logik
const handleRestartClient = async (uuid: string) => {
try {
const result = await restartClient(uuid);
alert(`Neustart erfolgreich: ${result.message}`);
} catch (error: unknown) {
if (error && typeof error === 'object' && 'message' in error) {
alert(`Fehler beim Neustart: ${(error as { message: string }).message}`);
} else {
alert('Unbekannter Fehler beim Neustart');
}
}
};
// SyncFusion Grid liefert im Event die Zeile/Gruppe
const onDetailDataBound = (args: DetailRowDataBoundArgs) => {
if (args && args.data && args.data.id) {
const groupId = String(args.data.id);
setExpandedGroupIds(prev => (prev.includes(groupId) ? prev : [...prev, groupId]));
}
};
return (
<div>
<header className="mb-8 pb-4 border-b-2 border-[#d6c3a6]">
<h2 className="text-3xl font-extrabold mb-2">Dashboard</h2>
<header style={{ marginBottom: 32, paddingBottom: 16, borderBottom: '2px solid #d6c3a6' }}>
<h2 style={{ fontSize: '1.75rem', fontWeight: 800, margin: 0, marginBottom: 8 }}>Dashboard</h2>
</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() : '-'}
</div>
</div>
))}
{clients.length === 0 && (
<div className="col-span-full text-center text-gray-400">Keine Clients gefunden.</div>
)}
</div>
<h3 style={{ fontSize: '1.125rem', fontWeight: 600, marginTop: 24, marginBottom: 16 }}>
Raumgruppen Übersicht
</h3>
<GridComponent
dataSource={groups}
allowPaging={true}
pageSettings={{ pageSize: 5 }}
height={400}
detailTemplate={(props: Group) => getClientTable(props)}
detailDataBound={onDetailDataBound}
ref={gridRef}
>
<Inject services={[Page, DetailRow]} />
<ColumnsDirective>
<ColumnDirective field="name" headerText="Raumgruppe" width="180" />
<ColumnDirective
headerText="Health"
width="160"
template={(props: Group) => getHealthBadge(props)}
/>
</ColumnsDirective>
</GridComponent>
{groups.length === 0 && (
<div className="col-span-full text-center text-gray-400">Keine Gruppen gefunden.</div>
)}
</div>
);
};

View File

@@ -1,8 +1,87 @@
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>
);
import { listHolidays, uploadHolidaysCsv, type Holiday } from './apiHolidays';
const Einstellungen: React.FC = () => {
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 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);
}
}, []);
React.useEffect(() => {
refresh();
}, [refresh]);
const onUpload = async () => {
if (!file) return;
setBusy(true);
setMessage(null);
try {
const res = await uploadHolidaysCsv(file);
setMessage(`Import erfolgreich: ${res.inserted} neu, ${res.updated} aktualisiert.`);
await refresh();
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Import.';
setMessage(msg);
} finally {
setBusy(false);
}
};
return (
<div>
<h2 className="text-xl font-bold mb-4">Einstellungen</h2>
<div className="space-y-4">
<section className="p-4 border rounded-md">
<h3 className="font-semibold mb-2">Schulferien importieren</h3>
<p className="text-sm text-gray-600 mb-2">
Unterstützte Formate:
<br /> CSV mit Kopfzeile: <code>name</code>, <code>start_date</code>,{' '}
<code>end_date</code>, optional <code>region</code>
<br /> TXT/CSV ohne Kopfzeile mit Spalten: interner Name, <strong>Name</strong>,{' '}
<strong>Start (YYYYMMDD)</strong>, <strong>Ende (YYYYMMDD)</strong>, optional interne
Info (ignoriert)
</p>
<div className="flex items-center gap-3">
<input
type="file"
accept=".csv,text/csv,.txt,text/plain"
onChange={e => setFile(e.target.files?.[0] ?? null)}
/>
<button className="e-btn e-primary" onClick={onUpload} disabled={!file || busy}>
{busy ? 'Importiere…' : 'CSV/TXT importieren'}
</button>
</div>
{message && <div className="mt-2 text-sm">{message}</div>}
</section>
<section className="p-4 border rounded-md">
<h3 className="font-semibold mb-2">Importierte Ferien</h3>
{holidays.length === 0 ? (
<div className="text-sm text-gray-600">Keine Einträge vorhanden.</div>
) : (
<ul className="text-sm list-disc pl-6">
{holidays.slice(0, 20).map(h => (
<li key={h.id}>
{h.name}: {h.start_date} {h.end_date}
{h.region ? ` (${h.region})` : ''}
</li>
))}
</ul>
)}
</section>
</div>
</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,4 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Tailwind removed: base/components/utilities directives no longer used. */
/* :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,7 +135,10 @@ 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);
} catch (err) {
@@ -144,9 +157,12 @@ const Infoscreen_groups: React.FC = () => {
// 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);
@@ -165,8 +181,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 +218,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 +282,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 +300,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 +317,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 +335,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 +370,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>
);

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

@@ -0,0 +1,12 @@
import React from 'react';
const Logout: React.FC = () => (
<div className="flex items-center justify-center h-screen">
<div className="text-center">
<h2 className="text-2xl font-bold mb-4">Abmeldung</h2>
<p>Sie haben sich erfolgreich abgemeldet.</p>
</div>
</div>
);
export default Logout;

View File

@@ -3,9 +3,28 @@ import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
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';
// Setze hier deinen Lizenzschlüssel ein
registerLicense('ORg4AjUWIQA/Gnt3VVhhQlJDfV5AQmBIYVp/TGpJfl96cVxMZVVBJAtUQF1hTH5VdENiXX1dcHxUQWNVWkd2');
registerLicense(
'ORg4AjUWIQA/Gnt3VVhhQlJDfV5AQmBIYVp/TGpJfl96cVxMZVVBJAtUQF1hTH5VdENiXX1dcHxUQWNVWkd2'
);
createRoot(document.getElementById('root')!).render(
<StrictMode>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useState, useRef } from 'react';
import CustomMediaInfoPanel from './components/CustomMediaInfoPanel';
import {
FileManagerComponent,
@@ -10,43 +10,61 @@ import {
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);
// 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
}
// 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());
};
// 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]);
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}
ajaxSettings={{
url: hostUrl + 'operations',
getImageUrl: hostUrl + 'get-image',
@@ -71,18 +89,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

@@ -1,4 +1,4 @@
declare module "*.json" {
declare module '*.json' {
const value: unknown;
export default value;
}

View File

@@ -1,10 +0,0 @@
module.exports = {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
corePlugins: {
preflight: false,
},
theme: {
extend: {},
},
plugins: [],
};

View File

@@ -1,13 +1,54 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// import path from 'path';
// https://vite.dev/config/
export default defineConfig({
cacheDir: './.vite',
plugins: [react()],
resolve: {
// 🔧 KORRIGIERT: Entferne die problematischen Aliases komplett
// Diese verursachen das "not an absolute path" Problem
// alias: {
// '@syncfusion/ej2-react-navigations': '@syncfusion/ej2-react-navigations/index.js',
// '@syncfusion/ej2-react-buttons': '@syncfusion/ej2-react-buttons/index.js',
// },
},
optimizeDeps: {
// 🔧 NEU: Force pre-bundling der Syncfusion Module
include: [
'@syncfusion/ej2-react-navigations',
'@syncfusion/ej2-react-buttons',
'@syncfusion/ej2-base',
'@syncfusion/ej2-navigations',
'@syncfusion/ej2-buttons',
'@syncfusion/ej2-react-base',
],
// 🔧 NEU: Force dependency re-optimization
force: true,
esbuildOptions: {
target: 'es2020',
},
},
build: {
target: 'es2020',
commonjsOptions: {
include: [/node_modules/],
transformMixedEsModules: true,
},
},
server: {
host: '0.0.0.0',
port: 5173,
watch: {
usePolling: true,
},
fs: {
strict: false,
},
proxy: {
'/api': 'http://localhost:8000',
'/screenshots': 'http://localhost:8000',
'/api': 'http://server:8000',
'/screenshots': 'http://server:8000',
},
},
});

24
dashboard/wait-for-backend.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/bin/sh
# wait-for-backend.sh
# Stellt sicher, dass das Skript bei einem Fehler abbricht
set -e
# Der erste Parameter ist der Host, der erreicht werden soll
host="$1"
# Alle weiteren Parameter bilden den Befehl, der danach ausgeführt werden soll
shift
cmd="$@"
# Schleife, die so lange läuft, bis der Host mit einem erfolgreichen HTTP-Status antwortet
# curl -s: silent mode (kein Fortschrittsbalken)
# curl -f: fail silently (gibt einen Fehlercode > 0 zurück, wenn der HTTP-Status nicht 2xx ist)
until curl -s -f "$host" > /dev/null; do
>&2 echo "Backend ist noch nicht erreichbar - schlafe für 2 Sekunden"
sleep 2
done
# Wenn die Schleife beendet ist, ist das Backend erreichbar
>&2 echo "Backend ist erreichbar - starte Vite-Server..."
# Führe den eigentlichen Befehl aus (z.B. npm run dev)
exec $cmd

336
deployment-debian.md Normal file
View File

@@ -0,0 +1,336 @@
# Infoscreen Deployment Guide (Debian)
Komplette Anleitung für das Deployment des Infoscreen-Systems auf einem Debian-Server (Bookworm/Trixie) mit GitHub Container Registry.
## 📋 Übersicht
- **Phase 0**: Docker Installation (optional)
- **Phase 1**: Images bauen und zur Registry pushen
- **Phase 2**: Debian-Server Vorbereitung
- **Phase 3**: System-Konfiguration und Start
---
## 🐳 Phase 0: Docker Installation (optional)
Falls Docker noch nicht installiert ist, wählen Sie eine der folgenden Optionen:
### Option A: Debian Repository (schnell, aber oft ältere Version)
```bash
sudo apt update
sudo apt install -y docker.io docker-compose-plugin
sudo systemctl enable docker
sudo systemctl start docker
```
### Option B: Offizielle Docker-Installation (empfohlen für Produktion)
```bash
# Alte Docker-Versionen entfernen (falls vorhanden)
sudo apt remove -y docker docker-engine docker.io containerd runc
# Abhängigkeiten installieren
sudo apt update
sudo apt install -y ca-certificates curl gnupg lsb-release
# Repository-Schlüssel hinzufügen
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
# Debian Codename ermitteln (bookworm / trixie / bullseye ...)
source /etc/os-release
echo "Using Debian release: $VERSION_CODENAME"
# Docker Repository hinzufügen
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
$VERSION_CODENAME stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Docker installieren (neueste aus dem offiziellen Repo)
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Docker aktivieren
sudo systemctl enable docker
sudo systemctl start docker
# Benutzer zur docker-Gruppe hinzufügen (für Root-losen Zugriff)
sudo usermod -aG docker $USER
echo "Bitte neu einloggen (SSH Sitzung beenden und neu verbinden), damit die Gruppenzugehörigkeit aktiv wird."
```
### Docker-Installation testen
```bash
docker run hello-world
docker --version
docker compose version
```
---
## 🏗️ Phase 1: Images bauen und pushen (lokale Entwicklungsmaschine)
### 1. GitHub Container Registry Login
```bash
# Personal Access Token (write:packages) verwenden
echo $GITHUB_TOKEN | docker login ghcr.io -u robbstarkaustria --password-stdin
# Alternativ interaktiv
docker login ghcr.io
# Username: robbstarkaustria
# Password: [GITHUB_TOKEN]
```
### 2. Images bauen und taggen
```bash
cd /workspace
docker build -f server/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-api:latest .
docker build -f dashboard/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-dashboard:latest .
docker build -f listener/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-listener:latest .
docker build -f scheduler/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-scheduler:latest .
```
### 3. Images pushen
```bash
docker push ghcr.io/robbstarkaustria/infoscreen-api:latest
docker push ghcr.io/robbstarkaustria/infoscreen-dashboard:latest
docker push ghcr.io/robbstarkaustria/infoscreen-listener:latest
docker push ghcr.io/robbstarkaustria/infoscreen-scheduler:latest
docker images | grep ghcr.io
```
---
## 🖥️ Phase 2: Debian-Server Vorbereitung
### 4. Grundsystem aktualisieren
```bash
sudo apt update && sudo apt upgrade -y
sudo apt install -y git curl wget
# Falls Docker noch fehlt → Phase 0 ausführen
```
### 5. Deployment-Dateien übertragen
```bash
mkdir -p ~/infoscreen-deployment
cd ~/infoscreen-deployment
scp user@dev-machine:/workspace/docker-compose.prod.yml .
scp user@dev-machine:/workspace/.env .
scp user@dev-machine:/workspace/nginx.conf .
scp -r user@dev-machine:/workspace/certs ./
scp -r user@dev-machine:/workspace/mosquitto ./
# Alternative Paketierung:
# (auf Entwicklungsrechner)
# tar -czf infoscreen-deployment.tar.gz docker-compose.prod.yml .env nginx.conf certs/ mosquitto/
# scp infoscreen-deployment.tar.gz user@server:~/
# (auf Server)
# tar -xzf infoscreen-deployment.tar.gz -C ~/infoscreen-deployment
```
### 6. Mosquitto-Konfiguration (falls nicht kopiert)
```bash
mkdir -p mosquitto/{config,data,log}
cat > mosquitto/config/mosquitto.conf << 'EOF'
listener 1883
allow_anonymous true
listener 9001
protocol websockets
persistence true
persistence_location /mosquitto/data/
log_dest file /mosquitto/log/mosquitto.log
EOF
sudo chown -R 1883:1883 mosquitto/data mosquitto/log
chmod 755 mosquitto/config mosquitto/data mosquitto/log
```
### 7. Environment (.env) prüfen/anpassen
```bash
nano .env
# Prüfen u.a.:
# DB_HOST=db
# DB_CONN=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}
# VITE_API_URL=https://YOUR_SERVER_HOST/api
# Sichere Passwörter & Secrets setzen
```
Hinweise:
- Vorlage `.env.example` aus dem Repository verwenden: `cp .env.example .env` (falls noch nicht vorhanden).
- In Produktion lädt der Code keine `.env` automatisch (nur bei `ENV=development`).
---
## 🚀 Phase 3: System-Start und Konfiguration
### 8. Images von Registry pullen
```bash
echo $GITHUB_TOKEN | docker login ghcr.io -u robbstarkaustria --password-stdin
docker compose -f docker-compose.prod.yml pull
```
### 9. System starten
```bash
docker compose -f docker-compose.prod.yml up -d
docker compose ps
docker compose logs -f
```
### 10. Firewall konfigurieren
Debian hat standardmäßig nftables/iptables aktiv. Falls eine einfache Verwaltung gewünscht ist, kann `ufw` installiert werden:
```bash
sudo apt install -y ufw
sudo ufw allow ssh
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 1883/tcp
sudo ufw allow 9001/tcp
sudo ufw enable
sudo ufw status
```
Alternativ direkt via nftables / iptables konfigurieren (optional, nicht dargestellt).
### 11. Installation validieren
```bash
curl http://localhost/api/health
curl -k https://localhost # -k bei selbstsignierten Zertifikaten
docker compose ps
docker compose logs server
docker compose logs mqtt
```
---
## 🧪 Quickstart (lokale Entwicklung)
```bash
cp -n .env.example .env
docker compose up -d --build
docker compose ps
docker compose logs -f server
```
Dev-Erreichbarkeit:
- Dashboard: http://localhost:5173
- API: http://localhost:8000/api
- Health: http://localhost:8000/health
- Screenshots: http://localhost:8000/screenshots/<uuid>.jpg
- MQTT: localhost:1883 (WebSocket: 9001)
### 12. Systemd Autostart (optional)
```bash
sudo tee /etc/systemd/system/infoscreen.service > /dev/null << 'EOF'
[Unit]
Description=Infoscreen Application
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/home/$USER/infoscreen-deployment
ExecStart=/usr/bin/docker compose -f docker-compose.prod.yml up -d
ExecStop=/usr/bin/docker compose -f docker-compose.prod.yml down
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl enable infoscreen.service
sudo systemctl start infoscreen.service
```
---
## 🌐 Zugriff auf die Anwendung
- HTTPS Dashboard: `https://YOUR_SERVER_IP`
- HTTP (Redirect): `http://YOUR_SERVER_IP`
- API: `http://YOUR_SERVER_IP/api/`
- MQTT: `YOUR_SERVER_IP:1883`
- MQTT WebSocket: `YOUR_SERVER_IP:9001`
---
## 🔧 Troubleshooting
```bash
docker compose ps
docker compose logs -f server
docker compose restart server
```
Häufige Ursachen:
| Problem | Lösung |
|---------|--------|
| Container startet nicht | `docker compose logs <service>` prüfen |
| Ports belegt | `ss -tulpn | grep -E ':80|:443|:1883|:9001'` |
| Keine Berechtigung Docker | User zur Gruppe `docker` hinzufügen & neu einloggen |
| DB-Verbindung schlägt fehl | `.env` Einträge prüfen (Host = db) |
| Mosquitto Fehler | Ordner-Berechtigungen (`1883:1883`) prüfen |
System Neustart / Update des Stacks:
```bash
docker compose down
docker compose pull
docker compose up -d
```
---
## 📝 Wartung
### Updates
```bash
docker compose pull
docker compose up -d
sudo apt update && sudo apt upgrade -y
```
### Backup (abhängig von persistenter Datenhaltung hier nur Mosquitto + Certs exemplarisch)
```bash
docker compose down
sudo tar -czf infoscreen-backup-$(date +%Y%m%d).tar.gz mosquitto/data/ certs/
# Wiederherstellung
sudo tar -xzf infoscreen-backup-YYYYMMDD.tar.gz
docker compose up -d
```
---
**Das Infoscreen-System ist jetzt auf Ihrem Debian-Server bereitgestellt.**
Bei Verbesserungswünschen oder Problemen: Issues / Pull Requests im Repository willkommen.

417
deployment-ubuntu.md Normal file
View File

@@ -0,0 +1,417 @@
# Infoscreen Deployment Guide
Komplette Anleitung für das Deployment des Infoscreen-Systems auf einem Ubuntu-Server mit GitHub Container Registry.
## 📋 Übersicht
- **Phase 0**: Docker Installation (optional)
- **Phase 1**: Images bauen und zur Registry pushen
- **Phase 2**: Ubuntu-Server Installation
- **Phase 3**: System-Konfiguration und Start
---
## 🐳 Phase 0: Docker Installation (optional)
Falls Docker noch nicht installiert ist, wählen Sie eine der folgenden Optionen:
### Option A: Ubuntu Repository (schnell)
```bash
# Standard Ubuntu Docker-Pakete
sudo apt update
sudo apt install docker.io docker-compose-plugin -y
sudo systemctl enable docker
sudo systemctl start docker
```
### Option B: Offizielle Docker-Installation (empfohlen)
```bash
# Alte Docker-Versionen entfernen
sudo apt remove docker docker-engine docker.io containerd runc -y
# Abhängigkeiten installieren
sudo apt update
sudo apt install ca-certificates curl gnupg lsb-release -y
# Docker GPG-Key hinzufügen
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
# Docker Repository hinzufügen
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Docker installieren (neueste Version)
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y
# Docker aktivieren und starten
sudo systemctl enable docker
sudo systemctl start docker
# User zur Docker-Gruppe hinzufügen
sudo usermod -aG docker $USER
# Neuanmeldung für Gruppenänderung erforderlich
exit
# Neu einloggen via SSH
```
### Docker-Installation testen
```bash
# Test-Container ausführen
docker run hello-world
# Docker-Version prüfen
docker --version
docker compose version
```
---
## 🏗️ Phase 1: Images bauen und pushen (Entwicklungsmaschine)
### 1. GitHub Container Registry Login
```bash
# GitHub Personal Access Token mit write:packages Berechtigung erstellen
echo $GITHUB_TOKEN | docker login ghcr.io -u robbstarkaustria --password-stdin
# Oder interaktiv:
docker login ghcr.io
# Username: robbstarkaustria
# Password: [GITHUB_TOKEN]
```
### 2. Images bauen und taggen
```bash
cd /workspace
# Server-Image bauen
docker build -f server/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-api:latest .
# Dashboard-Image bauen
docker build -f dashboard/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-dashboard:latest .
# Listener-Image bauen (falls vorhanden)
docker build -f listener/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-listener:latest .
# Scheduler-Image bauen (falls vorhanden)
docker build -f scheduler/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-scheduler:latest .
```
### 3. Images zur Registry pushen
```bash
# Alle Images pushen
docker push ghcr.io/robbstarkaustria/infoscreen-api:latest
docker push ghcr.io/robbstarkaustria/infoscreen-dashboard:latest
docker push ghcr.io/robbstarkaustria/infoscreen-listener:latest
docker push ghcr.io/robbstarkaustria/infoscreen-scheduler:latest
# Status prüfen
docker images | grep ghcr.io
```
---
## 🖥️ Phase 2: Ubuntu-Server Installation
### 4. Ubuntu Server vorbereiten
```bash
sudo apt update && sudo apt upgrade -y
# Grundlegende Tools installieren
sudo apt install git curl wget -y
# Docker installieren (siehe Phase 0)
```
### 5. Deployment-Dateien übertragen
```bash
# Deployment-Ordner erstellen
mkdir -p ~/infoscreen-deployment
cd ~/infoscreen-deployment
# Dateien vom Dev-System kopieren (über SCP)
scp user@dev-machine:/workspace/docker-compose.prod.yml .
scp user@dev-machine:/workspace/.env .
scp user@dev-machine:/workspace/nginx.conf .
scp -r user@dev-machine:/workspace/certs ./
scp -r user@dev-machine:/workspace/mosquitto ./
# Alternative: Deployment-Paket verwenden
# Auf Dev-Maschine (/workspace):
# tar -czf infoscreen-deployment.tar.gz docker-compose.prod.yml .env nginx.conf certs/ mosquitto/
# scp infoscreen-deployment.tar.gz user@server:~/
# Auf Server: tar -xzf infoscreen-deployment.tar.gz
```
### 6. Mosquitto-Konfiguration vorbereiten
```bash
# Falls mosquitto-Ordner noch nicht vollständig vorhanden:
mkdir -p mosquitto/{config,data,log}
# Mosquitto-Konfiguration erstellen (falls nicht übertragen)
cat > mosquitto/config/mosquitto.conf << 'EOF'
# -----------------------------
# Netzwerkkonfiguration
# -----------------------------
listener 1883
allow_anonymous true
# password_file /mosquitto/config/passwd
# WebSocket (optional)
listener 9001
protocol websockets
# -----------------------------
# Persistence & Pfade
# -----------------------------
persistence true
persistence_location /mosquitto/data/
log_dest file /mosquitto/log/mosquitto.log
EOF
# Berechtigungen für Mosquitto setzen
sudo chown -R 1883:1883 mosquitto/data mosquitto/log
chmod 755 mosquitto/config mosquitto/data mosquitto/log
```
### 7. Environment-Variablen anpassen
```bash
# .env für Produktionsumgebung anpassen
nano .env
# Wichtige Anpassungen:
# VITE_API_URL=https://YOUR_SERVER_HOST/api # Für Dashboard-Build (Production)
# DB_HOST=db # In Containern immer 'db'
# DB_CONN=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}
# Alle Passwörter für Produktion ändern
```
Hinweise:
- Eine Vorlage `.env.example` liegt im Repo. Kopiere sie als Ausgangspunkt: `cp .env.example .env`.
- Für lokale Entwicklung lädt `server/database.py` die `.env`, wenn `ENV=development` gesetzt ist.
- In Produktion verwaltet Compose/Container die Variablen; kein automatisches `.env`-Load im Code nötig.
---
## 🚀 Phase 3: System-Start und Konfiguration
### 8. Images von Registry pullen
```bash
# GitHub Container Registry Login (falls private Repository)
echo $GITHUB_TOKEN | docker login ghcr.io -u robbstarkaustria --password-stdin
# Images pullen
docker compose -f docker-compose.prod.yml pull
```
### 9. System starten
```bash
# Container starten
docker compose -f docker-compose.prod.yml up -d
# Status prüfen
docker compose ps
docker compose logs -f
```
### 10. Firewall konfigurieren
```bash
sudo ufw enable
sudo ufw allow ssh
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 1883/tcp # MQTT
sudo ufw allow 9001/tcp # MQTT WebSocket
sudo ufw status
```
### 11. Installation validieren
```bash
# Health-Checks
curl http://localhost/api/health
curl https://localhost -k # -k für selbstsignierte Zertifikate
# Container-Status
docker compose ps
# Logs bei Problemen anzeigen
docker compose logs server
docker compose logs dashboard
docker compose logs mqtt
```
---
## 🧪 Quickstart (Entwicklung)
Schneller Start der Entwicklungsumgebung mit automatischen Proxys und Hot-Reload.
```bash
# Im Repository-Root
# 1) .env aus Vorlage erzeugen (lokal, falls noch nicht vorhanden)
cp -n .env.example .env
# 2) Dev-Stack starten (verwendet docker-compose.yml + docker-compose.override.yml)
docker compose up -d --build
# 3) Status & Logs
docker compose ps
docker compose logs -f server
docker compose logs -f dashboard
docker compose logs -f mqtt
# 4) Stack stoppen
docker compose down
```
Erreichbarkeit (Dev):
- Dashboard (Vite): http://localhost:5173
- API (Flask Dev): http://localhost:8000/api
- API Health: http://localhost:8000/health
- Screenshots: http://localhost:8000/screenshots/<uuid>.jpg
- MQTT: localhost:1883 (WebSocket: localhost:9001)
Hinweise:
- `ENV=development` lädt `.env` automatisch in `server/database.py`.
- Vite proxy routet `/api` und `/screenshots` in Dev direkt auf die API (siehe `dashboard/vite.config.ts`).
### 12. Automatischer Start (optional)
```bash
# Systemd-Service erstellen
sudo tee /etc/systemd/system/infoscreen.service > /dev/null << 'EOF'
[Unit]
Description=Infoscreen Application
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/home/$USER/infoscreen-deployment
ExecStart=/usr/bin/docker compose -f docker-compose.prod.yml up -d
ExecStop=/usr/bin/docker compose -f docker-compose.prod.yml down
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target
EOF
# Service aktivieren
sudo systemctl enable infoscreen.service
sudo systemctl start infoscreen.service
```
---
## 🌐 Zugriff auf die Anwendung
Nach erfolgreichem Deployment ist die Anwendung unter folgenden URLs erreichbar:
- **HTTPS Dashboard**: `https://YOUR_SERVER_IP`
- **HTTP Dashboard**: `http://YOUR_SERVER_IP` (Redirect zu HTTPS)
- **API**: `http://YOUR_SERVER_IP/api/`
- **MQTT**: `YOUR_SERVER_IP:1883`
- **MQTT WebSocket**: `YOUR_SERVER_IP:9001`
---
## 🔧 Troubleshooting
### Container-Status prüfen
```bash
# Alle Container anzeigen
docker compose ps
# Spezifische Logs anzeigen
docker compose logs -f [service-name]
# Container einzeln neustarten
docker compose restart [service-name]
```
### System neustarten
```bash
# Komplett neu starten
docker compose down
docker compose up -d
# Images neu pullen
docker compose pull
docker compose up -d
```
### Häufige Probleme
| Problem | Lösung |
|---------|--------|
| Container startet nicht | `docker compose logs [service]` prüfen |
| Ports bereits belegt | `sudo netstat -tulpn \| grep :80` prüfen |
| Keine Berechtigung | User zu docker-Gruppe hinzufügen |
| DB-Verbindung fehlschlägt | Environment-Variablen in `.env` prüfen |
| Mosquitto startet nicht | Ordner-Berechtigungen für `1883:1883` setzen |
---
## 📊 Docker-Version Vergleich
| Aspekt | Ubuntu Repository | Offizielle Installation |
|--------|------------------|------------------------|
| **Installation** | ✅ Schnell (1 Befehl) | ⚠️ Mehrere Schritte |
| **Version** | ⚠️ Oft älter | ✅ Neueste Version |
| **Updates** | ✅ Via apt | ✅ Via apt (nach Setup) |
| **Stabilität** | ✅ Getestet | ✅ Aktuell |
| **Features** | ⚠️ Möglicherweise eingeschränkt | ✅ Alle Features |
**Empfehlung:** Für Produktion die offizielle Docker-Installation verwenden.
---
## 📝 Wartung
### Regelmäßige Updates
```bash
# Images aktualisieren
docker compose pull
docker compose up -d
# System-Updates
sudo apt update && sudo apt upgrade -y
```
### Backup
```bash
# Container-Daten sichern
docker compose down
sudo tar -czf infoscreen-backup-$(date +%Y%m%d).tar.gz mosquitto/data/ certs/
# Backup wiederherstellen
sudo tar -xzf infoscreen-backup-YYYYMMDD.tar.gz
docker compose up -d
```
---
**Das Infoscreen-System ist jetzt vollständig über GitHub

143
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,143 @@
networks:
infoscreen-net:
driver: bridge
services:
proxy:
image: nginx:1.25
container_name: infoscreen-proxy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
- server
- dashboard
networks:
- infoscreen-net
db:
image: mariadb:11.2
container_name: infoscreen-db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
volumes:
- db-data:/var/lib/mysql
networks:
- infoscreen-net
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
mqtt:
image: eclipse-mosquitto:2.0.21
container_name: infoscreen-mqtt
restart: unless-stopped
volumes:
- ./mosquitto/config/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
ports:
- "1883:1883"
- "9001:9001"
networks:
- infoscreen-net
healthcheck:
test: ["CMD-SHELL", "mosquitto_pub -h localhost -t test -m 'health' || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
# Verwende fertige Images statt Build
server:
image: ghcr.io/robbstarkaustria/infoscreen-api:latest
container_name: infoscreen-api
restart: unless-stopped
depends_on:
db:
condition: service_healthy
mqtt:
condition: service_healthy
environment:
DB_CONN: "mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}"
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
DB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
DB_HOST: db
FLASK_ENV: production
MQTT_BROKER_URL: mqtt://mqtt:1883
MQTT_USER: ${MQTT_USER}
MQTT_PASSWORD: ${MQTT_PASSWORD}
networks:
- infoscreen-net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
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"
dashboard:
image: ghcr.io/robbstarkaustria/infoscreen-dashboard:latest # Oder wo auch immer Ihre Images liegen
container_name: infoscreen-dashboard
restart: unless-stopped
depends_on:
server:
condition: service_healthy
environment:
NODE_ENV: production
VITE_API_URL: ${API_URL}
networks:
- infoscreen-net
listener:
image: ghcr.io/robbstarkaustria/infoscreen-listener:latest # Oder wo auch immer Ihre Images liegen
container_name: infoscreen-listener
restart: unless-stopped
depends_on:
db:
condition: service_healthy
mqtt:
condition: service_healthy
environment:
DB_CONN: "mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}"
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
DB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
networks:
- infoscreen-net
scheduler:
image: ghcr.io/robbstarkaustria/infoscreen-scheduler:latest
container_name: infoscreen-scheduler
restart: unless-stopped
depends_on:
# HINZUGEFÜGT: Stellt sicher, dass die DB vor dem Scheduler startet
db:
condition: service_healthy
mqtt:
condition: service_healthy
environment:
# HINZUGEFÜGT: Datenbank-Verbindungsstring
DB_CONN: "mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}"
MQTT_PORT: 1883
networks:
- infoscreen-net
volumes:
db-data:

View File

@@ -3,15 +3,34 @@ networks:
driver: bridge
services:
listener:
build:
context: .
dockerfile: listener/Dockerfile
image: infoscreen-listener:latest
container_name: infoscreen-listener
restart: unless-stopped
depends_on:
db:
condition: service_healthy
mqtt:
condition: service_healthy
environment:
- DB_CONN=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}
- DB_URL=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}
# 🔧 ENTFERNT: Volume-Mount ist nur für die Entwicklung
networks:
- infoscreen-net
proxy:
image: nginx:1.27
image: nginx:1.25 # 🔧 GEÄNDERT: Spezifische Version
container_name: infoscreen-proxy
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
- ./nginx.conf:/etc/nginx/nginx.conf:ro # 🔧 GEÄNDERT: Relativer Pfad
- ./certs:/etc/nginx/certs:ro # 🔧 GEÄNDERT: Relativer Pfad
depends_on:
- server
- dashboard
@@ -19,7 +38,7 @@ services:
- infoscreen-net
db:
image: mariadb:11.4.7
image: mariadb:11.2 # 🔧 GEÄNDERT: Spezifische Version
container_name: infoscreen-db
restart: unless-stopped
environment:
@@ -41,7 +60,7 @@ services:
start_period: 30s
mqtt:
image: eclipse-mosquitto:2.0.21
image: eclipse-mosquitto:2.0.21 # ✅ GUT: Version ist bereits spezifisch
container_name: infoscreen-mqtt
restart: unless-stopped
volumes:
@@ -49,12 +68,16 @@ services:
- ./mosquitto/data:/mosquitto/data
- ./mosquitto/log:/mosquitto/log
ports:
- "1883:1883" # Standard MQTT
- "9001:9001" # WebSocket (falls benötigt)
- "1883:1883" # Standard MQTT
- "9001:9001" # WebSocket (falls benötigt)
networks:
- infoscreen-net
healthcheck:
test: ["CMD-SHELL", "mosquitto_pub -h localhost -t test -m 'health' || exit 1"]
test:
[
"CMD-SHELL",
"mosquitto_pub -h localhost -t test -m 'health' || exit 1",
]
interval: 30s
timeout: 5s
retries: 3
@@ -62,8 +85,8 @@ services:
server:
build:
context: ./server
dockerfile: Dockerfile
context: .
dockerfile: server/Dockerfile
image: infoscreen-api:latest
container_name: infoscreen-api
restart: unless-stopped
@@ -79,10 +102,14 @@ services:
MQTT_BROKER_URL: ${MQTT_BROKER_URL}
MQTT_USER: ${MQTT_USER}
MQTT_PASSWORD: ${MQTT_PASSWORD}
REDIS_URL: "${REDIS_URL:-redis://redis:6379/0}"
GOTENBERG_URL: "${GOTENBERG_URL:-http://gotenberg:3000}"
ports:
- "8000:8000"
networks:
- infoscreen-net
volumes:
- media-data:/app/server/media
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
@@ -95,9 +122,7 @@ services:
build:
context: ./dashboard
dockerfile: Dockerfile
# ✅ HINZUGEFÜGT: Build-Args für React Production Build
args:
- NODE_ENV=production
- VITE_API_URL=${API_URL}
image: infoscreen-dashboard:latest
container_name: infoscreen-dashboard
@@ -106,20 +131,79 @@ services:
server:
condition: service_healthy
environment:
# ✅ GEÄNDERT: React-spezifische Umgebungsvariablen
- VITE_API_URL=${API_URL}
- NODE_ENV=production
ports:
- "3000:3000" # ✅ GEÄNDERT: Standard React/Vite Port
- VITE_API_URL=${API_URL}
# 🔧 ENTFERNT: Port wird in Produktion nicht direkt freigegeben, Zugriff via Proxy
networks:
- infoscreen-net
# ✅ GEÄNDERT: Healthcheck für React App
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/"]
# 🔧 GEÄNDERT: Healthcheck prüft den Nginx-Server im Container
test: ["CMD", "curl", "-f", "http://localhost/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
# 🔧 ERHÖHT: Gibt dem Backend mehr Zeit zum Starten, bevor dieser
# Container als "gesund" markiert wird.
start_period: 60s
scheduler:
build:
context: .
dockerfile: scheduler/Dockerfile
image: infoscreen-scheduler:latest
container_name: infoscreen-scheduler
restart: unless-stopped
depends_on:
# HINZUGEFÜGT: Stellt sicher, dass die DB vor dem Scheduler startet
db:
condition: service_healthy
mqtt:
condition: service_healthy
environment:
# HINZUGEFÜGT: Datenbank-Verbindungsstring
- DB_CONN=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}
- MQTT_PORT=1883
networks:
- infoscreen-net
volumes:
- ./scheduler:/app/scheduler
redis:
image: redis:7-alpine
container_name: infoscreen-redis
restart: unless-stopped
networks:
- infoscreen-net
gotenberg:
image: gotenberg/gotenberg:8
container_name: infoscreen-gotenberg
restart: unless-stopped
networks:
- infoscreen-net
worker:
build:
context: .
dockerfile: server/Dockerfile
image: infoscreen-worker:latest
container_name: infoscreen-worker
restart: unless-stopped
depends_on:
- redis
- gotenberg
- db
environment:
DB_CONN: "mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}"
REDIS_URL: "${REDIS_URL:-redis://redis:6379/0}"
GOTENBERG_URL: "${GOTENBERG_URL:-http://gotenberg:3000}"
PYTHONPATH: /app
command: ["rq", "worker", "conversions"]
networks:
- infoscreen-net
volumes:
db-data:
server-pip-cache:
db-data:
media-data:

100
early-validation.sh Normal file
View File

@@ -0,0 +1,100 @@
#!/bin/bash
# Early Hardware Validation für 25% Entwicklungsstand
# Ziel: Architektur-Probleme früh erkennen, nicht Volltest
echo "🧪 Infoscreen Early Hardware Validation"
echo "======================================"
echo "Entwicklungsstand: ~25-30%"
echo "Ziel: Basis-Deployment + Performance-Baseline"
echo ""
# Phase 1: Quick-Setup (30 Min)
echo "📦 Phase 1: Container-Setup-Test"
echo "- Docker-Compose startet alle Services?"
echo "- Health-Checks werden grün?"
echo "- Ports sind erreichbar?"
echo ""
# Phase 2: Connectivity-Test (1 Stunde)
echo "🌐 Phase 2: Service-Kommunikation"
echo "- Database-Connection vom Server?"
echo "- MQTT-Broker empfängt Messages?"
echo "- Nginx routet zu Services?"
echo "- API-Grundendpoints antworten?"
echo ""
# Phase 3: Performance-Baseline (2 Stunden)
echo "📊 Phase 3: Performance-Snapshot"
echo "- Memory-Verbrauch pro Container"
echo "- CPU-Usage im Idle"
echo "- Startup-Zeiten messen"
echo "- Network-Latency zwischen Services"
echo ""
# Phase 4: Basic Load-Test (4 Stunden)
echo "🔥 Phase 4: Basis-Belastungstest"
echo "- 10 parallele API-Requests"
echo "- 1000 MQTT-Messages senden"
echo "- Database-Insert-Performance"
echo "- Memory-Leak-Check (1h Laufzeit)"
echo ""
# Test-Checklist erstellen
cat > early-validation-checklist.md << 'EOF'
# Early Hardware Validation Checklist
## ✅ Container-Setup
- [ ] `docker compose up -d` erfolgreich
- [ ] Alle Services zeigen "healthy" Status
- [ ] Keine Error-Logs in den ersten 5 Minuten
- [ ] Ports 80, 8000, 3306, 1883 erreichbar
## ✅ Service-Kommunikation
- [ ] Server kann zu Database verbinden
- [ ] MQTT-Test-Message wird empfangen
- [ ] Nginx zeigt Service-Status-Page
- [ ] API-Health-Endpoint antwortet (200 OK)
## ✅ Performance-Baseline
- [ ] Total Memory < 4GB bei Idle
- [ ] CPU-Usage < 10% bei Idle
- [ ] Container-Startup < 60s
- [ ] API-Response-Time < 500ms
## ✅ Basic-Load-Test
- [ ] 10 parallele Requests ohne Errors
- [ ] 1000 MQTT-Messages ohne Message-Loss
- [ ] Memory-Usage stabil über 1h
- [ ] Keine Container-Restarts
## 📊 Baseline-Metriken (dokumentieren)
- Memory pro Container: ___MB
- CPU-Usage bei Load: ___%
- API-Response-Time: ___ms
- Database-Query-Time: ___ms
- Container-Startup-Zeit: ___s
## 🚨 Gefundene Probleme
- [ ] Performance-Bottlenecks: ____________
- [ ] Memory-Issues: ____________________
- [ ] Network-Probleme: _________________
- [ ] Container-Probleme: _______________
## ✅ Architektur-Validierung
- [ ] Container-Orchestrierung funktioniert
- [ ] Service-Discovery läuft
- [ ] Volume-Mounting korrekt
- [ ] Environment-Variables werden geladen
- [ ] Health-Checks sind aussagekräftig
EOF
echo "✅ Early Validation Checklist erstellt: early-validation-checklist.md"
echo ""
echo "🎯 Erwartetes Ergebnis:"
echo "- Architektur-Probleme identifiziert"
echo "- Performance-Baseline dokumentiert"
echo "- Deployment-Prozess validiert"
echo "- Basis für spätere Tests gelegt"
echo ""
echo "⏰ Geschätzter Aufwand: 8-12 Stunden über 2-3 Tage"
echo "💰 ROI: Verhindert teure Architektur-Änderungen später"

140
hardware-test-setup.sh Normal file
View File

@@ -0,0 +1,140 @@
#!/bin/bash
# Infoscreen Hardware Test Setup für Quad-Core 16GB System
echo "🖥️ Infoscreen Hardware Test Setup"
echo "=================================="
echo "System: Quad-Core, 16GB RAM, SSD"
echo ""
# System-Info anzeigen
echo "📊 System-Information:"
echo "CPU Cores: $(nproc)"
echo "RAM Total: $(free -h | grep Mem | awk '{print $2}')"
echo "Disk Free: $(df -h / | tail -1 | awk '{print $4}')"
echo ""
# Docker-Setup
echo "🐳 Docker-Setup..."
sudo apt update -y
sudo apt install -y docker.io docker-compose-plugin
sudo systemctl enable docker
sudo systemctl start docker
sudo usermod -aG docker $USER
# Test-Verzeichnisse erstellen
echo "📁 Test-Umgebung erstellen..."
mkdir -p ~/infoscreen-hardware-test/{prod,dev,monitoring,scripts,backups}
# Performance-Monitoring-Tools
echo "📊 Monitoring-Tools installieren..."
sudo apt install -y htop iotop nethogs ncdu stress-ng
# Test-Script erstellen
cat > ~/infoscreen-hardware-test/scripts/system-monitor.sh << 'EOF'
#!/bin/bash
# System-Monitoring während Tests
echo "=== Infoscreen System Monitor ==="
echo "Zeit: $(date)"
echo ""
echo "🖥️ CPU-Info:"
echo "Load: $(uptime | awk -F'load average:' '{print $2}')"
echo "Cores: $(nproc) | Usage: $(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d'%' -f1)%"
echo ""
echo "💾 Memory-Info:"
free -h
echo ""
echo "💿 Disk-Info:"
df -h /
echo ""
echo "🐳 Docker-Info:"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
echo ""
echo "🌡️ System-Temperature (falls verfügbar):"
sensors 2>/dev/null || echo "lm-sensors nicht installiert"
echo ""
echo "🌐 Network-Connections:"
ss -tuln | grep :80\\\|:443\\\|:8000\\\|:3306\\\|:1883
EOF
chmod +x ~/infoscreen-hardware-test/scripts/system-monitor.sh
# Load-Test-Script erstellen
cat > ~/infoscreen-hardware-test/scripts/load-test.sh << 'EOF'
#!/bin/bash
# Load-Test für Infoscreen-System
echo "🔥 Infoscreen Load-Test startet..."
# CPU-Load erzeugen (für Thermal-Tests)
echo "CPU-Stress-Test (30s)..."
stress-ng --cpu $(nproc) --timeout 30s &
# Memory-Test
echo "Memory-Stress-Test..."
stress-ng --vm 2 --vm-bytes 2G --timeout 30s &
# Disk-I/O-Test
echo "Disk-I/O-Test..."
stress-ng --hdd 1 --hdd-bytes 1G --timeout 30s &
# Warten auf Tests
wait
echo "✅ Load-Test abgeschlossen"
EOF
chmod +x ~/infoscreen-hardware-test/scripts/load-test.sh
# Docker-Test-Setup
echo "🧪 Docker-Test-Setup..."
cat > ~/infoscreen-hardware-test/docker-compose.test.yml << 'EOF'
version: '3.8'
services:
test-web:
image: nginx:alpine
ports: ["8080:80"]
deploy:
resources:
limits:
memory: 256M
reservations:
memory: 128M
test-db:
image: mariadb:11.2
environment:
MYSQL_ROOT_PASSWORD: test123
MYSQL_DATABASE: testdb
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
test-load:
image: alpine
command: sh -c "while true; do wget -q -O- http://test-web/ > /dev/null; sleep 0.1; done"
depends_on: [test-web]
EOF
echo ""
echo "✅ Setup abgeschlossen!"
echo ""
echo "🚀 Nächste Schritte:"
echo "1. Logout/Login für Docker-Gruppe"
echo "2. Test: docker run hello-world"
echo "3. System-Monitor: ~/infoscreen-hardware-test/scripts/system-monitor.sh"
echo "4. Load-Test: ~/infoscreen-hardware-test/scripts/load-test.sh"
echo "5. Docker-Test: cd ~/infoscreen-hardware-test && docker compose -f docker-compose.test.yml up"
echo ""
echo "📁 Test-Verzeichnis: ~/infoscreen-hardware-test/"
echo "📊 Monitoring: Führen Sie system-monitor.sh parallel zu Tests aus"

4
listener/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
__pycache__/
*.pyc
*.pyo
*.log

16
listener/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
# Listener Dockerfile
FROM python:3.13-slim
WORKDIR /app
COPY listener/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Mosquitto-Tools für MQTT-Tests installieren
RUN apt-get update && apt-get install -y --no-install-recommends mosquitto-clients && rm -rf /var/lib/apt/lists/*
COPY listener/ ./listener
COPY models/ ./models
ENV PYTHONPATH=/app
CMD ["python", "listener/listener.py"]

102
listener/listener.py Normal file
View File

@@ -0,0 +1,102 @@
import os
import json
import logging
import datetime
import paho.mqtt.client as mqtt
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from models.models import Client
if os.getenv("ENV", "development") == "development":
from dotenv import load_dotenv
load_dotenv(".env")
# ENV-abhängige Konfiguration
ENV = os.getenv("ENV", "development")
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO" if ENV == "production" else "DEBUG")
DB_URL = os.environ.get(
"DB_CONN", "mysql+pymysql://user:password@db/infoscreen")
# Logging
logging.basicConfig(level=logging.DEBUG,
format='%(asctime)s [%(levelname)s] %(message)s')
# DB-Konfiguration
engine = create_engine(DB_URL)
Session = sessionmaker(bind=engine)
def on_message(client, userdata, msg):
topic = msg.topic
logging.debug(f"Empfangene Nachricht auf Topic: {topic}")
try:
# Heartbeat-Handling
if topic.startswith("infoscreen/") and topic.endswith("/heartbeat"):
uuid = topic.split("/")[1]
session = Session()
client_obj = session.query(Client).filter_by(uuid=uuid).first()
if client_obj:
client_obj.last_alive = datetime.datetime.now(datetime.UTC)
session.commit()
logging.info(
f"Heartbeat von {uuid} empfangen, last_alive (UTC) aktualisiert.")
session.close()
return
# Discovery-Handling
if topic == "infoscreen/discovery":
payload = json.loads(msg.payload.decode())
logging.info(f"Discovery empfangen: {payload}")
if "uuid" in payload:
uuid = payload["uuid"]
session = Session()
existing = session.query(Client).filter_by(uuid=uuid).first()
if not existing:
new_client = Client(
uuid=uuid,
hardware_token=payload.get("hardware_token"),
ip=payload.get("ip"),
type=payload.get("type"),
hostname=payload.get("hostname"),
os_version=payload.get("os_version"),
software_version=payload.get("software_version"),
macs=",".join(payload.get("macs", [])),
model=payload.get("model"),
registration_time=datetime.datetime.now(datetime.UTC),
)
session.add(new_client)
session.commit()
logging.info(f"Neuer Client registriert: {uuid}")
else:
logging.info(f"Client bereits bekannt: {uuid}")
session.close()
# Discovery-ACK senden
ack_topic = f"infoscreen/{uuid}/discovery_ack"
client.publish(ack_topic, json.dumps({"status": "ok"}))
logging.info(f"Discovery-ACK gesendet an {ack_topic}")
else:
logging.warning("Discovery ohne UUID empfangen, ignoriert.")
except Exception as e:
logging.error(f"Fehler bei Verarbeitung: {e}")
def main():
mqtt_client = mqtt.Client(protocol=mqtt.MQTTv311, callback_api_version=2)
mqtt_client.on_message = on_message
mqtt_client.connect("mqtt", 1883)
mqtt_client.subscribe("infoscreen/discovery")
mqtt_client.subscribe("infoscreen/+/heartbeat")
logging.info(
"Listener gestartet und abonniert auf infoscreen/discovery und infoscreen/+/heartbeat")
mqtt_client.loop_forever()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,4 @@
paho-mqtt>=2.0
SQLAlchemy>=2.0
pymysql
python-dotenv

1
models/__init__.py Normal file
View File

@@ -0,0 +1 @@
# models package for shared SQLAlchemy models

286
models/models.py Normal file
View File

@@ -0,0 +1,286 @@
from sqlalchemy import (
Column, Integer, String, Enum, TIMESTAMP, func, Boolean, ForeignKey, Float, Text, Index, DateTime, Date, UniqueConstraint
)
from sqlalchemy.orm import declarative_base, relationship
import enum
from datetime import datetime, timezone
Base = declarative_base()
class UserRole(enum.Enum):
user = "user"
admin = "admin"
superadmin = "superadmin"
class AcademicPeriodType(enum.Enum):
schuljahr = "schuljahr"
semester = "semester"
trimester = "trimester"
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String(50), unique=True, nullable=False, index=True)
password_hash = Column(String(128), nullable=False)
role = Column(Enum(UserRole), nullable=False, default=UserRole.user)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(TIMESTAMP(timezone=True),
server_default=func.current_timestamp())
updated_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp(
), onupdate=func.current_timestamp())
class AcademicPeriod(Base):
__tablename__ = 'academic_periods'
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False) # "Schuljahr 2024/25"
display_name = Column(String(50), nullable=True) # "SJ 24/25" (kurz)
start_date = Column(Date, nullable=False, index=True)
end_date = Column(Date, nullable=False, index=True)
period_type = Column(Enum(AcademicPeriodType),
nullable=False, default=AcademicPeriodType.schuljahr)
# nur eine aktive Periode zur Zeit
is_active = Column(Boolean, default=False, nullable=False)
created_at = Column(TIMESTAMP(timezone=True),
server_default=func.current_timestamp())
updated_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp(
), onupdate=func.current_timestamp())
# Constraint: nur eine aktive Periode zur Zeit
__table_args__ = (
Index('ix_academic_periods_active', 'is_active'),
UniqueConstraint('name', name='uq_academic_periods_name'),
)
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"display_name": self.display_name,
"start_date": self.start_date.isoformat() if self.start_date else None,
"end_date": self.end_date.isoformat() if self.end_date else None,
"period_type": self.period_type.value if self.period_type else None,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class ClientGroup(Base):
__tablename__ = 'client_groups'
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), unique=True, nullable=False)
description = Column(String(255), nullable=True) # Manuell zu setzen
created_at = Column(TIMESTAMP(timezone=True),
server_default=func.current_timestamp())
is_active = Column(Boolean, default=True, nullable=False)
class Client(Base):
__tablename__ = 'clients'
uuid = Column(String(36), primary_key=True, nullable=False)
hardware_token = Column(String(64), nullable=True)
ip = Column(String(45), nullable=True)
type = Column(String(50), nullable=True)
hostname = Column(String(100), nullable=True)
os_version = Column(String(100), nullable=True)
software_version = Column(String(100), nullable=True)
macs = Column(String(255), nullable=True)
model = Column(String(100), nullable=True)
description = Column(String(255), nullable=True) # Manuell zu setzen
registration_time = Column(TIMESTAMP(
timezone=True), server_default=func.current_timestamp(), nullable=False)
last_alive = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp(
), onupdate=func.current_timestamp(), nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
group_id = Column(Integer, ForeignKey(
'client_groups.id'), nullable=False, default=1)
class EventType(enum.Enum):
presentation = "presentation"
website = "website"
video = "video"
message = "message"
other = "other"
webuntis = "webuntis"
class MediaType(enum.Enum):
# Präsentationen
pdf = "pdf"
ppt = "ppt"
pptx = "pptx"
odp = "odp"
# Videos (gängige VLC-Formate)
mp4 = "mp4"
avi = "avi"
mkv = "mkv"
mov = "mov"
wmv = "wmv"
flv = "flv"
webm = "webm"
mpg = "mpg"
mpeg = "mpeg"
ogv = "ogv"
# Bilder (benutzerfreundlich)
jpg = "jpg"
jpeg = "jpeg"
png = "png"
gif = "gif"
bmp = "bmp"
tiff = "tiff"
svg = "svg"
# HTML-Mitteilung
html = "html"
# Webseiten
website = "website"
class Event(Base):
__tablename__ = 'events'
id = Column(Integer, primary_key=True, autoincrement=True)
group_id = Column(Integer, ForeignKey('client_groups.id'), nullable=False, index=True)
academic_period_id = Column(Integer, ForeignKey('academic_periods.id'), nullable=True, index=True) # Optional für Rückwärtskompatibilität
title = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
start = Column(TIMESTAMP(timezone=True), nullable=False, index=True)
end = Column(TIMESTAMP(timezone=True), nullable=False, index=True)
event_type = Column(Enum(EventType), nullable=False)
event_media_id = Column(Integer, ForeignKey('event_media.id'), nullable=True)
autoplay = Column(Boolean, nullable=True) # NEU
loop = Column(Boolean, nullable=True) # NEU
volume = Column(Float, nullable=True) # NEU
slideshow_interval = Column(Integer, nullable=True) # NEU
# Recurrence fields
recurrence_rule = Column(String(255), nullable=True, index=True) # iCalendar RRULE string
recurrence_end = Column(TIMESTAMP(timezone=True), nullable=True, index=True) # When recurrence ends
# Whether recurrences should skip school holidays
skip_holidays = Column(Boolean, nullable=False, server_default='0')
created_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp())
updated_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp(), onupdate=func.current_timestamp())
created_by = Column(Integer, ForeignKey('users.id'), nullable=False)
updated_by = Column(Integer, ForeignKey('users.id'), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
# Add relationships
academic_period = relationship("AcademicPeriod", foreign_keys=[academic_period_id])
event_media = relationship("EventMedia", foreign_keys=[event_media_id])
exceptions = relationship("EventException", back_populates="event", cascade="all, delete-orphan")
# --- EventException: Store exceptions/overrides for recurring events ---
class EventException(Base):
__tablename__ = 'event_exceptions'
id = Column(Integer, primary_key=True, autoincrement=True)
event_id = Column(Integer, ForeignKey('events.id', ondelete='CASCADE'), nullable=False, index=True)
exception_date = Column(Date, nullable=False, index=True) # Date of the exception/override
is_skipped = Column(Boolean, default=False, nullable=False) # If this occurrence is skipped
override_title = Column(String(100), nullable=True)
override_description = Column(Text, nullable=True)
override_start = Column(TIMESTAMP(timezone=True), nullable=True)
override_end = Column(TIMESTAMP(timezone=True), nullable=True)
created_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp())
updated_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp(), onupdate=func.current_timestamp())
event = relationship("Event", back_populates="exceptions")
class EventMedia(Base):
__tablename__ = 'event_media'
id = Column(Integer, primary_key=True, autoincrement=True)
academic_period_id = Column(Integer, ForeignKey(
# Optional für bessere Organisation
'academic_periods.id'), nullable=True, index=True)
media_type = Column(Enum(MediaType), nullable=False)
url = Column(String(255), nullable=False)
file_path = Column(String(255), nullable=True)
message_content = Column(Text, nullable=True)
uploaded_at = Column(TIMESTAMP, nullable=False,
default=lambda: datetime.now(timezone.utc))
# Add relationship
academic_period = relationship(
"AcademicPeriod", foreign_keys=[academic_period_id])
def to_dict(self):
return {
"id": self.id,
"academic_period_id": self.academic_period_id,
"media_type": self.media_type.value if self.media_type else None,
"url": self.url,
"file_path": self.file_path,
"message_content": self.message_content,
}
class SchoolHoliday(Base):
__tablename__ = 'school_holidays'
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(150), nullable=False)
start_date = Column(Date, nullable=False, index=True)
end_date = Column(Date, nullable=False, index=True)
region = Column(String(100), nullable=True, index=True)
source_file_name = Column(String(255), nullable=True)
imported_at = Column(TIMESTAMP(timezone=True),
server_default=func.current_timestamp())
__table_args__ = (
UniqueConstraint('name', 'start_date', 'end_date',
'region', name='uq_school_holidays_unique'),
)
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"start_date": self.start_date.isoformat() if self.start_date else None,
"end_date": self.end_date.isoformat() if self.end_date else None,
"region": self.region,
"source_file_name": self.source_file_name,
"imported_at": self.imported_at.isoformat() if self.imported_at else None,
}
# --- Conversions: Track PPT/PPTX/ODP -> PDF processing state ---
class ConversionStatus(enum.Enum):
pending = "pending"
processing = "processing"
ready = "ready"
failed = "failed"
class Conversion(Base):
__tablename__ = 'conversions'
id = Column(Integer, primary_key=True, autoincrement=True)
# Source media to be converted
source_event_media_id = Column(
Integer,
ForeignKey('event_media.id', ondelete='CASCADE'),
nullable=False,
index=True,
)
target_format = Column(String(10), nullable=False,
index=True) # e.g. 'pdf'
# relative to server/media
target_path = Column(String(512), nullable=True)
status = Column(Enum(ConversionStatus), nullable=False,
default=ConversionStatus.pending)
file_hash = Column(String(64), nullable=False) # sha256 of source file
started_at = Column(TIMESTAMP(timezone=True), nullable=True)
completed_at = Column(TIMESTAMP(timezone=True), nullable=True)
error_message = Column(Text, nullable=True)
__table_args__ = (
# Fast lookup per media/format
Index('ix_conv_source_target', 'source_event_media_id', 'target_format'),
# Operational filtering
Index('ix_conv_status_target', 'status', 'target_format'),
# Idempotency: same source + target + file content should be unique
UniqueConstraint('source_event_media_id', 'target_format',
'file_hash', name='uq_conv_source_target_hash'),
)

View File

@@ -1,7 +1,7 @@
events {}
http {
upstream dashboard {
server 127.0.0.1:3000;
server infoscreen-dashboard:80;
}
upstream infoscreen_api {
server infoscreen-api:8000;

47
nginx.dev.conf Normal file
View File

@@ -0,0 +1,47 @@
events {}
http {
upstream dashboard {
# Vite dev server inside the dashboard container
server infoscreen-dashboard:5173;
}
upstream infoscreen_api {
server infoscreen-api:8000;
}
server {
listen 80;
server_name _;
# Proxy /api and /screenshots to the Flask API
location /api/ {
proxy_pass http://infoscreen_api/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /screenshots/ {
proxy_pass http://infoscreen_api/screenshots/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Everything else to the Vite dev server
location / {
proxy_pass http://dashboard;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket upgrade for Vite HMR
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
}

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