Initial commit - copied workspace after database cleanup

This commit is contained in:
RobbStarkAustria
2025-10-10 15:20:14 +00:00
commit 1efe40a03b
142 changed files with 23625 additions and 0 deletions

1
dashboard/.dockerignore Normal file
View File

@@ -0,0 +1 @@
node_modules

34
dashboard/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,34 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
},
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended'
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
plugins: ['react', '@typescript-eslint'],
settings: {
react: {
version: 'detect',
},
},
rules: {
// Beispiele für sinnvolle Anpassungen
'react/react-in-jsx-scope': 'off', // nicht nötig mit React 17+
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
};

9
dashboard/.prettierrc Normal file
View File

@@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 2,
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "avoid"
}

View File

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

25
dashboard/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
# ==========================================
# dashboard/Dockerfile (Production)
# ==========================================
FROM node:20-alpine AS build
WORKDIR /app
# Kopiere package.json und Lockfile aus dem Build-Kontext (./dashboard)
COPY package*.json ./
# Produktions-Abhängigkeiten installieren
ENV NODE_ENV=production
RUN npm ci --omit=dev
# Quellcode kopieren und builden
COPY . .
ARG VITE_API_URL
ENV VITE_API_URL=${VITE_API_URL}
RUN npm run build
FROM nginx:1.25-alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", " -g", "daemon off;"]

28
dashboard/Dockerfile.dev Normal file
View File

@@ -0,0 +1,28 @@
# ==========================================
# dashboard/Dockerfile.dev (Development)
# 🔧 OPTIMIERT: Für schnelle Entwicklung mit Vite und npm
# ==========================================
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
# 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 ./
# 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
EXPOSE 5173 9230
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]

54
dashboard/README.md Normal file
View File

@@ -0,0 +1,54 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```

View File

@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

13
dashboard/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

8177
dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

75
dashboard/package.json Normal file
View File

@@ -0,0 +1,75 @@
{
"name": "dashboard",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@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",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.2"
},
"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",
"@typescript-eslint/eslint-plugin": "^8.34.1",
"@typescript-eslint/parser": "^8.34.1",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21",
"eslint": "^9.29.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.5.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"postcss": "^8.5.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"
}
}

View File

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

View File

@@ -0,0 +1,96 @@
{
"appName": "Infoscreen-Management",
"version": "2025.1.0-alpha.7",
"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.7",
"date": "2025-09-21",
"changes": [
"🧭 UI: Periode-Auswahl (Syncfusion) neben Gruppenauswahl; kompaktes Layout",
"✅ Anzeige: Abzeichen für vorhandenen Ferienplan + Zähler Ferien im Blick",
"🛠️ API: Endpunkte für akademische Perioden (list, active GET/POST, for_date)",
"📅 Scheduler: Standardmäßig keine Terminierung in Ferien; Block-Darstellung wie Ganztagesereignis; schwarze Textfarbe",
"📤 Ferien: Upload von TXT/CSV (headless TXT nutzt Spalten 24)",
"🔧 UX: Schalter in einer Reihe; Dropdown-Breiten optimiert"
]
},
{
"version": "2025.1.0-alpha.6",
"date": "2025-09-20",
"changes": [
"🗓️ NEU: Akademische Perioden System - Unterstützung für Schuljahre, Semester und Trimester",
"🏗️ DATENBANK: Neue 'academic_periods' Tabelle für zeitbasierte Organisation",
"🔗 ERWEITERT: Events und Medien können jetzt optional einer akademischen Periode zugeordnet werden",
"📊 ARCHITEKTUR: Vollständig rückwärtskompatible Implementierung für schrittweise Einführung",
"🎯 BILDUNG: Fokus auf Schulumgebung mit Erweiterbarkeit für Hochschulen",
"⚙️ TOOLS: Automatische Erstellung von Standard-Schuljahren für österreichische Schulen"
]
},
{
"version": "2025.1.0-alpha.5",
"date": "2025-09-14",
"changes": [
"Komplettes Redesign des Backend-Handlings der Gruppenzuordnungen von neuen Clients und der Schritte bei Änderung der Gruppenzuordnung."
]
},
{
"version": "2025.1.0-alpha.4",
"date": "2025-09-01",
"changes": [
"Grundstruktur für Deployment getestet und optimiert.",
"FIX: Programmfehler beim Umschalten der Ansicht auf der Medien-Seite behoben."
]
},
{
"version": "2025.1.0-alpha.3",
"date": "2025-08-30",
"changes": [
"NEU: Programminfo-Seite mit dynamischen Daten, Build-Infos und Changelog.",
"NEU: Logout-Funktionalität implementiert.",
"FIX: Breite der Sidebar im eingeklappten Zustand korrigiert."
]
},
{
"version": "2025.1.0-alpha.2",
"date": "2025-08-29",
"changes": [
"INFO: Analyse und Anzeige der verwendeten Open-Source-Bibliotheken."
]
},
{
"version": "2025.1.0-alpha.1",
"date": "2025-08-28",
"changes": [
"Initiales Setup des Projekts und der Grundstruktur."
]
}
]
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

275
dashboard/src/App.css Normal file
View File

@@ -0,0 +1,275 @@
@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";
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-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 {
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex !important;
width: 100% !important;
box-sizing: border-box;
}
.sidebar-theme .sidebar-logout {
border: none;
cursor: pointer;
text-align: left;
width: 100%;
font-size: 1.15rem;
display: flex !important;
box-sizing: border-box;
}
.sidebar-link:hover,
.sidebar-logout:hover {
background-color: var(--sidebar-hover-bg);
color: var(--sidebar-hover-text);
}
.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,
.e-kanban .e-card .e-card-header {
background-color: var(--sidebar-bg) !important;
color: var(--sidebar-fg) !important;
}
.e-kanban .e-card:hover,
.e-kanban .e-card.e-selection,
.e-kanban .e-card.e-card-active,
.e-kanban .e-card:hover .e-card-content,
.e-kanban .e-card.e-selection .e-card-content,
.e-kanban .e-card.e-card-active .e-card-content,
.e-kanban .e-card:hover .e-card-header,
.e-kanban .e-card.e-selection .e-card-header,
.e-kanban .e-card.e-card-active .e-card-header {
background-color: var(--sidebar-fg) !important;
color: var(--sidebar-bg) !important;
}
/* Optional: Fokus-Style für Tastatur-Navigation */
.e-kanban .e-card:focus {
outline: 2px solid var(--sidebar-fg);
outline-offset: 2px;
}
/* Kanban-Spaltenheader: Hintergrund und Textfarbe überschreiben */
.e-kanban .e-kanban-table .e-header-cells {
background-color: color-mix(in srgb, var(--sidebar-bg) 80%, #fff 20%) !important;
color: var(--sidebar-fg) !important;
font-weight: 700;
font-size: 1.08rem;
border-bottom: 2px solid var(--sidebar-fg);
box-shadow: 0 2px 6px 0 color-mix(in srgb, #78591c 8%, transparent);
letter-spacing: 0.02em;
}
/* Header-Text noch spezifischer and mit !important */
.e-kanban .e-kanban-table .e-header-cells .e-header-text {
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;
}

337
dashboard/src/App.tsx Normal file
View File

@@ -0,0 +1,337 @@
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';
// Lucide Icons importieren
import {
LayoutDashboard,
Calendar,
Boxes,
Image,
User,
Settings,
Monitor,
MonitorDotIcon,
LogOut,
Wrench,
Info,
} from 'lucide-react';
import { ToastProvider } from './components/ToastProvider';
const sidebarItems = [
{ name: 'Dashboard', path: '/', icon: LayoutDashboard },
{ name: 'Termine', path: '/termine', icon: Calendar },
{ name: 'Ressourcen', path: '/ressourcen', icon: Boxes },
{ name: 'Raumgruppen', path: '/infoscr_groups', icon: MonitorDotIcon },
{ name: '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 },
];
// Dummy Components (können in eigene Dateien ausgelagert werden)
import Dashboard from './dashboard';
import Appointments from './appointments';
import Ressourcen from './ressourcen';
import Infoscreens from './clients';
import Infoscreen_groups from './infoscreen_groups';
import Media from './media';
import Benutzer from './benutzer';
import Einstellungen from './einstellungen';
import SetupMode from './SetupMode';
import Programminfo from './programminfo';
import Logout from './logout';
// ENV aus .env holen (Platzhalter, im echten Projekt über process.env oder API)
// const ENV = import.meta.env.VITE_ENV || 'development';
const Layout: React.FC = () => {
const [version, setVersion] = useState('');
const [isCollapsed, setIsCollapsed] = useState(false);
let sidebarRef: SidebarComponent | null;
React.useEffect(() => {
fetch('/program-info.json')
.then(res => res.json())
.then(data => setVersion(data.version))
.catch(err => console.error('Failed to load version info:', err));
}, []);
const toggleSidebar = () => {
if (sidebarRef) {
sidebarRef.toggle();
}
};
const onSidebarChange = () => {
// Syncfusion unterscheidet zwischen isOpen (true/false) und dem Dock-Modus
// Im Dock-Modus ist isOpen=true, aber die Sidebar ist kollabiert
const sidebar = sidebarRef?.element;
if (sidebar) {
const currentWidth = sidebar.style.width;
const newCollapsedState = currentWidth === '60px' || currentWidth.includes('60');
setIsCollapsed(newCollapsedState);
}
};
const sidebarTemplate = () => (
<div
className={`sidebar-theme ${isCollapsed ? 'collapsed' : 'expanded'}`}
style={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
minHeight: '100vh',
overflow: 'hidden',
}}
>
<div
style={{
borderColor: 'var(--sidebar-border)',
height: '68px',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid var(--sidebar-border)',
margin: 0,
padding: 0,
}}
>
<img
src={logo}
alt="Logo"
style={{
height: '64px',
maxHeight: '60px',
display: 'block',
margin: '0 auto',
}}
/>
</div>
<nav
style={{
flex: '1 1 auto',
display: 'flex',
flexDirection: 'column',
marginTop: '1rem',
overflowY: 'auto',
minHeight: 0, // Wichtig für Flex-Shrinking
}}
>
{sidebarItems.map(item => {
const Icon = item.icon;
const linkContent = (
<Link
key={item.path}
to={item.path}
className="sidebar-link no-underline w-full"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
gap: '8px',
padding: '12px 24px',
transition: 'background 0.2s, color 0.2s, justify-content 0.3s',
textDecoration: 'none',
color: 'var(--sidebar-fg)',
backgroundColor: 'var(--sidebar-bg)',
}}
>
<Icon size={22} style={{ flexShrink: 0, marginRight: 0 }} />
<span className="sidebar-text" style={{ marginLeft: 0, transition: 'opacity 0.3s' }}>
{item.name}
</span>
</Link>
);
// Syncfusion Tooltip nur im collapsed state
return isCollapsed ? (
<TooltipComponent
key={item.path}
content={item.name}
position="RightCenter"
opensOn="Hover"
showTipPointer={true}
animation={{
open: { effect: 'FadeIn', duration: 200 },
close: { effect: 'FadeOut', duration: 200 },
}}
>
{linkContent}
</TooltipComponent>
) : (
linkContent
);
})}
</nav>
<div
style={{
flexShrink: 0,
marginTop: 'auto',
display: 'flex',
flexDirection: 'column',
minHeight: 'auto',
}}
>
{(() => {
const logoutContent = (
<Link
to="/logout"
className="sidebar-logout no-underline w-full"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
gap: '8px',
padding: '12px 24px',
transition: 'background 0.2s, color 0.2s, justify-content 0.3s',
textDecoration: 'none',
color: 'var(--sidebar-fg)',
backgroundColor: 'var(--sidebar-bg)',
border: 'none',
cursor: 'pointer',
fontSize: '1.15rem',
}}
>
<LogOut size={22} style={{ flexShrink: 0, marginRight: 0 }} />
<span className="sidebar-text" style={{ marginLeft: 0, transition: 'opacity 0.3s' }}>
Abmelden
</span>
</Link>
);
// Syncfusion Tooltip nur im collapsed state
return isCollapsed ? (
<TooltipComponent
content="Abmelden"
position="RightCenter"
opensOn="Hover"
showTipPointer={true}
animation={{
open: { effect: 'FadeIn', duration: 200 },
close: { effect: 'FadeOut', duration: 200 },
}}
>
{logoutContent}
</TooltipComponent>
) : (
logoutContent
);
})()}
{version && (
<div
className="version-info px-6 py-2 text-xs text-center opacity-70 border-t"
style={{ borderColor: 'var(--sidebar-border)' }}
>
Version {version}
</div>
)}
</div>
</div>
);
return (
<div className="layout-container">
<SidebarComponent
id="sidebar"
ref={(sidebar: SidebarComponent | null) => {
sidebarRef = sidebar;
}}
width="256px"
target=".layout-container"
isOpen={true}
closeOnDocumentClick={false}
enableGestures={false}
type="Auto"
enableDock={true}
dockSize="60px"
change={onSidebarChange}
>
{sidebarTemplate()}
</SidebarComponent>
<div className="content-area">
<header
className="content-header flex items-center shadow"
style={{
backgroundColor: '#e5d8c7',
color: '#78591c',
height: '68px', // Exakt gleiche Höhe wie Sidebar-Header
fontSize: '1.15rem',
fontFamily:
'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif',
margin: 0,
padding: '0 2rem 0 0', // Nur rechts Padding, links kein Padding
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
}}
>
<ButtonComponent
cssClass="e-inherit"
iconCss="e-icons e-menu"
onClick={toggleSidebar}
isToggle={true}
style={{
margin: '0 1rem 0 0', // Nur rechts Margin für Abstand zum Logo
padding: '8px 12px',
minWidth: '44px',
height: '44px',
flexShrink: 0,
}}
/>
<img src={logo} alt="Logo" className="h-16 mr-4" style={{ maxHeight: '60px' }} />
<span className="text-2xl font-bold mr-8" style={{ color: '#78591c' }}>
Infoscreen-Management
</span>
<span className="ml-auto text-lg font-medium" style={{ color: '#78591c' }}>
[Organisationsname]
</span>
</header>
<main className="page-content">
<Outlet />
</main>
</div>
</div>
);
};
const App: React.FC = () => {
// Automatische Navigation zu /clients bei leerer Beschreibung entfernt
return (
<ToastProvider>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="termine" element={<Appointments />} />
<Route path="ressourcen" element={<Ressourcen />} />
<Route path="infoscr_groups" element={<Infoscreen_groups />} />
<Route path="medien" element={<Media />} />
<Route path="benutzer" element={<Benutzer />} />
<Route path="einstellungen" element={<Einstellungen />} />
<Route path="clients" element={<Infoscreens />} />
<Route path="setup" element={<SetupMode />} />
<Route path="programminfo" element={<Programminfo />} />
</Route>
<Route path="/logout" element={<Logout />} />
</Routes>
</ToastProvider>
);
};
const AppWrapper: React.FC = () => (
<Router>
<App />
</Router>
);
export default AppWrapper;

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

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

View File

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

105
dashboard/src/apiClients.ts Normal file
View File

@@ -0,0 +1,105 @@
export interface Client {
uuid: string;
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[]> {
const response = await fetch('/api/clients');
if (!response.ok) {
throw new Error('Fehler beim Laden der Clients');
}
return await response.json();
}
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_id: groupId }),
});
if (!res.ok) throw new Error('Fehler beim Aktualisieren der Clients');
return await res.json();
}
export async function updateClient(uuid: string, data: { description?: string; model?: string }) {
const res = await fetch(`/api/clients/${uuid}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Fehler beim Aktualisieren des Clients');
return await res.json();
}
export async function restartClient(uuid: string): Promise<{ success: boolean; message?: string }> {
const response = await fetch(`/api/clients/${uuid}/restart`, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Fehler beim Neustart des Clients');
}
return await response.json();
}
export async function deleteClient(uuid: string) {
const res = await fetch(`/api/clients/${uuid}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Fehler beim Entfernen des Clients');
return await res.json();
}
export async function fetchMediaById(mediaId: number | string) {
const response = await fetch(`/api/eventmedia/${mediaId}`);
if (!response.ok) throw new Error('Fehler beim Laden der Mediainformationen');
return await response.json();
}

View File

@@ -0,0 +1,42 @@
export interface Event {
id: string;
title: string;
start: string;
end: string;
allDay: boolean;
classNames: string[];
extendedProps: Record<string, unknown>;
}
export async function fetchEvents(groupId: string, showInactive = false) {
const res = await fetch(
`/api/events?group_id=${encodeURIComponent(groupId)}&show_inactive=${showInactive ? '1' : '0'}`
);
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Termine');
return data;
}
export async function deleteEvent(eventId: string) {
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}`, {
method: 'DELETE',
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Löschen des Termins');
return data;
}
export 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;
}

View File

@@ -0,0 +1,40 @@
export async function createGroup(name: string) {
const res = await fetch('/api/groups', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Erstellen der Gruppe');
return data;
}
export async function fetchGroups() {
const res = await fetch('/api/groups');
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Gruppen');
return data;
}
export async function deleteGroup(groupName: string) {
const res = await fetch(`/api/groups/byname/${encodeURIComponent(groupName)}`, {
method: 'DELETE',
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Löschen der Gruppe');
return data;
}
export async function renameGroup(oldName: string, newName: string) {
const res = await fetch(`/api/groups/byname/${encodeURIComponent(oldName)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newName: newName }),
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Umbenennen der Gruppe');
return data;
}
// Hier kannst du später weitere Funktionen ergänzen:
// export async function updateGroup(id: number, name: string) { ... }

View File

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

View File

@@ -0,0 +1,813 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
ScheduleComponent,
Day,
Week,
WorkWeek,
Month,
Agenda,
Inject,
ViewsDirective,
ViewDirective,
} from '@syncfusion/ej2-react-schedule';
import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns';
import { L10n, loadCldr, setCulture } from '@syncfusion/ej2-base';
import type {
EventRenderedArgs,
ActionEventArgs,
RenderCellEventArgs,
} from '@syncfusion/ej2-react-schedule';
import { fetchEvents } from './apiEvents';
import { fetchGroups } from './apiGroups';
import { getGroupColor } from './groupColors';
import { deleteEvent } from './apiEvents';
import CustomEventModal from './components/CustomEventModal';
import { fetchMediaById } from './apiClients';
import { listHolidays, type Holiday } from './apiHolidays';
import {
getAcademicPeriodForDate,
listAcademicPeriods,
setActiveAcademicPeriod,
} from './apiAcademicPeriods';
import {
Presentation,
Globe,
Video,
MessageSquare,
School,
CheckCircle,
AlertCircle,
} from 'lucide-react';
import { renderToStaticMarkup } from 'react-dom/server';
import caGregorian from './cldr/ca-gregorian.json';
import numbers from './cldr/numbers.json';
import timeZoneNames from './cldr/timeZoneNames.json';
import numberingSystems from './cldr/numberingSystems.json';
// Typ für Gruppe ergänzen
type Group = {
id: string;
name: string;
};
// Typ für Event ergänzen
type Event = {
Id: string;
Subject: string;
StartTime: Date;
EndTime: Date;
IsAllDay: boolean;
IsBlock?: boolean; // Syncfusion block appointment
isHoliday?: boolean; // marker for styling/logic
MediaId?: string | number;
SlideshowInterval?: number;
WebsiteUrl?: string;
Icon?: string; // <--- Icon ergänzen!
Type?: string; // <--- Typ ergänzen, falls benötigt
};
type RawEvent = {
Id: string;
Subject: string;
StartTime: string;
EndTime: string;
IsAllDay: boolean;
MediaId?: string | number;
Icon?: string; // <--- Icon ergänzen!
Type?: string;
};
// CLDR-Daten laden (direkt die JSON-Objekte übergeben)
loadCldr(
caGregorian as object,
numbers as object,
timeZoneNames as object,
numberingSystems as object
);
// Deutsche Lokalisierung für den Scheduler
L10n.load({
de: {
schedule: {
day: 'Tag',
week: 'Woche',
workWeek: 'Arbeitswoche',
month: 'Monat',
agenda: 'Agenda',
today: 'Heute',
noEvents: 'Keine Termine',
allDay: 'Ganztägig',
start: 'Start',
end: 'Ende',
event: 'Termin',
save: 'Speichern',
cancel: 'Abbrechen',
delete: 'Löschen',
edit: 'Bearbeiten',
newEvent: 'Neuer Termin',
title: 'Titel',
description: 'Beschreibung',
location: 'Ort',
recurrence: 'Wiederholung',
repeat: 'Wiederholen',
deleteEvent: 'Termin löschen',
deleteContent: 'Möchten Sie diesen Termin wirklich löschen?',
moreDetails: 'Mehr Details',
addTitle: 'Termintitel',
},
},
});
// Kultur setzen
setCulture('de');
// Mapping für Lucide-Icons
const iconMap: Record<string, React.ElementType> = {
Presentation,
Globe,
Video,
MessageSquare,
School,
};
const eventTemplate = (event: Event) => {
const IconComponent = iconMap[event.Icon ?? ''] || null;
// Zeitangabe formatieren
const start = event.StartTime instanceof Date ? event.StartTime : new Date(event.StartTime);
const end = event.EndTime instanceof Date ? event.EndTime : new Date(event.EndTime);
const timeString = `${start.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${end.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<div style={{ display: 'flex', alignItems: 'center', color: '#000', marginBottom: 2 }}>
{IconComponent && (
<span style={{ verticalAlign: 'middle', display: 'inline-block', marginRight: 6 }}>
<IconComponent size={18} color="#000" />
</span>
)}
<span style={{ marginTop: 3 }}>{event.Subject}</span>
</div>
<div style={{ fontSize: '0.95em', color: '#000', marginTop: -2 }}>{timeString}</div>
</div>
);
};
const Appointments: React.FC = () => {
const [groups, setGroups] = useState<Group[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [events, setEvents] = useState<Event[]>([]);
const [holidays, setHolidays] = useState<Holiday[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const [modalInitialData, setModalInitialData] = useState({});
const [schedulerKey, setSchedulerKey] = useState(0);
const [editMode, setEditMode] = useState(false); // NEU: Editiermodus
const [showInactive, setShowInactive] = React.useState(true);
const [allowScheduleOnHolidays, setAllowScheduleOnHolidays] = React.useState(false);
const [showHolidayList, setShowHolidayList] = React.useState(true);
const scheduleRef = React.useRef<ScheduleComponent | null>(null);
const [holidaysInView, setHolidaysInView] = React.useState<number>(0);
const [schoolYearLabel, setSchoolYearLabel] = React.useState<string>('');
const [hasSchoolYearPlan, setHasSchoolYearPlan] = React.useState<boolean>(false);
const [periods, setPeriods] = React.useState<{ id: number; label: string }[]>([]);
const [activePeriodId, setActivePeriodId] = React.useState<number | null>(null);
// Gruppen laden
useEffect(() => {
fetchGroups()
.then(data => {
// Nur Gruppen mit id != 1 berücksichtigen (nicht zugeordnet ignorieren)
const filtered = Array.isArray(data) ? data.filter(g => g.id && g.name && g.id !== 1) : [];
setGroups(filtered);
if (filtered.length > 0) setSelectedGroupId(filtered[0].id);
})
.catch(console.error);
}, []);
// Holidays laden
useEffect(() => {
listHolidays()
.then(res => setHolidays(res.holidays || []))
.catch(err => console.error('Ferien laden fehlgeschlagen:', err));
}, []);
// Perioden laden (Dropdown)
useEffect(() => {
listAcademicPeriods()
.then(all => {
setPeriods(all.map(p => ({ id: p.id, label: p.display_name || p.name })));
const active = all.find(p => p.is_active);
setActivePeriodId(active ? active.id : null);
})
.catch(err => console.error('Akademische Perioden laden fehlgeschlagen:', err));
}, []);
// fetchAndSetEvents als useCallback definieren, damit die Dependency korrekt ist:
const fetchAndSetEvents = React.useCallback(async () => {
if (!selectedGroupId) {
setEvents([]);
return;
}
try {
const data = await fetchEvents(selectedGroupId, showInactive); // selectedGroupId ist jetzt garantiert string
const mapped: Event[] = data.map((e: RawEvent) => ({
Id: e.Id,
Subject: e.Subject,
StartTime: new Date(e.StartTime.endsWith('Z') ? e.StartTime : e.StartTime + 'Z'),
EndTime: new Date(e.EndTime.endsWith('Z') ? e.EndTime : e.EndTime + 'Z'),
IsAllDay: e.IsAllDay,
MediaId: e.MediaId,
Icon: e.Icon, // <--- Icon übernehmen!
Type: e.Type, // <--- Typ übernehmen!
}));
setEvents(mapped);
} catch (err) {
console.error('Fehler beim Laden der Termine:', err);
}
}, [selectedGroupId, showInactive]);
React.useEffect(() => {
if (selectedGroupId) {
// selectedGroupId kann null sein, fetchEvents erwartet aber string
fetchAndSetEvents();
} else {
setEvents([]);
}
}, [selectedGroupId, showInactive, fetchAndSetEvents]);
// Helper: prüfe, ob Zeitraum einen Feiertag/Ferienbereich schneidet
const isWithinHolidayRange = React.useCallback(
(start: Date, end: Date) => {
// normalisiere Endzeit minimal (Syncfusion nutzt exklusive Enden in einigen Fällen)
const adjEnd = new Date(end);
// keine Änderung nötig unsere eigenen Events sind präzise
for (const h of holidays) {
// Holiday dates are strings YYYY-MM-DD (local date)
const hs = new Date(h.start_date + 'T00:00:00');
const he = new Date(h.end_date + 'T23:59:59');
if (
(start >= hs && start <= he) ||
(adjEnd >= hs && adjEnd <= he) ||
(start <= hs && adjEnd >= he)
) {
return true;
}
}
return false;
},
[holidays]
);
// Baue Holiday-Anzeige-Events und Block-Events
const holidayDisplayEvents: Event[] = useMemo(() => {
if (!showHolidayList) return [];
const out: Event[] = [];
for (const h of holidays) {
const start = new Date(h.start_date + 'T00:00:00');
const end = new Date(h.end_date + 'T23:59:59');
out.push({
Id: `holiday-${h.id}-display`,
Subject: h.name,
StartTime: start,
EndTime: end,
IsAllDay: true,
isHoliday: true,
});
}
return out;
}, [holidays, showHolidayList]);
const holidayBlockEvents: Event[] = useMemo(() => {
if (allowScheduleOnHolidays) return [];
const out: Event[] = [];
for (const h of holidays) {
const start = new Date(h.start_date + 'T00:00:00');
const end = new Date(h.end_date + 'T23:59:59');
out.push({
Id: `holiday-${h.id}-block`,
Subject: h.name,
StartTime: start,
EndTime: end,
IsAllDay: true,
IsBlock: true,
isHoliday: true,
});
}
return out;
}, [holidays, allowScheduleOnHolidays]);
const dataSource = useMemo(() => {
return [...events, ...holidayDisplayEvents, ...holidayBlockEvents];
}, [events, holidayDisplayEvents, holidayBlockEvents]);
// Aktive akademische Periode für Datum aus dem Backend ermitteln
const refreshAcademicPeriodFor = React.useCallback(
async (baseDate: Date) => {
try {
const p = await getAcademicPeriodForDate(baseDate);
if (!p) {
setSchoolYearLabel('');
setHasSchoolYearPlan(false);
return;
}
// Anzeige: bevorzugt display_name, sonst name
const label = p.display_name ? p.display_name : p.name;
setSchoolYearLabel(label);
// Existiert ein Ferienplan innerhalb der Periode?
const start = new Date(p.start_date + 'T00:00:00');
const end = new Date(p.end_date + 'T23:59:59');
let exists = false;
for (const h of holidays) {
const hs = new Date(h.start_date + 'T00:00:00');
const he = new Date(h.end_date + 'T23:59:59');
if (hs <= end && he >= start) {
exists = true;
break;
}
}
setHasSchoolYearPlan(exists);
} catch (e) {
console.error('Akademische Periode laden fehlgeschlagen:', e);
setSchoolYearLabel('');
setHasSchoolYearPlan(false);
}
},
[holidays]
);
// Anzahl an Ferienzeiträumen in aktueller Ansicht ermitteln + Perioden-Indikator setzen
const updateHolidaysInView = React.useCallback(() => {
const inst = scheduleRef.current;
if (!inst) {
setHolidaysInView(0);
return;
}
const view = inst.currentView as 'Day' | 'Week' | 'WorkWeek' | 'Month' | 'Agenda';
const baseDate = inst.selectedDate as Date;
if (!baseDate) {
setHolidaysInView(0);
return;
}
let rangeStart = new Date(baseDate);
let rangeEnd = new Date(baseDate);
if (view === 'Day' || view === 'Agenda') {
rangeStart.setHours(0, 0, 0, 0);
rangeEnd.setHours(23, 59, 59, 999);
} else if (view === 'Week' || view === 'WorkWeek') {
const day = baseDate.getDay();
const diffToMonday = (day + 6) % 7; // Monday=0
rangeStart = new Date(baseDate);
rangeStart.setDate(baseDate.getDate() - diffToMonday);
rangeStart.setHours(0, 0, 0, 0);
rangeEnd = new Date(rangeStart);
rangeEnd.setDate(rangeStart.getDate() + 6);
rangeEnd.setHours(23, 59, 59, 999);
} else if (view === 'Month') {
rangeStart = new Date(baseDate.getFullYear(), baseDate.getMonth(), 1, 0, 0, 0, 0);
rangeEnd = new Date(baseDate.getFullYear(), baseDate.getMonth() + 1, 0, 23, 59, 59, 999);
}
let count = 0;
for (const h of holidays) {
const hs = new Date(h.start_date + 'T00:00:00');
const he = new Date(h.end_date + 'T23:59:59');
const overlaps =
(hs >= rangeStart && hs <= rangeEnd) ||
(he >= rangeStart && he <= rangeEnd) ||
(hs <= rangeStart && he >= rangeEnd);
if (overlaps) count += 1;
}
setHolidaysInView(count);
// Perioden-Indikator über Backend prüfen
refreshAcademicPeriodFor(baseDate);
}, [holidays, refreshAcademicPeriodFor]);
// Aktualisiere Indikator wenn Ferien oder Ansicht (Key) wechseln
React.useEffect(() => {
updateHolidaysInView();
}, [holidays, updateHolidaysInView, schedulerKey]);
return (
<div>
<h1 className="text-2xl font-bold mb-4">Terminmanagement</h1>
<div
style={{
marginBottom: 16,
display: 'flex',
alignItems: 'center',
gap: 16,
flexWrap: 'wrap',
}}
>
<label htmlFor="groupDropdown" className="mb-0 mr-2" style={{ whiteSpace: 'nowrap' }}>
Raumgruppe auswählen:
</label>
<DropDownListComponent
id="groupDropdown"
dataSource={groups}
fields={{ text: 'name', value: 'id' }}
placeholder="Gruppe auswählen"
value={selectedGroupId}
width="240px"
change={(e: { value: string }) => {
// <--- Typ für e ergänzt
setEvents([]); // Events sofort leeren
setSelectedGroupId(e.value);
}}
style={{}}
/>
{/* Akademische Periode Selector + Plan-Badge */}
<span style={{ marginLeft: 8, whiteSpace: 'nowrap' }}>Periode:</span>
<DropDownListComponent
id="periodDropdown"
dataSource={periods}
fields={{ text: 'label', value: 'id' }}
placeholder="Periode wählen"
value={activePeriodId ?? undefined}
width="260px"
change={async (e: { value: number }) => {
const id = Number(e.value);
if (!id) return;
try {
const updated = await setActiveAcademicPeriod(id);
setActivePeriodId(updated.id);
// Zum gleichen Tag/Monat (heute) innerhalb der gewählten Periode springen
const today = new Date();
const targetYear = new Date(updated.start_date).getFullYear();
const target = new Date(targetYear, today.getMonth(), today.getDate(), 12, 0, 0);
if (scheduleRef.current) {
scheduleRef.current.selectedDate = target;
scheduleRef.current.dataBind?.();
}
updateHolidaysInView();
} catch (err) {
console.error('Aktive Periode setzen fehlgeschlagen:', err);
}
}}
style={{}}
/>
{/* School-year/period plan badge (adjacent) */}
<span
title={hasSchoolYearPlan ? 'Ferienplan ist hinterlegt' : 'Kein Ferienplan hinterlegt'}
style={{
background: hasSchoolYearPlan ? '#dcfce7' : '#f3f4f6',
border: hasSchoolYearPlan ? '1px solid #86efac' : '1px solid #e5e7eb',
color: '#000',
padding: '4px 10px',
borderRadius: 16,
fontSize: 12,
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
{hasSchoolYearPlan ? (
<CheckCircle size={14} color="#166534" />
) : (
<AlertCircle size={14} color="#6b7280" />
)}
{schoolYearLabel || 'Periode'}
</span>
</div>
<button
className="e-btn e-success mb-4"
onClick={() => {
const now = new Date();
// Runde auf die nächste halbe Stunde
const minutes = now.getMinutes();
const roundedMinutes = minutes < 30 ? 30 : 0;
const startTime = new Date(now);
startTime.setMinutes(roundedMinutes, 0, 0);
if (roundedMinutes === 0) startTime.setHours(startTime.getHours() + 1);
const endTime = new Date(startTime);
endTime.setMinutes(endTime.getMinutes() + 30);
setModalInitialData({
startDate: startTime,
startTime: startTime,
endTime: endTime,
});
setEditMode(false);
setModalOpen(true);
}}
>
Neuen Termin anlegen
</button>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 24,
marginBottom: 16,
flexWrap: 'wrap',
}}
>
<label>
<input
type="checkbox"
checked={showInactive}
onChange={e => setShowInactive(e.target.checked)}
style={{ marginRight: 8 }}
/>
Vergangene Termine anzeigen
</label>
<label>
<input
type="checkbox"
checked={allowScheduleOnHolidays}
onChange={e => setAllowScheduleOnHolidays(e.target.checked)}
style={{ marginRight: 8 }}
/>
Termine an Ferientagen erlauben
</label>
<label>
<input
type="checkbox"
checked={showHolidayList}
onChange={e => setShowHolidayList(e.target.checked)}
style={{ marginRight: 8 }}
/>
Ferien im Kalender anzeigen
</label>
{/* Right-aligned indicators */}
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}>
{/* Holidays-in-view badge */}
<span
title="Anzahl der Ferientage/-zeiträume in der aktuellen Ansicht"
style={{
background: holidaysInView > 0 ? '#ffe8cc' : '#f3f4f6',
border: holidaysInView > 0 ? '1px solid #ffcf99' : '1px solid #e5e7eb',
color: '#000',
padding: '4px 10px',
borderRadius: 16,
fontSize: 12,
}}
>
{holidaysInView > 0 ? `Ferien im Blick: ${holidaysInView}` : 'Keine Ferien in Ansicht'}
</span>
</div>
</div>
<CustomEventModal
open={modalOpen}
onClose={() => {
setModalOpen(false);
setEditMode(false); // Editiermodus zurücksetzen
}}
onSave={async () => {
setModalOpen(false);
setEditMode(false);
if (selectedGroupId) {
const data = await fetchEvents(selectedGroupId, showInactive);
const mapped: Event[] = data.map((e: RawEvent) => ({
Id: e.Id,
Subject: e.Subject,
StartTime: new Date(e.StartTime.endsWith('Z') ? e.StartTime : e.StartTime + 'Z'),
EndTime: new Date(e.EndTime.endsWith('Z') ? e.EndTime : e.EndTime + 'Z'),
IsAllDay: e.IsAllDay,
MediaId: e.MediaId,
}));
setEvents(mapped);
setSchedulerKey(prev => prev + 1); // <-- Key erhöhen
}
}}
initialData={modalInitialData}
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
groupColor={selectedGroupId ? getGroupColor(selectedGroupId, groups) : undefined}
editMode={editMode} // NEU: Prop für Editiermodus
blockHolidays={!allowScheduleOnHolidays}
isHolidayRange={(s, e) => isWithinHolidayRange(s, e)}
/>
<ScheduleComponent
ref={scheduleRef}
key={schedulerKey} // <-- dynamischer Key
height="750px"
locale="de"
currentView="Week"
eventSettings={{
dataSource: dataSource,
fields: { isBlock: 'IsBlock' },
template: eventTemplate, // <--- Hier das Template setzen!
}}
actionComplete={() => updateHolidaysInView()}
cellClick={args => {
if (!allowScheduleOnHolidays && isWithinHolidayRange(args.startTime, args.endTime)) {
args.cancel = true;
return; // block creation on holidays
}
// args.startTime und args.endTime sind Date-Objekte
args.cancel = true; // Verhindert die Standardaktion
setModalInitialData({
startDate: args.startTime,
startTime: args.startTime,
endTime: args.endTime,
});
setEditMode(false); // NEU: kein Editiermodus
setModalOpen(true);
}}
popupOpen={async args => {
if (args.type === 'Editor') {
args.cancel = true;
const event = args.data;
console.log('Event zum Bearbeiten:', event);
let media = null;
if (event.MediaId) {
try {
const mediaData = await fetchMediaById(event.MediaId);
media = {
id: mediaData.id,
path: mediaData.file_path,
name: mediaData.name || mediaData.url,
};
} catch (err) {
console.error('Fehler beim Laden der Mediainfos:', err);
}
}
setModalInitialData({
Id: event.Id,
title: event.Subject,
startDate: event.StartTime,
startTime: event.StartTime,
endTime: event.EndTime,
description: event.Description ?? '',
type: event.Type ?? 'presentation',
repeat: event.Repeat ?? false,
weekdays: event.Weekdays ?? [],
repeatUntil: event.RepeatUntil ?? null,
skipHolidays: event.SkipHolidays ?? false,
media, // Metadaten werden nur bei Bedarf geladen!
slideshowInterval: event.SlideshowInterval ?? 10,
websiteUrl: event.WebsiteUrl ?? '',
});
console.log('Modal initial data:', {
Id: event.Id,
title: event.Subject,
startDate: event.StartTime,
startTime: event.StartTime,
endTime: event.EndTime,
description: event.Description ?? '',
type: event.Type ?? 'presentation',
repeat: event.Repeat ?? false,
weekdays: event.Weekdays ?? [],
repeatUntil: event.RepeatUntil ?? null,
skipHolidays: event.SkipHolidays ?? false,
media, // Metadaten werden nur bei Bedarf geladen!
slideshowInterval: event.SlideshowInterval ?? 10,
websiteUrl: event.WebsiteUrl ?? '',
});
setEditMode(true);
setModalOpen(true);
}
}}
eventRendered={(args: EventRenderedArgs) => {
// Blende Nicht-Ferien-Events aus, falls sie in Ferien fallen und Terminieren nicht erlaubt ist
if (!allowScheduleOnHolidays && args.data && !args.data.isHoliday) {
const s =
args.data.StartTime instanceof Date
? args.data.StartTime
: new Date(args.data.StartTime);
const e =
args.data.EndTime instanceof Date ? args.data.EndTime : new Date(args.data.EndTime);
if (isWithinHolidayRange(s, e)) {
args.cancel = true;
return;
}
}
if (selectedGroupId && args.data && args.data.Id) {
const groupColor = getGroupColor(selectedGroupId, groups);
const now = new Date();
let IconComponent: React.ElementType | null = null;
switch (args.data.Type) {
case 'presentation':
IconComponent = Presentation;
break;
case 'website':
IconComponent = Globe;
break;
case 'video':
IconComponent = Video;
break;
case 'message':
IconComponent = MessageSquare;
break;
case 'webuntis':
IconComponent = School;
break;
default:
IconComponent = null;
}
// Nur .e-subject verwenden!
const titleElement = args.element.querySelector('.e-subject');
if (titleElement && IconComponent) {
const svgString = renderToStaticMarkup(<IconComponent size={18} color="#78591c" />);
// Immer nur den reinen Text nehmen, kein vorhandenes Icon!
const subjectText = (titleElement as HTMLElement).textContent ?? '';
(titleElement as HTMLElement).innerHTML =
`<span style="vertical-align:middle;display:inline-block;margin-right:6px;">${svgString}</span>` +
subjectText;
}
// Vergangene Termine: Raumgruppenfarbe
if (args.data.EndTime && args.data.EndTime < now) {
args.element.style.backgroundColor = groupColor ? `${groupColor}` : '#f3f3f3';
args.element.style.color = '#000';
} else if (groupColor) {
args.element.style.backgroundColor = groupColor;
args.element.style.color = '#000';
}
// Spezielle Darstellung für Ferienanzeige-Events
if (args.data.isHoliday && !args.data.IsBlock) {
args.element.style.backgroundColor = '#ffe8cc'; // sanftes Orange
args.element.style.border = '1px solid #ffcf99';
args.element.style.color = '#000';
}
// Gleiche Darstellung für Ferien-Block-Events
if (args.data.isHoliday && args.data.IsBlock) {
args.element.style.backgroundColor = '#ffe8cc';
args.element.style.border = '1px solid #ffcf99';
args.element.style.color = '#000';
}
}
}}
actionBegin={async (args: ActionEventArgs) => {
if (args.requestType === 'eventRemove') {
// args.data ist ein Array von zu löschenden Events
const toDelete = Array.isArray(args.data) ? args.data : [args.data];
for (const ev of toDelete) {
try {
await deleteEvent(ev.Id); // Deine API-Funktion
} catch (err) {
// Optional: Fehlerbehandlung
console.error('Fehler beim Löschen:', err);
}
}
// Events nach Löschen neu laden
if (selectedGroupId) {
fetchEvents(selectedGroupId, showInactive)
.then((data: RawEvent[]) => {
const mapped: Event[] = data.map((e: RawEvent) => ({
Id: e.Id,
Subject: e.Subject,
StartTime: new Date(e.StartTime),
EndTime: new Date(e.EndTime),
IsAllDay: e.IsAllDay,
MediaId: e.MediaId,
}));
setEvents(mapped);
})
.catch(console.error);
}
// Syncfusion soll das Event nicht selbst löschen
args.cancel = true;
} else if (
(args.requestType === 'eventCreate' || args.requestType === 'eventChange') &&
!allowScheduleOnHolidays
) {
// Verhindere Erstellen/Ändern in Ferienbereichen (Failsafe, falls eingebauter Editor genutzt wird)
type PartialEventLike = { StartTime?: Date | string; EndTime?: Date | string };
const raw = (args as ActionEventArgs).data as
| PartialEventLike
| PartialEventLike[]
| undefined;
const data = Array.isArray(raw) ? raw[0] : raw;
if (data && data.StartTime && data.EndTime) {
const s = data.StartTime instanceof Date ? data.StartTime : new Date(data.StartTime);
const e = data.EndTime instanceof Date ? data.EndTime : new Date(data.EndTime);
if (isWithinHolidayRange(s, e)) {
args.cancel = true;
return;
}
}
}
}}
firstDayOfWeek={1}
renderCell={(args: RenderCellEventArgs) => {
// Nur für Arbeitszellen (Stunden-/Tageszellen)
if (args.elementType === 'workCells') {
const now = new Date();
// args.element ist vom Typ Element, daher als HTMLElement casten:
const cell = args.element as HTMLElement;
if (args.date && args.date < now) {
cell.style.backgroundColor = '#fff9e3'; // Hellgelb für Vergangenheit
cell.style.opacity = '0.7';
}
}
}}
>
<ViewsDirective>
<ViewDirective option="Day" />
<ViewDirective option="Week" />
<ViewDirective option="WorkWeek" />
<ViewDirective option="Month" />
<ViewDirective option="Agenda" />
</ViewsDirective>
<Inject services={[Day, Week, WorkWeek, Month, Agenda]} />
</ScheduleComponent>
</div>
);
};
export default Appointments;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

278
dashboard/src/clients.tsx Normal file
View File

@@ -0,0 +1,278 @@
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';
// Raumgruppen werden dynamisch aus der API geladen
interface DetailsModalProps {
open: boolean;
client: Client | null;
groupIdToName: Record<string | number, string>;
onClose: () => void;
}
function DetailsModal({ open, client, groupIdToName, onClose }: DetailsModalProps) {
if (!open || !client) return null;
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.3)',
zIndex: 1000,
}}
>
<div
style={{
background: 'white',
padding: 0,
margin: '100px auto',
maxWidth: 500,
borderRadius: 12,
boxShadow: '0 4px 24px rgba(0,0,0,0.12)',
}}
>
<div style={{ padding: 32 }}>
<h3 style={{ fontSize: '1.25rem', fontWeight: 700, marginBottom: 18 }}>Client-Details</h3>
<table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: 24 }}>
<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: 'bold', padding: '6px 8px' }}>
{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] || value
: ''
: key === 'registration_time' && value
? new Date(
(value as string).endsWith('Z') ? (value as string) : value + 'Z'
).toLocaleString()
: key === 'last_alive' && value
? String(value) // Wert direkt anzeigen, nicht erneut parsen
: String(value)}
</td>
</tr>
))}
</tbody>
</table>
<div style={{ textAlign: 'right' }}>
<button className="e-btn e-outline" onClick={onClose}>
Schließen
</button>
</div>
</div>
</div>
</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: '8px' }}>
<button
className="e-btn e-primary"
onClick={() => setDetailsClient(props)}
style={{ minWidth: 80 }}
>
Details
</button>
<button
className="e-btn e-danger"
onClick={e => {
e.stopPropagation();
handleDelete(props.uuid);
}}
style={{ minWidth: 80 }}
>
Entfernen
</button>
</div>
);
return (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">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={400}
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="80" />
<ColumnDirective
field="last_alive"
headerText="Last Alive"
allowEditing={false}
width="120"
/>
<ColumnDirective field="model" headerText="Model" allowEditing={true} width="120" />
<ColumnDirective
headerText="Aktion"
width="190"
template={detailsButtonTemplate}
textAlign="Center"
allowEditing={false}
/>
</ColumnsDirective>
<Inject services={[Page, Toolbar, Search, Sort, Edit]} />
</GridComponent>
<DetailsModal
open={!!detailsClient}
client={detailsClient}
groupIdToName={groupIdToName}
onClose={() => setDetailsClient(null)}
/>
</>
) : (
<div className="text-gray-500">Raumgruppen werden geladen ...</div>
)}
{/* DialogComponent für Bestätigung */}
{showDialog && deleteClientId && (
<DialogComponent
visible={showDialog}
header="Bestätigung"
content="Möchten Sie diesen Client wirklich entfernen?"
showCloseIcon={true}
width="400px"
buttons={[
{ click: confirmDelete, buttonModel: { content: 'Ja', isPrimary: true } },
{ click: cancelDelete, buttonModel: { content: 'Abbrechen' } },
]}
close={cancelDelete}
/>
)}
</div>
);
};
export default Clients;

View File

@@ -0,0 +1,515 @@
import React from 'react';
import { DialogComponent } from '@syncfusion/ej2-react-popups';
import { TextBoxComponent } from '@syncfusion/ej2-react-inputs';
import { DatePickerComponent, TimePickerComponent } from '@syncfusion/ej2-react-calendars';
import { DropDownListComponent, MultiSelectComponent } from '@syncfusion/ej2-react-dropdowns';
import { CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
import CustomSelectUploadEventModal from './CustomSelectUploadEventModal';
import { updateEvent } from '../apiEvents';
type CustomEventData = {
title: string;
startDate: Date | null;
startTime: Date | null;
endTime: Date | null;
type: string;
description: string;
repeat: boolean;
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: CustomEventData) => void;
initialData?: Partial<CustomEventData> & { Id?: string }; // <--- Id ergänzen
groupName: string | { id: string | null; name: string };
groupColor?: string;
editMode?: boolean;
blockHolidays?: boolean;
isHolidayRange?: (start: Date, end: Date) => boolean;
};
const weekdayOptions = [
{ 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' },
];
const typeOptions = [
{ value: 'presentation', label: 'Präsentation' },
{ value: 'website', label: 'Website' },
{ value: 'video', label: 'Video' },
{ value: 'message', label: 'Nachricht' },
{ value: 'webuntis', label: 'WebUntis' },
];
const CustomEventModal: React.FC<CustomEventModalProps> = ({
open,
onClose,
onSave,
initialData = {},
groupName,
groupColor,
editMode,
blockHolidays,
isHolidayRange,
}) => {
const [title, setTitle] = React.useState(initialData.title || '');
const [startDate, setStartDate] = React.useState(initialData.startDate || null);
const [startTime, setStartTime] = React.useState(
initialData.startTime || new Date(0, 0, 0, 9, 0)
);
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);
const [errors, setErrors] = React.useState<{ [key: string]: string }>({});
// --- 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>(
initialData.slideshowInterval ?? 10
);
const [websiteUrl, setWebsiteUrl] = React.useState<string>(initialData.websiteUrl ?? '');
const [mediaModalOpen, setMediaModalOpen] = React.useState(false);
React.useEffect(() => {
if (open) {
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');
setDescription(initialData.description || '');
setRepeat(initialData.repeat || false);
setWeekdays(initialData.weekdays || []);
setRepeatUntil(initialData.repeatUntil || null);
setSkipHolidays(initialData.skipHolidays || false);
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
setMedia(initialData.media ?? null);
setSlideshowInterval(initialData.slideshowInterval ?? 10);
setWebsiteUrl(initialData.websiteUrl ?? '');
}
}, [open, initialData]);
React.useEffect(() => {
if (!mediaModalOpen && pendingMedia) {
setMedia(pendingMedia);
setPendingMedia(null);
}
}, [mediaModalOpen, pendingMedia]);
const handleSave = async () => {
const newErrors: { [key: string]: string } = {};
if (!title.trim()) newErrors.title = 'Titel ist erforderlich';
if (!startDate) newErrors.startDate = 'Startdatum ist erforderlich';
if (!startTime) newErrors.startTime = 'Startzeit ist erforderlich';
if (!endTime) newErrors.endTime = 'Endzeit ist erforderlich';
if (!type) newErrors.type = 'Termintyp ist erforderlich';
// Vergangenheitsprüfung
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) {
newErrors.startDate = 'Termine in der Vergangenheit sind nicht erlaubt!';
}
if (type === 'presentation') {
if (!media) newErrors.media = 'Bitte eine Präsentation auswählen';
if (!slideshowInterval || slideshowInterval < 1)
newErrors.slideshowInterval = 'Intervall angeben';
}
if (type === 'website') {
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;
}
setErrors({});
const group_id = typeof groupName === 'object' && groupName !== null ? groupName.id : groupName;
const payload: CustomEventData & { [key: string]: unknown } = {
group_id,
title,
description,
start:
startDate && startTime
? new Date(
startDate.getFullYear(),
startDate.getMonth(),
startDate.getDate(),
startTime.getHours(),
startTime.getMinutes()
).toISOString()
: null,
end:
startDate && endTime
? new Date(
startDate.getFullYear(),
startDate.getMonth(),
startDate.getDate(),
endTime.getHours(),
endTime.getMinutes()
).toISOString()
: null,
type,
startDate,
startTime,
endTime,
repeat,
weekdays,
repeatUntil,
skipHolidays,
event_type: type,
is_active: 1,
created_by: 1,
};
if (type === 'presentation') {
payload.event_media_id = media?.id;
payload.slideshow_interval = slideshowInterval;
}
if (type === 'website') {
payload.website_url = websiteUrl;
}
try {
let res;
if (editMode && initialData && typeof initialData.Id === 'string') {
// UPDATE statt CREATE
res = await updateEvent(initialData.Id, payload);
} else {
// 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());
return (
<DialogComponent
target="#root"
visible={open}
width="800px"
header={() => (
<div
style={{
background: groupColor || '#f5f5f5',
padding: '8px 16px',
borderRadius: '6px 6px 0 0',
color: '#fff',
fontWeight: 600,
fontSize: 20,
display: 'flex',
alignItems: 'center',
}}
>
{editMode ? 'Termin bearbeiten' : 'Neuen Termin anlegen'}
{groupName && (
<span style={{ fontWeight: 400, fontSize: 16, marginLeft: 16, color: '#fff' }}>
für Raumgruppe: <b>{typeof groupName === 'object' ? groupName.name : groupName}</b>
</span>
)}
</div>
)}
showCloseIcon={true}
close={onClose}
isModal={true}
footerTemplate={() => (
<div className="flex gap-2 justify-end">
<button className="e-btn e-danger" onClick={onClose}>
Schließen
</button>
<button
className="e-btn e-success"
onClick={handleSave}
disabled={isPast} // <--- Button deaktivieren, wenn Termin in Vergangenheit
>
Termin(e) speichern
</button>
</div>
)}
>
<div style={{ padding: '24px' }}>
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: 260 }}>
{/* ...Titel, Beschreibung, Datum, Zeit... */}
<div style={{ marginBottom: 12 }}>
<TextBoxComponent
placeholder="Titel"
floatLabelType="Auto"
value={title}
change={e => setTitle(e.value)}
/>
{errors.title && <div style={{ color: 'red', fontSize: 12 }}>{errors.title}</div>}
</div>
<div style={{ marginBottom: 12 }}>
<TextBoxComponent
placeholder="Beschreibung"
floatLabelType="Auto"
multiline={true}
value={description}
change={e => setDescription(e.value)}
/>
</div>
<div style={{ marginBottom: 12 }}>
<DatePickerComponent
placeholder="Startdatum"
floatLabelType="Auto"
value={startDate ?? undefined}
change={e => setStartDate(e.value)}
/>
{errors.startDate && (
<div style={{ color: 'red', fontSize: 12 }}>{errors.startDate}</div>
)}
{isPast && (
<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>
)}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<div style={{ flex: 1 }}>
<TimePickerComponent
placeholder="Startzeit"
floatLabelType="Auto"
value={startTime}
step={30}
change={e => setStartTime(e.value)}
/>
{errors.startTime && (
<div style={{ color: 'red', fontSize: 12 }}>{errors.startTime}</div>
)}
</div>
<div style={{ flex: 1 }}>
<TimePickerComponent
placeholder="Endzeit"
floatLabelType="Auto"
value={endTime}
step={30}
change={e => setEndTime(e.value)}
/>
{errors.endTime && (
<div style={{ color: 'red', fontSize: 12 }}>{errors.endTime}</div>
)}
</div>
</div>
</div>
<div style={{ flex: 1, minWidth: 260 }}>
{/* ...Wiederholung, MultiSelect, Wiederholung bis, Ferien... */}
<div style={{ marginBottom: 12 }}>
<CheckBoxComponent
label="Wiederholender Termin"
checked={repeat}
change={e => setRepeat(e.checked)}
/>
</div>
<div style={{ marginBottom: 12 }}>
<MultiSelectComponent
key={repeat ? 'enabled' : 'disabled'}
dataSource={weekdayOptions}
fields={{ text: 'label', value: 'value' }}
placeholder="Wochentage"
value={weekdays}
change={e => setWeekdays(e.value as number[])}
disabled={!repeat}
showDropDownIcon={true}
closePopupOnSelect={false}
/>
</div>
<div style={{ marginBottom: 12 }}>
<DatePickerComponent
key={repeat ? 'enabled' : 'disabled'}
placeholder="Wiederholung bis"
floatLabelType="Auto"
value={repeatUntil ?? undefined}
change={e => setRepeatUntil(e.value)}
disabled={!repeat}
/>
</div>
<div style={{ marginBottom: 12 }}>
<CheckBoxComponent
label="Ferientage berücksichtigen"
checked={skipHolidays}
change={e => setSkipHolidays(e.checked)}
disabled={!repeat}
/>
</div>
</div>
</div>
{/* NEUER ZWEISPALTIGER BEREICH für Termintyp und Zusatzfelder */}
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', marginTop: 8 }}>
<div style={{ flex: 1, minWidth: 260 }}>
<div style={{ marginBottom: 12, marginTop: 16 }}>
<DropDownListComponent
dataSource={typeOptions}
fields={{ text: 'label', value: 'value' }}
placeholder="Termintyp"
value={type}
change={e => setType(e.value as string)}
style={{ width: '100%' }}
/>
{errors.type && <div style={{ color: 'red', fontSize: 12 }}>{errors.type}</div>}
</div>
</div>
<div style={{ flex: 1, minWidth: 260 }}>
<div style={{ marginBottom: 12, minHeight: 60 }}>
{type === 'presentation' && (
<div>
<div style={{ marginBottom: 8, marginTop: 16 }}>
<button
className="e-btn"
onClick={() => setMediaModalOpen(true)}
style={{ width: '100%' }}
>
Medium auswählen/hochladen
</button>
</div>
<div style={{ marginBottom: 8 }}>
<b>Ausgewähltes Medium:</b>{' '}
{media ? (
media.path
) : (
<span style={{ color: '#888' }}>Kein Medium ausgewählt</span>
)}
</div>
<TextBoxComponent
placeholder="Slideshow-Intervall (Sekunden)"
floatLabelType="Auto"
type="number"
value={String(slideshowInterval)}
change={e => setSlideshowInterval(Number(e.value))}
/>
</div>
)}
{type === 'website' && (
<div>
<TextBoxComponent
placeholder="Webseiten-URL"
floatLabelType="Always"
value={websiteUrl}
change={e => setWebsiteUrl(e.value)}
/>
</div>
)}
</div>
</div>
</div>
</div>
{mediaModalOpen && (
<CustomSelectUploadEventModal
open={mediaModalOpen}
onClose={() => setMediaModalOpen(false)}
onSelect={({ id, path, name }) => {
setPendingMedia({ id, path, name });
setMediaModalOpen(false);
}}
selectedFileId={null}
/>
)}
</DialogComponent>
);
};
export default CustomEventModal;

View File

@@ -0,0 +1,58 @@
import React from 'react';
interface CustomMediaInfoPanelProps {
name: string;
size: number;
type: string;
dateModified: number;
description?: string | null;
}
const CustomMediaInfoPanel: React.FC<CustomMediaInfoPanelProps> = ({
name,
size,
type,
dateModified,
description,
}) => {
function formatLocalDate(timestamp: number | undefined | null) {
if (!timestamp || isNaN(timestamp)) return '-';
const date = new Date(timestamp * 1000);
return date.toLocaleString('de-DE');
}
return (
<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>
);
};
export default CustomMediaInfoPanel;

View File

@@ -0,0 +1,119 @@
import React, { useState } from 'react';
import { DialogComponent } from '@syncfusion/ej2-react-popups';
import {
FileManagerComponent,
Inject,
NavigationPane,
DetailsView,
Toolbar,
} from '@syncfusion/ej2-react-filemanager';
const hostUrl = '/api/eventmedia/filemanager/';
type CustomSelectUploadEventModalProps = {
open: boolean;
onClose: () => void;
onSelect: (file: { id: string; path: string; name: string }) => void; // name ergänzt
selectedFileId?: string | null;
};
const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps> = props => {
const { open, onClose, onSelect } = props;
const [selectedFile, setSelectedFile] = useState<{
id: string;
path: string;
name: string;
} | null>(null);
// Callback für Dateiauswahl
interface FileSelectEventArgs {
fileDetails: {
name: string;
isFile: boolean;
size: number;
// weitere Felder falls benötigt
};
}
const handleFileSelect = async (args: FileSelectEventArgs) => {
if (args.fileDetails.isFile && args.fileDetails.size > 0) {
const filename = args.fileDetails.name;
try {
const response = await fetch(
`/api/eventmedia/find_by_filename?filename=${encodeURIComponent(filename)}`
);
if (response.ok) {
const data = await response.json();
setSelectedFile({ id: data.id, path: data.file_path, name: filename });
} else {
setSelectedFile({ id: filename, path: filename, name: filename });
}
} catch (e) {
console.error('Error fetching file details:', e);
}
}
};
// Button-Handler
const handleSelectClick = () => {
if (selectedFile) {
onSelect(selectedFile);
}
};
return (
<DialogComponent
target="#root"
visible={open}
width="700px"
header="Medium auswählen/hochladen"
showCloseIcon={true}
close={onClose}
isModal={true}
footerTemplate={() => (
<div className="flex gap-2 justify-end">
<button className="e-btn" onClick={onClose}>
Abbrechen
</button>
<button className="e-btn e-primary" disabled={!selectedFile} onClick={handleSelectClick}>
Auswählen
</button>
</div>
)}
>
<FileManagerComponent
ajaxSettings={{
url: hostUrl + 'operations',
getImageUrl: hostUrl + 'get-image',
uploadUrl: hostUrl + 'upload',
downloadUrl: hostUrl + 'download',
}}
toolbarSettings={{
items: [
'NewFolder',
'Upload',
'Download',
'Rename',
'Delete',
'SortBy',
'Refresh',
'Details',
],
}}
contextMenuSettings={{
file: ['Open', '|', 'Download', '|', 'Rename', 'Delete', '|', 'Details'],
folder: ['Open', '|', 'Rename', 'Delete', '|', 'Details'],
layout: ['SortBy', 'Refresh', '|', 'View', 'Details'],
}}
allowMultiSelection={false}
fileSelect={handleFileSelect}
>
<Inject services={[NavigationPane, DetailsView, Toolbar]} />
</FileManagerComponent>
</DialogComponent>
);
};
export default CustomSelectUploadEventModal;

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Wrench } from 'lucide-react';
const SetupModeButton: React.FC = () => {
const navigate = useNavigate();
return (
<button
className="setupmode-btn flex items-center gap-2 px-4 py-2 bg-yellow-200 hover:bg-yellow-300 rounded"
onClick={() => navigate('/setup')}
title="Erweiterungsmodus starten"
>
<Wrench size={18} />
Erweiterungsmodus
</button>
);
};
export default SetupModeButton;

View File

@@ -0,0 +1,24 @@
import React, { createContext, useRef, useContext } from 'react';
import { ToastComponent, type ToastModel } from '@syncfusion/ej2-react-notifications';
const ToastContext = createContext<{ show: (opts: ToastModel) => void }>({ show: () => {} });
export const useToast = () => useContext(ToastContext);
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const toastRef = useRef<ToastComponent>(null);
const show = (opts: ToastModel) => toastRef.current?.show(opts);
return (
<ToastContext.Provider value={{ show }}>
{children}
<ToastComponent
ref={toastRef}
position={{ X: 'Right', Y: 'Top' }}
timeOut={5000} // Standard: 5 Sekunden
showCloseButton={false}
/>
</ToastContext.Provider>
);
};

204
dashboard/src/dashboard.tsx Normal file
View File

@@ -0,0 +1,204 @@
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 [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(() => {
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>
<h3 className="text-lg font-semibold mt-6 mb-4">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>
);
};
export default Dashboard;

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { listHolidays, uploadHolidaysCsv, type Holiday } from './apiHolidays';
const Einstellungen: React.FC = () => {
const [file, setFile] = React.useState<File | null>(null);
const [busy, setBusy] = React.useState(false);
const [message, setMessage] = React.useState<string | null>(null);
const [holidays, setHolidays] = React.useState<Holiday[]>([]);
const refresh = React.useCallback(async () => {
try {
const data = await listHolidays();
setHolidays(data.holidays);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Ferien';
setMessage(msg);
}
}, []);
React.useEffect(() => {
refresh();
}, [refresh]);
const onUpload = async () => {
if (!file) return;
setBusy(true);
setMessage(null);
try {
const res = await uploadHolidaysCsv(file);
setMessage(`Import erfolgreich: ${res.inserted} neu, ${res.updated} aktualisiert.`);
await refresh();
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Import.';
setMessage(msg);
} finally {
setBusy(false);
}
};
return (
<div>
<h2 className="text-xl font-bold mb-4">Einstellungen</h2>
<div className="space-y-4">
<section className="p-4 border rounded-md">
<h3 className="font-semibold mb-2">Schulferien importieren</h3>
<p className="text-sm text-gray-600 mb-2">
Unterstützte Formate:
<br /> CSV mit Kopfzeile: <code>name</code>, <code>start_date</code>,{' '}
<code>end_date</code>, optional <code>region</code>
<br /> TXT/CSV ohne Kopfzeile mit Spalten: interner Name, <strong>Name</strong>,{' '}
<strong>Start (YYYYMMDD)</strong>, <strong>Ende (YYYYMMDD)</strong>, optional interne
Info (ignoriert)
</p>
<div className="flex items-center gap-3">
<input
type="file"
accept=".csv,text/csv,.txt,text/plain"
onChange={e => setFile(e.target.files?.[0] ?? null)}
/>
<button className="e-btn e-primary" onClick={onUpload} disabled={!file || busy}>
{busy ? 'Importiere…' : 'CSV/TXT importieren'}
</button>
</div>
{message && <div className="mt-2 text-sm">{message}</div>}
</section>
<section className="p-4 border rounded-md">
<h3 className="font-semibold mb-2">Importierte Ferien</h3>
{holidays.length === 0 ? (
<div className="text-sm text-gray-600">Keine Einträge vorhanden.</div>
) : (
<ul className="text-sm list-disc pl-6">
{holidays.slice(0, 20).map(h => (
<li key={h.id}>
{h.name}: {h.start_date} {h.end_date}
{h.region ? ` (${h.region})` : ''}
</li>
))}
</ul>
)}
</section>
</div>
</div>
);
};
export default Einstellungen;

View File

@@ -0,0 +1,52 @@
// 20 gut unterscheidbare Farben für Gruppen
export const groupColorPalette: string[] = [
'#1E90FF', // Blau
'#28A745', // Grün
'#FFC107', // Gelb
'#DC3545', // Rot
'#6F42C1', // Lila
'#20C997', // Türkis
'#FD7E14', // Orange
'#6610F2', // Violett
'#17A2B8', // Cyan
'#E83E8C', // Pink
'#FF5733', // Koralle
'#2ECC40', // Hellgrün
'#FFB300', // Dunkelgelb
'#00796B', // Petrol
'#C70039', // Dunkelrot
'#8D6E63', // Braun
'#607D8B', // Grau-Blau
'#00B8D4', // Türkisblau
'#FF6F00', // Dunkelorange
'#9C27B0', // Dunkellila
];
// Gibt für eine Gruppen-ID immer dieselbe Farbe zurück (Index basiert auf Gruppenliste)
export function getGroupColor(groupId: string, groups: { id: string }[]): string {
const colorPalette = [
'#1E90FF',
'#28A745',
'#FFC107',
'#DC3545',
'#6F42C1',
'#20C997',
'#FD7E14',
'#6610F2',
'#17A2B8',
'#E83E8C',
'#FF5733',
'#2ECC40',
'#FFB300',
'#00796B',
'#C70039',
'#8D6E63',
'#607D8B',
'#00B8D4',
'#FF6F00',
'#9C27B0',
];
const idx = groups.findIndex(g => g.id === groupId);
return colorPalette[idx % colorPalette.length];
}

View File

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

76
dashboard/src/index.css Normal file
View File

@@ -0,0 +1,76 @@
/* @tailwind base;
@tailwind components; */
/* @tailwind utilities; */
/* :root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgb(255 255 255 / 87%);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizelegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
/* button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
} */
/* @media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #fff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
} */

View File

@@ -0,0 +1,519 @@
import React, { useEffect, useState, useRef } from 'react';
import { KanbanComponent } from '@syncfusion/ej2-react-kanban';
import { fetchClients, updateClientGroup } from './apiClients';
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 { useToast } from './components/ToastProvider';
import { L10n } from '@syncfusion/ej2-base';
interface KanbanClient extends Client {
Id: string;
Status: string; // Raumgruppe (Gruppenname)
Summary: string; // Anzeigename
}
interface Group {
id: number;
name: string;
// weitere Felder möglich
}
interface KanbanDragEventArgs {
element: HTMLElement | HTMLElement[];
data: KanbanClient | KanbanClient[];
event?: { event?: MouseEvent };
[key: string]: unknown;
}
interface KanbanComponentWithClear extends KanbanComponentType {
clearSelection: () => void;
}
const de = {
title: 'Gruppen',
newGroup: 'Neue Raumgruppe',
renameGroup: 'Gruppe umbenennen',
deleteGroup: 'Gruppe löschen',
add: 'Hinzufügen',
cancel: 'Abbrechen',
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.',
groupCreated: 'Gruppe angelegt',
groupDeleted: 'Gruppe gelöscht. Clients in "Nicht zugeordnet" verschoben',
groupRenamed: 'Gruppenname geändert',
selectGroup: 'Gruppe wählen',
newName: 'Neuer Name',
warning: 'Achtung:',
yesDelete: 'Ja, löschen',
};
L10n.load({
de: {
kanban: {
items: 'Clients',
addTitle: 'Neue Karte hinzufügen',
editTitle: 'Karte bearbeiten',
deleteTitle: 'Karte löschen',
edit: 'Bearbeiten',
delete: 'Löschen',
save: 'Speichern',
cancel: 'Abbrechen',
yes: 'Ja',
no: 'Nein',
noCard: 'Keine Clients vorhanden',
},
},
});
const Infoscreen_groups: React.FC = () => {
const toast = useToast();
const [clients, setClients] = useState<KanbanClient[]>([]);
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);
const [renameDialog, setRenameDialog] = useState<{
open: boolean;
oldName: string;
newName: string;
}>({ open: false, oldName: '', newName: '' });
const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; groupName: string }>({
open: false,
groupName: '',
});
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const kanbanRef = useRef<KanbanComponentType | null>(null); // Ref für Kanban
// Lade Gruppen und Clients
useEffect(() => {
let groupMap: Record<number, string> = {};
fetchGroups().then((groupData: Group[]) => {
const kanbanGroups = groupData.map(g => ({
keyField: g.name,
headerText: g.name,
id: g.id,
}));
setGroups(kanbanGroups);
groupMap = Object.fromEntries(groupData.map(g => [g.id, g.name]));
fetchClients().then(data => {
setClients(
data.map((c, i) => ({
...c,
Id: c.uuid,
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}`,
}))
);
});
});
}, []);
// Neue Gruppe anlegen (persistiert per API)
const handleAddGroup = async () => {
if (!newGroupName.trim()) return;
try {
const newGroup = await createGroup(newGroupName);
toast.show({
content: de.groupCreated,
cssClass: 'e-toast-success',
timeOut: 5000,
showCloseButton: false,
});
setGroups([
...groups,
{ keyField: newGroup.name, headerText: newGroup.name, id: newGroup.id },
]);
setNewGroupName('');
setShowDialog(false);
} catch (err) {
toast.show({
content: (err as Error).message,
cssClass: 'e-toast-danger',
timeOut: 0,
showCloseButton: true,
});
}
};
// Löschen einer Gruppe
const handleDeleteGroup = async (groupName: string) => {
try {
// 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),
target.id
);
}
await deleteGroup(groupName);
toast.show({
content: de.groupDeleted,
cssClass: 'e-toast-success',
timeOut: 5000,
showCloseButton: false,
});
// Gruppen und Clients neu laden
const groupData = await fetchGroups();
const groupMap = Object.fromEntries(groupData.map((g: Group) => [g.id, g.name]));
setGroups(groupData.map((g: Group) => ({ keyField: g.name, headerText: g.name, id: g.id })));
const data = await fetchClients();
setClients(
data.map((c, i) => ({
...c,
Id: c.uuid,
Status:
typeof c.group_id === 'number' && groupMap[c.group_id]
? groupMap[c.group_id]
: 'Nicht zugeordnet',
Summary: c.description || `Client ${i + 1}`,
}))
);
} catch (err) {
toast.show({
content: (err as Error).message,
cssClass: 'e-toast-danger',
timeOut: 0,
showCloseButton: true,
});
}
setDeleteDialog({ open: false, groupName: '' });
};
// Umbenennen einer Gruppe
const handleRenameGroup = async () => {
try {
await renameGroup(renameDialog.oldName, renameDialog.newName);
toast.show({
content: de.groupRenamed,
cssClass: 'e-toast-success',
timeOut: 5000,
showCloseButton: false,
});
// Gruppen und Clients neu laden
const groupData = await fetchGroups();
const groupMap = Object.fromEntries(groupData.map((g: Group) => [g.id, g.name]));
setGroups(groupData.map((g: Group) => ({ keyField: g.name, headerText: g.name, id: g.id })));
const data = await fetchClients();
setClients(
data.map((c, i) => ({
...c,
Id: c.uuid,
Status:
typeof c.group_id === 'number' && groupMap[c.group_id]
? groupMap[c.group_id]
: 'Nicht zugeordnet',
Summary: c.description || `Client ${i + 1}`,
}))
);
} catch (err) {
toast.show({
content: (err as Error).message,
cssClass: 'e-toast-danger',
timeOut: 0,
showCloseButton: true,
});
}
setRenameDialog({ open: false, oldName: '', newName: '' });
};
const handleDragStart = (args: KanbanDragEventArgs) => {
const element = Array.isArray(args.element) ? args.element[0] : args.element;
const cardId = element.getAttribute('data-id');
const fromColumn = element.getAttribute('data-key');
setDraggedCard({ id: cardId || '', fromColumn: fromColumn || '' });
};
const handleCardDrop = async (args: KanbanDragEventArgs) => {
if (!draggedCard) return;
const mouseEvent = args.event?.event;
let targetGroupName = '';
if (mouseEvent && mouseEvent.clientX && mouseEvent.clientY) {
const targetElement = document.elementFromPoint(mouseEvent.clientX, mouseEvent.clientY);
const kanbanColumn =
targetElement?.closest('[data-key]') || targetElement?.closest('.e-content-row');
if (kanbanColumn) {
const columnKey = kanbanColumn.getAttribute('data-key');
if (columnKey) {
targetGroupName = columnKey;
} else {
const headerElement = kanbanColumn.querySelector('.e-header-text');
targetGroupName = headerElement?.textContent?.trim() || '';
}
}
}
// Fallback
if (!targetGroupName) {
const targetElement = Array.isArray(args.element) ? args.element[0] : args.element;
const cardWrapper = targetElement.closest('.e-card-wrapper');
const contentRow = cardWrapper?.closest('.e-content-row');
const headerText = contentRow?.querySelector('.e-header-text');
targetGroupName = headerText?.textContent?.trim() || '';
}
if (!targetGroupName || targetGroupName === draggedCard.fromColumn) {
setDraggedCard(null);
return;
}
const dropped = Array.isArray(args.data) ? args.data : [args.data];
const clientIds = dropped.map((card: KanbanClient) => card.Id);
try {
// 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(
groupData.map(g => ({
keyField: g.name,
headerText: g.name,
id: g.id,
}))
);
fetchClients().then(data => {
setClients(
data.map((c, i) => ({
...c,
Id: c.uuid,
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
setTimeout(() => {
(kanbanRef.current as KanbanComponentWithClear)?.clearSelection();
setTimeout(() => {
(kanbanRef.current as KanbanComponentWithClear)?.clearSelection();
}, 100);
}, 50);
});
});
} catch {
alert('Fehler beim Aktualisieren der Clients');
}
setDraggedCard(null);
};
// Spalten-Array ohne Header-Buttons/Template
const kanbanColumns = groups.map(group => ({
keyField: group.keyField,
headerText: group.headerText,
}));
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)}
>
{de.newGroup}
</button>
<button
className="px-4 py-2 bg-yellow-500 text-white rounded"
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: '' })}
>
{de.deleteGroup}
</button>
</div>
<KanbanComponent
locale="de"
id="kanban"
keyField="Status"
dataSource={clients}
cardSettings={{
headerField: 'Summary',
selectionType: 'Multiple',
}}
allowDragAndDrop={true}
dragStart={handleDragStart}
dragStop={handleCardDrop}
ref={kanbanRef}
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}>
{de.add}
</button>
<button
className="bg-gray-300 px-4 py-2 rounded"
onClick={() => setShowDialog(false)}
>
{de.cancel}
</button>
</div>
</div>
</div>
)}
{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"
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: '' })}
>
{de.cancel}
</button>
</div>
</div>
</div>
)}
{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"
onClick={() => setShowDeleteConfirm(true)}
disabled={!deleteDialog.groupName}
>
{de.deleteGroup}
</button>
<button
className="bg-gray-300 px-4 py-2 rounded"
onClick={() => setDeleteDialog({ open: false, groupName: '' })}
>
{de.cancel}
</button>
</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>
);
};
export default Infoscreen_groups;

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

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

19
dashboard/src/main.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { StrictMode } from 'react';
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';
// Setze hier deinen Lizenzschlüssel ein
registerLicense(
'ORg4AjUWIQA/Gnt3VVhhQlJDfV5AQmBIYVp/TGpJfl96cVxMZVVBJAtUQF1hTH5VdENiXX1dcHxUQWNVWkd2'
);
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

117
dashboard/src/media.tsx Normal file
View File

@@ -0,0 +1,117 @@
import React, { useState, useRef } from 'react';
import CustomMediaInfoPanel from './components/CustomMediaInfoPanel';
import {
FileManagerComponent,
Inject,
NavigationPane,
DetailsView,
Toolbar,
} from '@syncfusion/ej2-react-filemanager';
const hostUrl = '/api/eventmedia/filemanager/'; // Dein Backend-Endpunkt für FileManager
const Media: React.FC = () => {
// 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);
// Hilfsfunktion für Datum in Browser-Zeitzone
function formatLocalDate(timestamp: number) {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
return date.toLocaleString('de-DE'); // Zeigt lokale Zeit des Browsers
}
// Ansicht umschalten, ohne Remount
React.useEffect(() => {
if (fileManagerRef.current) {
const element = fileManagerRef.current.element as HTMLElement & { ej2_instances?: unknown[] };
if (element && element.ej2_instances && element.ej2_instances[0]) {
// Typisiere Instanz als unknown, da kein offizieller Typ vorhanden
const instanz = element.ej2_instances[0] as { view: string; dataBind: () => void };
instanz.view = viewMode;
instanz.dataBind();
}
}
}, [viewMode]);
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',
uploadUrl: hostUrl + 'upload',
downloadUrl: hostUrl + 'download',
}}
toolbarSettings={{
items: [
'NewFolder',
'Upload',
'Download',
'Rename',
'Delete',
'SortBy',
'Refresh',
'Details',
],
}}
contextMenuSettings={{
file: ['Open', '|', 'Download', '|', 'Rename', 'Delete', '|', 'Details'],
folder: ['Open', '|', 'Rename', 'Delete', '|', 'Details'],
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>
{/* Details-Panel anzeigen, wenn Details verfügbar sind */}
{fileDetails && <CustomMediaInfoPanel {...fileDetails} />}
</div>
);
};
export default Media;

View File

@@ -0,0 +1,172 @@
import React, { useState, useEffect } from 'react';
interface ProgramInfo {
appName: string;
version: string;
copyright: string;
supportContact: string;
description: string;
techStack: {
[key: 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);
useEffect(() => {
fetch('/program-info.json')
.then(response => {
if (!response.ok) {
throw new Error('Netzwerk-Antwort war nicht ok');
}
return response.json();
})
.then(data => setInfo(data))
.catch(error => {
console.error('Fehler beim Laden der Programminformationen:', error);
setError('Informationen konnten nicht geladen werden.');
});
}, []);
if (error) {
return (
<div>
<h2 className="text-xl font-bold mb-4 text-red-600">Fehler</h2>
<p>{error}</p>
</div>
);
}
if (!info) {
return (
<div>
<h2 className="text-xl font-bold mb-4">Programminfo</h2>
<p>Lade Informationen...</p>
</div>
);
}
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold mb-2">{info.appName}</h2>
<p className="text-gray-600">{info.description}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Allgemeine Infos & Build */}
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-xl font-semibold mb-4 border-b pb-2">Allgemein</h3>
<div className="space-y-3">
<p>
<strong>Version:</strong> {info.version}
</p>
<p>
<strong>Copyright:</strong> {info.copyright}
</p>
<p>
<strong>Support:</strong>{' '}
<a href={`mailto:${info.supportContact}`} className="text-blue-600 hover:underline">
{info.supportContact}
</a>
</p>
<hr className="my-4" />
<h4 className="font-semibold">Build-Informationen</h4>
<p>
<strong>Build-Datum:</strong>{' '}
{new Date(info.buildInfo.buildDate).toLocaleString('de-DE')}
</p>
<p>
<strong>Commit-ID:</strong>{' '}
<span className="font-mono text-sm bg-gray-100 p-1 rounded">
{info.buildInfo.commitId}
</span>
</p>
</div>
</div>
{/* Technischer Stack */}
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-xl font-semibold mb-4 border-b pb-2">Technologie-Stack</h3>
<ul className="list-disc list-inside space-y-2">
{Object.entries(info.techStack).map(([key, value]) => (
<li key={key}>
<span className="font-semibold capitalize">{key}:</span> {value}
</li>
))}
</ul>
</div>
</div>
{/* Changelog */}
<div>
<h3 className="text-xl font-semibold mb-4">Änderungsprotokoll (Changelog)</h3>
<div className="space-y-6">
{info.changelog.map(log => (
<div key={log.version} className="bg-white p-6 rounded-lg shadow">
<h4 className="font-bold text-lg mb-2">
Version {log.version}{' '}
<span className="text-sm font-normal text-gray-500">
- {new Date(log.date).toLocaleDateString('de-DE')}
</span>
</h4>
<ul className="list-disc list-inside space-y-1 text-gray-700">
{log.changes.map((change, index) => (
<li key={index}>{change}</li>
))}
</ul>
</div>
))}
</div>
</div>
{/* Open Source Komponenten */}
<div>
<h3 className="text-xl font-semibold mb-4">Verwendete Open-Source-Komponenten</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{info.openSourceComponents.frontend && (
<div className="bg-white p-6 rounded-lg shadow">
<h4 className="font-bold mb-3">Frontend</h4>
<ul className="list-disc list-inside space-y-1">
{info.openSourceComponents.frontend.map(item => (
<li key={item.name}>
{item.name} ({item.license}-Lizenz)
</li>
))}
</ul>
</div>
)}
{info.openSourceComponents.backend && (
<div className="bg-white p-6 rounded-lg shadow">
<h4 className="font-bold mb-3">Backend</h4>
<ul className="list-disc list-inside space-y-1">
{info.openSourceComponents.backend.map(item => (
<li key={item.name}>
{item.name} ({item.license}-Lizenz)
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
);
};
export default Programminfo;

View File

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

4
dashboard/src/types/json.d.ts vendored Normal file
View File

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

1
dashboard/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

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

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

10
dashboard/tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"typeRoots": ["./src/types", "./node_modules/@types"]
},
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

54
dashboard/vite.config.ts Normal file
View File

@@ -0,0 +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://server:8000',
'/screenshots': 'http://server:8000',
},
},
});

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

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