Compare commits
44 Commits
dash-front
...
recurring_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17c3452310 | ||
|
|
e53cc619ec | ||
|
|
773628c324 | ||
|
|
7ab4ea14c4 | ||
|
|
4d807be6f8 | ||
|
|
0601bac243 | ||
|
|
4a97ad4f1d | ||
|
|
1efe40a03b | ||
| 5627829617 | |||
| fc9b3228c4 | |||
| fcc0dfbb0f | |||
| 80bf8bc58d | |||
| eaf6e32446 | |||
| 41194000a4 | |||
| 89d1748100 | |||
| c19f478f11 | |||
| e8d71b8349 | |||
| c5a8571e97 | |||
| 1d23b7591d | |||
| f3b72da9fe | |||
| 75c5622efe | |||
| 4c44b98d53 | |||
| 76629b8e30 | |||
| 86b1bdbd91 | |||
| e30723da0a | |||
| 4e74f72c9f | |||
| 2ca5f0060e | |||
| 270bad5980 | |||
| 49e9f9eade | |||
| 8bbda836b3 | |||
| 4e6451ce80 | |||
| b0e933e895 | |||
| 7f4800496a | |||
| c0202e5802 | |||
| c9fbb38347 | |||
| 2e9f22f5cc | |||
| a1d6d83488 | |||
| 4e525e4bae | |||
| 1a6faaa104 | |||
| 84a92ab9c2 | |||
| f37744b31e | |||
| 661d25d70c | |||
| 2fa84c1e2b | |||
| 7c1f546af9 |
36
.env.example
Normal file
36
.env.example
Normal 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
154
.github/copilot-instructions.md
vendored
Normal 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 today’s month/day within the period year, and refreshes a right-aligned indicator row showing:
|
||||
- Holidays present in the current view (count)
|
||||
- Period label (display_name or name) with a badge indicating whether any holidays exist in that period (overlap check)
|
||||
|
||||
- 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 ≤ 8–10 bullets; summarize or group micro-changes
|
||||
- JSON hygiene: valid JSON, no trailing commas, don’t edit historical entries except typos
|
||||
23
.gitignore
vendored
23
.gitignore
vendored
@@ -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/
|
||||
|
||||
100
AI-INSTRUCTIONS-MAINTENANCE.md
Normal file
100
AI-INSTRUCTIONS-MAINTENANCE.md
Normal 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. 20–50 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 don’t 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
39
CLEANUP_SUMMARY.md
Normal 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
147
DATABASE_GUIDE.md
Normal 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.
|
||||
18
GPU25_26_mit_Herbstferien.TXT
Normal file
18
GPU25_26_mit_Herbstferien.TXT
Normal 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
92
Makefile
Normal 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
465
README.md
Normal file
@@ -0,0 +1,465 @@
|
||||
# Infoscreen 2025
|
||||
|
||||
[](https://www.docker.com/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://flask.palletsprojects.com/)
|
||||
[](https://mariadb.org/)
|
||||
[](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 ≤ 8–10 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.
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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 |
@@ -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 |
@@ -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)"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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":
|
||||
# Dev‐Bypass: setze immer Admin‐Session und leite weiter
|
||||
session["username"] = "dev_admin"
|
||||
session["role"] = "admin"
|
||||
return dcc.Location(href="/overview", id="redirect-dev"), None
|
||||
|
||||
# Produktions‐Login: 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
|
||||
@@ -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 ""
|
||||
@@ -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 ""
|
||||
@@ -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}
|
||||
@@ -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 ""
|
||||
@@ -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")
|
||||
]
|
||||
)
|
||||
@@ -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/Profil‐Button
|
||||
]
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -1,28 +0,0 @@
|
||||
# dashboard/config.py
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# .env aus Root‐Verzeichnis laden
|
||||
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
load_dotenv(os.path.join(base_dir, ".env"))
|
||||
|
||||
# DB‐Einstellungen
|
||||
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"))
|
||||
|
||||
# MQTT‐Einstellungen
|
||||
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")
|
||||
Binary file not shown.
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
)
|
||||
@@ -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")
|
||||
]
|
||||
)
|
||||
@@ -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")
|
||||
]
|
||||
)
|
||||
@@ -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
|
||||
]
|
||||
)
|
||||
@@ -1,5 +0,0 @@
|
||||
import dash
|
||||
from dash import html
|
||||
|
||||
dash.register_page(__name__, path="/test", name="Testseite")
|
||||
layout = html.Div("Testseite funktioniert!")
|
||||
@@ -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")
|
||||
]
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
debugpy
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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 MQTT‐Einstellungen
|
||||
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 Client‐Instanz
|
||||
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. Callback‐Stubs (kannst du bei Bedarf anpassen)
|
||||
def _on_connect(client, userdata, flags, rc):
|
||||
if rc == 0:
|
||||
print(f"[mqtt_client.py] Erfolgreich mit MQTT‐Broker 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 Callback‐Funktion wird aufgerufen, sobald eine Nachricht auf einem
|
||||
Topic ankommt, auf das wir subscribed haben. Du kannst hier eine Queue
|
||||
füllen oder direkt eine Datenbank‐Funktion 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 Live‐Statusdaten 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 Endlos‐Schleife, in der der Client auf eingehende
|
||||
MQTT‐Nachrichten hört und automatisch reconnectet.
|
||||
Muss idealerweise in einem eigenen Thread laufen, damit Dash‐Callbacks
|
||||
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 MQTT‐Broker herstellen: {e}")
|
||||
|
||||
|
||||
def stop_loop():
|
||||
"""
|
||||
Stoppt die MQTT‐Loop und trennt die Verbindung.
|
||||
"""
|
||||
try:
|
||||
client.loop_stop()
|
||||
client.disconnect()
|
||||
except Exception as e:
|
||||
print(f"[mqtt_client.py] Fehler beim Stoppen der MQTT‐Schleife: {e}")
|
||||
|
||||
|
||||
def publish(topic: str, payload: str, qos: int = 0, retain: bool = False) -> bool:
|
||||
"""
|
||||
Verschickt eine MQTT‐Nachricht:
|
||||
- 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 MQTT‐Topic, 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] Subscribe‐Fehler für Topic {topic}, rc={result}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"[mqtt_client.py] Exception beim Subscribe: {e}")
|
||||
return False
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"extends": [
|
||||
"stylelint-config-standard",
|
||||
"stylelint-config-tailwindcss"
|
||||
"stylelint-config-standard"
|
||||
],
|
||||
"rules": {
|
||||
"at-rule-no-unknown": null
|
||||
|
||||
@@ -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;"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
2462
dashboard/package-lock.json
generated
2462
dashboard/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
2310
dashboard/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
107
dashboard/public/program-info.json
Normal file
107
dashboard/public/program-info.json
Normal 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 2–4)",
|
||||
"🔧 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."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
174
dashboard/src/SetupMode.tsx
Normal 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;
|
||||
42
dashboard/src/apiAcademicPeriods.ts
Normal file
42
dashboard/src/apiAcademicPeriods.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type AcademicPeriod = {
|
||||
id: number;
|
||||
name: string;
|
||||
display_name?: string | null;
|
||||
start_date: string; // YYYY-MM-DD
|
||||
end_date: string; // YYYY-MM-DD
|
||||
period_type: 'schuljahr' | 'semester' | 'trimester';
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
async function api<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, { credentials: 'include', ...init });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getAcademicPeriodForDate(date: Date): Promise<AcademicPeriod | null> {
|
||||
const iso = date.toISOString().slice(0, 10);
|
||||
const { period } = await api<{ period: AcademicPeriod | null }>(
|
||||
`/api/academic_periods/for_date?date=${iso}`
|
||||
);
|
||||
return period ?? null;
|
||||
}
|
||||
|
||||
export async function listAcademicPeriods(): Promise<AcademicPeriod[]> {
|
||||
const { periods } = await api<{ periods: AcademicPeriod[] }>(`/api/academic_periods`);
|
||||
return Array.isArray(periods) ? periods : [];
|
||||
}
|
||||
|
||||
export async function getActiveAcademicPeriod(): Promise<AcademicPeriod | null> {
|
||||
const { period } = await api<{ period: AcademicPeriod | null }>(`/api/academic_periods/active`);
|
||||
return period ?? null;
|
||||
}
|
||||
|
||||
export async function setActiveAcademicPeriod(id: number): Promise<AcademicPeriod> {
|
||||
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/active`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
return period;
|
||||
}
|
||||
@@ -1,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();
|
||||
}
|
||||
|
||||
31
dashboard/src/apiEventExceptions.ts
Normal file
31
dashboard/src/apiEventExceptions.ts
Normal 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[];
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
26
dashboard/src/apiHolidays.ts
Normal file
26
dashboard/src/apiHolidays.ts
Normal 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
569
dashboard/src/cldr/ca-gregorian.json
Normal file
569
dashboard/src/cldr/ca-gregorian.json
Normal 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:mm a",
|
||||
"EHm": "E, HH:mm",
|
||||
"Ehms": "E, h:mm:ss a",
|
||||
"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:mm a",
|
||||
"Hm": "HH:mm",
|
||||
"hms": "h:mm:ss a",
|
||||
"Hms": "HH:mm:ss",
|
||||
"hmsv": "h:mm:ss a v",
|
||||
"Hmsv": "HH:mm:ss v",
|
||||
"hmv": "h:mm a 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' B – h 'Uhr' B",
|
||||
"h": "h–h 'Uhr' B"
|
||||
},
|
||||
"Bhm": {
|
||||
"B": "h:mm 'Uhr' B – h:mm 'Uhr' B",
|
||||
"h": "h:mm – h:mm 'Uhr' B",
|
||||
"m": "h:mm – h:mm 'Uhr' B"
|
||||
},
|
||||
"d": {
|
||||
"d": "d.–d."
|
||||
},
|
||||
"Gy": {
|
||||
"G": "y G – y G",
|
||||
"y": "y–y G"
|
||||
},
|
||||
"GyM": {
|
||||
"G": "MM/y G – MM/y G",
|
||||
"M": "MM/y – MM/y G",
|
||||
"y": "MM/y – MM/y G"
|
||||
},
|
||||
"GyMd": {
|
||||
"d": "dd.–dd.MM.y G",
|
||||
"G": "dd.MM.y G – dd.MM.y G",
|
||||
"M": "dd.MM. – dd.MM.y G",
|
||||
"y": "dd.MM.y – dd.MM.y G"
|
||||
},
|
||||
"GyMEd": {
|
||||
"d": "E, dd.MM.y – E, dd.MM.y G",
|
||||
"G": "E, dd.MM.y G – E, dd.MM.y G",
|
||||
"M": "E, dd.MM. – E, dd.MM.y G",
|
||||
"y": "E, dd.MM.y – E, dd.MM.y G"
|
||||
},
|
||||
"GyMMM": {
|
||||
"G": "MMM y G – MMM y G",
|
||||
"M": "MMM–MMM y G",
|
||||
"y": "MMM y – MMM y G"
|
||||
},
|
||||
"GyMMMd": {
|
||||
"d": "d.–d. MMM y G",
|
||||
"G": "d. MMM y G – d. MMM y G",
|
||||
"M": "d. MMM – d. MMM y G",
|
||||
"y": "d. MMM y – d. MMM y G"
|
||||
},
|
||||
"GyMMMEd": {
|
||||
"d": "E, d. – E, d. MMM y G",
|
||||
"G": "E, d. MMM y G – E E, d. MMM y G",
|
||||
"M": "E, d. MMM – E, d. MMM y G",
|
||||
"y": "E, d. MMM y – E, d. MMM y G"
|
||||
},
|
||||
"h": {
|
||||
"a": "h 'Uhr' a – h 'Uhr' a",
|
||||
"h": "h – h 'Uhr' a"
|
||||
},
|
||||
"H": {
|
||||
"H": "HH–HH 'Uhr'"
|
||||
},
|
||||
"hm": {
|
||||
"a": "h:mm a – h:mm a",
|
||||
"h": "h:mm–h:mm a",
|
||||
"m": "h:mm–h:mm a"
|
||||
},
|
||||
"Hm": {
|
||||
"H": "HH:mm–HH:mm 'Uhr'",
|
||||
"m": "HH:mm–HH:mm 'Uhr'"
|
||||
},
|
||||
"hmv": {
|
||||
"a": "h:mm a – h:mm a v",
|
||||
"h": "h:mm–h:mm a v",
|
||||
"m": "h:mm–h:mm a v"
|
||||
},
|
||||
"Hmv": {
|
||||
"H": "HH:mm–HH:mm 'Uhr' v",
|
||||
"m": "HH:mm–HH:mm 'Uhr' v"
|
||||
},
|
||||
"hv": {
|
||||
"a": "h a – h a v",
|
||||
"h": "h–h a v"
|
||||
},
|
||||
"Hv": {
|
||||
"H": "HH–HH 'Uhr' v"
|
||||
},
|
||||
"M": {
|
||||
"M": "MM–MM"
|
||||
},
|
||||
"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": "MMM–MMM"
|
||||
},
|
||||
"MMMd": {
|
||||
"d": "d.–d. MMM",
|
||||
"M": "d. MMM – d. MMM"
|
||||
},
|
||||
"MMMEd": {
|
||||
"d": "E, d. – E, d. MMM",
|
||||
"M": "E, d. MMM – E, d. MMM"
|
||||
},
|
||||
"MMMM": {
|
||||
"M": "LLLL–LLLL"
|
||||
},
|
||||
"y": {
|
||||
"y": "y–y"
|
||||
},
|
||||
"yM": {
|
||||
"M": "M/y – M/y",
|
||||
"y": "M/y – M/y"
|
||||
},
|
||||
"yMd": {
|
||||
"d": "dd.–dd.MM.y",
|
||||
"M": "dd.MM. – dd.MM.y",
|
||||
"y": "dd.MM.y – dd.MM.y"
|
||||
},
|
||||
"yMEd": {
|
||||
"d": "E, dd. – E, dd.MM.y",
|
||||
"M": "E, dd.MM. – E, dd.MM.y",
|
||||
"y": "E, dd.MM.y – E, dd.MM.y"
|
||||
},
|
||||
"yMMM": {
|
||||
"M": "MMM–MMM y",
|
||||
"y": "MMM y – MMM y"
|
||||
},
|
||||
"yMMMd": {
|
||||
"d": "d.–d. MMM y",
|
||||
"M": "d. MMM – d. MMM y",
|
||||
"y": "d. MMM y – d. MMM y"
|
||||
},
|
||||
"yMMMEd": {
|
||||
"d": "E, d. – E, d. MMM y",
|
||||
"M": "E, d. MMM – E, d. MMM y",
|
||||
"y": "E, d. MMM y – E, d. MMM y"
|
||||
},
|
||||
"yMMMM": {
|
||||
"M": "MMMM–MMMM y",
|
||||
"y": "MMMM y – MMMM y"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dateTimeFormats-atTime": {
|
||||
"standard": {
|
||||
"full": "{1} 'um' {0}",
|
||||
"long": "{1} 'um' {0}",
|
||||
"medium": "{1}, {0}",
|
||||
"short": "{1}, {0}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
394
dashboard/src/cldr/numberingSystems.json
Normal file
394
dashboard/src/cldr/numberingSystems.json
Normal 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": "0123456789",
|
||||
"_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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
164
dashboard/src/cldr/numbers.json
Normal file
164
dashboard/src/cldr/numbers.json
Normal 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 …"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1563
dashboard/src/cldr/timeZoneNames.json
Normal file
1563
dashboard/src/cldr/timeZoneNames.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
22
dashboard/src/components/SetupModeButton.tsx
Normal file
22
dashboard/src/components/SetupModeButton.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
36
dashboard/src/hooks/useClientDelete.ts
Normal file
36
dashboard/src/hooks/useClientDelete.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
12
dashboard/src/logout.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
243
dashboard/src/programminfo.tsx
Normal file
243
dashboard/src/programminfo.tsx
Normal 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;
|
||||
2
dashboard/src/types/json.d.ts
vendored
2
dashboard/src/types/json.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
declare module "*.json" {
|
||||
declare module '*.json' {
|
||||
const value: unknown;
|
||||
export default value;
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
module.exports = {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
corePlugins: {
|
||||
preflight: false,
|
||||
},
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -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
24
dashboard/wait-for-backend.sh
Executable 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
336
deployment-debian.md
Normal 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
417
deployment-ubuntu.md
Normal 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
143
docker-compose.prod.yml
Normal 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:
|
||||
@@ -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
100
early-validation.sh
Normal 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
140
hardware-test-setup.sh
Normal 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
4
listener/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.log
|
||||
16
listener/Dockerfile
Normal file
16
listener/Dockerfile
Normal 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
102
listener/listener.py
Normal 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()
|
||||
4
listener/requirements.txt
Normal file
4
listener/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
paho-mqtt>=2.0
|
||||
SQLAlchemy>=2.0
|
||||
pymysql
|
||||
python-dotenv
|
||||
1
models/__init__.py
Normal file
1
models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# models package for shared SQLAlchemy models
|
||||
286
models/models.py
Normal file
286
models/models.py
Normal 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'),
|
||||
)
|
||||
@@ -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
47
nginx.dev.conf
Normal 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
Reference in New Issue
Block a user