Initial commit - copied workspace after database cleanup
This commit is contained in:
1
dashboard/.dockerignore
Normal file
1
dashboard/.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
34
dashboard/.eslintrc.cjs
Normal file
34
dashboard/.eslintrc.cjs
Normal 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
9
dashboard/.prettierrc
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
9
dashboard/.stylelintrc.json
Normal file
9
dashboard/.stylelintrc.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": [
|
||||
"stylelint-config-standard",
|
||||
"stylelint-config-tailwindcss"
|
||||
],
|
||||
"rules": {
|
||||
"at-rule-no-unknown": null
|
||||
}
|
||||
}
|
||||
25
dashboard/Dockerfile
Normal file
25
dashboard/Dockerfile
Normal 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
28
dashboard/Dockerfile.dev
Normal 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
54
dashboard/README.md
Normal 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,
|
||||
},
|
||||
})
|
||||
```
|
||||
28
dashboard/eslint.config.js
Normal file
28
dashboard/eslint.config.js
Normal 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
13
dashboard/index.html
Normal 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
8177
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
75
dashboard/package.json
Normal file
75
dashboard/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
dashboard/postcss.config.cjs
Normal file
6
dashboard/postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
96
dashboard/public/program-info.json
Normal file
96
dashboard/public/program-info.json
Normal 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 2–4)",
|
||||
"🔧 UX: Schalter in einer Reihe; Dropdown-Breiten optimiert"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2025.1.0-alpha.6",
|
||||
"date": "2025-09-20",
|
||||
"changes": [
|
||||
"🗓️ NEU: Akademische Perioden System - Unterstützung für Schuljahre, Semester und Trimester",
|
||||
"🏗️ DATENBANK: Neue 'academic_periods' Tabelle für zeitbasierte Organisation",
|
||||
"🔗 ERWEITERT: Events und Medien können jetzt optional einer akademischen Periode zugeordnet werden",
|
||||
"📊 ARCHITEKTUR: Vollständig rückwärtskompatible Implementierung für schrittweise Einführung",
|
||||
"🎯 BILDUNG: Fokus auf Schulumgebung mit Erweiterbarkeit für Hochschulen",
|
||||
"⚙️ TOOLS: Automatische Erstellung von Standard-Schuljahren für österreichische Schulen"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2025.1.0-alpha.5",
|
||||
"date": "2025-09-14",
|
||||
"changes": [
|
||||
"Komplettes Redesign des Backend-Handlings der Gruppenzuordnungen von neuen Clients und der Schritte bei Änderung der Gruppenzuordnung."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2025.1.0-alpha.4",
|
||||
"date": "2025-09-01",
|
||||
"changes": [
|
||||
"Grundstruktur für Deployment getestet und optimiert.",
|
||||
"FIX: Programmfehler beim Umschalten der Ansicht auf der Medien-Seite behoben."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2025.1.0-alpha.3",
|
||||
"date": "2025-08-30",
|
||||
"changes": [
|
||||
"NEU: Programminfo-Seite mit dynamischen Daten, Build-Infos und Changelog.",
|
||||
"NEU: Logout-Funktionalität implementiert.",
|
||||
"FIX: Breite der Sidebar im eingeklappten Zustand korrigiert."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2025.1.0-alpha.2",
|
||||
"date": "2025-08-29",
|
||||
"changes": [
|
||||
"INFO: Analyse und Anzeige der verwendeten Open-Source-Bibliotheken."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2025.1.0-alpha.1",
|
||||
"date": "2025-08-28",
|
||||
"changes": [
|
||||
"Initiales Setup des Projekts und der Grundstruktur."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1
dashboard/public/vite.svg
Normal file
1
dashboard/public/vite.svg
Normal 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
275
dashboard/src/App.css
Normal 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
337
dashboard/src/App.tsx
Normal 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
174
dashboard/src/SetupMode.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { fetchClientsWithoutDescription, setClientDescription } from './apiClients';
|
||||
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
|
||||
import { TextBoxComponent } from '@syncfusion/ej2-react-inputs';
|
||||
import { GridComponent, ColumnsDirective, ColumnDirective } from '@syncfusion/ej2-react-grids';
|
||||
import { DialogComponent } from '@syncfusion/ej2-react-popups';
|
||||
import { useClientDelete } from './hooks/useClientDelete';
|
||||
|
||||
type Client = {
|
||||
uuid: string;
|
||||
hostname?: string;
|
||||
ip_address?: string;
|
||||
last_alive?: string;
|
||||
};
|
||||
|
||||
const SetupMode: React.FC = () => {
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [descriptions, setDescriptions] = useState<Record<string, string>>({});
|
||||
const [loading /* setLoading */] = useState(false);
|
||||
const [inputActive, setInputActive] = useState(false);
|
||||
|
||||
// Lösch-Logik aus Hook (analog zu clients.tsx)
|
||||
const { showDialog, deleteClientId, handleDelete, confirmDelete, cancelDelete } = useClientDelete(
|
||||
async uuid => {
|
||||
// Nach dem Löschen neu laden!
|
||||
const updated = await fetchClientsWithoutDescription();
|
||||
setClients(updated);
|
||||
setDescriptions(prev => {
|
||||
const copy = { ...prev };
|
||||
delete copy[uuid];
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Hilfsfunktion zum Vergleich der Clients
|
||||
const isEqual = (a: Client[], b: Client[]) => {
|
||||
if (a.length !== b.length) return false;
|
||||
const aSorted = [...a].sort((x, y) => x.uuid.localeCompare(y.uuid));
|
||||
const bSorted = [...b].sort((x, y) => x.uuid.localeCompare(y.uuid));
|
||||
for (let i = 0; i < aSorted.length; i++) {
|
||||
if (aSorted[i].uuid !== bSorted[i].uuid) return false;
|
||||
if (aSorted[i].hostname !== bSorted[i].hostname) return false;
|
||||
if (aSorted[i].ip_address !== bSorted[i].ip_address) return false;
|
||||
if (aSorted[i].last_alive !== bSorted[i].last_alive) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let polling: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const fetchClients = () => {
|
||||
if (inputActive) return;
|
||||
fetchClientsWithoutDescription().then(list => {
|
||||
setClients(prev => (isEqual(prev, list) ? prev : list));
|
||||
});
|
||||
};
|
||||
|
||||
fetchClients();
|
||||
polling = setInterval(fetchClients, 5000);
|
||||
|
||||
return () => {
|
||||
if (polling) clearInterval(polling);
|
||||
};
|
||||
}, [inputActive]);
|
||||
|
||||
const handleDescriptionChange = (uuid: string, value: string) => {
|
||||
setDescriptions(prev => ({ ...prev, [uuid]: value }));
|
||||
};
|
||||
|
||||
const handleSave = (uuid: string) => {
|
||||
setClientDescription(uuid, descriptions[uuid] || '')
|
||||
.then(() => {
|
||||
setClients(prev => prev.filter(c => c.uuid !== uuid));
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Fehler beim Speichern der Beschreibung:', err);
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) return <div>Lade neue Clients ...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Erweiterungsmodus: Neue Clients zuordnen</h2>
|
||||
<GridComponent
|
||||
dataSource={clients}
|
||||
allowPaging={true}
|
||||
pageSettings={{ pageSize: 10 }}
|
||||
rowHeight={50}
|
||||
width="100%"
|
||||
allowTextWrap={false}
|
||||
>
|
||||
<ColumnsDirective>
|
||||
<ColumnDirective field="uuid" headerText="UUID" width="180" />
|
||||
<ColumnDirective field="hostname" headerText="Hostname" width="90" />
|
||||
<ColumnDirective field="ip_address" headerText="IP" width="80" />
|
||||
<ColumnDirective
|
||||
headerText="Letzter Kontakt"
|
||||
width="120"
|
||||
template={(props: Client) => {
|
||||
if (!props.last_alive) return '';
|
||||
let iso = props.last_alive;
|
||||
if (!iso.endsWith('Z')) iso += 'Z';
|
||||
const date = new Date(iso);
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}}
|
||||
/>
|
||||
<ColumnDirective
|
||||
headerText="Beschreibung"
|
||||
width="220"
|
||||
template={(props: Client) => (
|
||||
<TextBoxComponent
|
||||
value={descriptions[props.uuid] || ''}
|
||||
placeholder="Beschreibung eingeben"
|
||||
change={e => handleDescriptionChange(props.uuid, e.value as string)}
|
||||
focus={() => setInputActive(true)}
|
||||
blur={() => setInputActive(false)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<ColumnDirective
|
||||
headerText="Aktion"
|
||||
width="180"
|
||||
template={(props: Client) => (
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<ButtonComponent
|
||||
content="Speichern"
|
||||
disabled={!descriptions[props.uuid]}
|
||||
onClick={() => handleSave(props.uuid)}
|
||||
/>
|
||||
<ButtonComponent
|
||||
content="Entfernen"
|
||||
cssClass="e-danger"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleDelete(props.uuid);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</ColumnsDirective>
|
||||
</GridComponent>
|
||||
{clients.length === 0 && <div>Keine neuen Clients ohne Beschreibung.</div>}
|
||||
|
||||
{/* Syncfusion Dialog für Sicherheitsabfrage */}
|
||||
{showDialog && deleteClientId && (
|
||||
<DialogComponent
|
||||
visible={showDialog}
|
||||
header="Bestätigung"
|
||||
content={(() => {
|
||||
const client = clients.find(c => c.uuid === deleteClientId);
|
||||
const hostname = client?.hostname ? ` (${client.hostname})` : '';
|
||||
return client
|
||||
? `Möchten Sie diesen Client${hostname} wirklich entfernen?`
|
||||
: 'Client nicht gefunden.';
|
||||
})()}
|
||||
showCloseIcon={true}
|
||||
width="400px"
|
||||
buttons={[
|
||||
{ click: confirmDelete, buttonModel: { content: 'Ja', isPrimary: true } },
|
||||
{ click: cancelDelete, buttonModel: { content: 'Abbrechen' } },
|
||||
]}
|
||||
close={cancelDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupMode;
|
||||
42
dashboard/src/apiAcademicPeriods.ts
Normal file
42
dashboard/src/apiAcademicPeriods.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type AcademicPeriod = {
|
||||
id: number;
|
||||
name: string;
|
||||
display_name?: string | null;
|
||||
start_date: string; // YYYY-MM-DD
|
||||
end_date: string; // YYYY-MM-DD
|
||||
period_type: 'schuljahr' | 'semester' | 'trimester';
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
async function api<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, { credentials: 'include', ...init });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getAcademicPeriodForDate(date: Date): Promise<AcademicPeriod | null> {
|
||||
const iso = date.toISOString().slice(0, 10);
|
||||
const { period } = await api<{ period: AcademicPeriod | null }>(
|
||||
`/api/academic_periods/for_date?date=${iso}`
|
||||
);
|
||||
return period ?? null;
|
||||
}
|
||||
|
||||
export async function listAcademicPeriods(): Promise<AcademicPeriod[]> {
|
||||
const { periods } = await api<{ periods: AcademicPeriod[] }>(`/api/academic_periods`);
|
||||
return Array.isArray(periods) ? periods : [];
|
||||
}
|
||||
|
||||
export async function getActiveAcademicPeriod(): Promise<AcademicPeriod | null> {
|
||||
const { period } = await api<{ period: AcademicPeriod | null }>(`/api/academic_periods/active`);
|
||||
return period ?? null;
|
||||
}
|
||||
|
||||
export async function setActiveAcademicPeriod(id: number): Promise<AcademicPeriod> {
|
||||
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/active`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
return period;
|
||||
}
|
||||
105
dashboard/src/apiClients.ts
Normal file
105
dashboard/src/apiClients.ts
Normal 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();
|
||||
}
|
||||
42
dashboard/src/apiEvents.ts
Normal file
42
dashboard/src/apiEvents.ts
Normal 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;
|
||||
}
|
||||
40
dashboard/src/apiGroups.ts
Normal file
40
dashboard/src/apiGroups.ts
Normal 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) { ... }
|
||||
26
dashboard/src/apiHolidays.ts
Normal file
26
dashboard/src/apiHolidays.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type Holiday = {
|
||||
id: number;
|
||||
name: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
region?: string | null;
|
||||
source_file_name?: string | null;
|
||||
imported_at?: string | null;
|
||||
};
|
||||
|
||||
export async function listHolidays(region?: string) {
|
||||
const url = region ? `/api/holidays?region=${encodeURIComponent(region)}` : '/api/holidays';
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Ferien');
|
||||
return data as { holidays: Holiday[] };
|
||||
}
|
||||
|
||||
export async function uploadHolidaysCsv(file: File) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await fetch('/api/holidays/upload', { method: 'POST', body: form });
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Import der Ferien');
|
||||
return data as { success: boolean; inserted: number; updated: number };
|
||||
}
|
||||
813
dashboard/src/appointments.tsx
Normal file
813
dashboard/src/appointments.tsx
Normal 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;
|
||||
BIN
dashboard/src/assets/TAA_Logo.png
Normal file
BIN
dashboard/src/assets/TAA_Logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
dashboard/src/assets/logo.png
Normal file
BIN
dashboard/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 225 KiB |
1
dashboard/src/assets/react.svg
Normal file
1
dashboard/src/assets/react.svg
Normal 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 |
8
dashboard/src/benutzer.tsx
Normal file
8
dashboard/src/benutzer.tsx
Normal 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;
|
||||
569
dashboard/src/cldr/ca-gregorian.json
Normal file
569
dashboard/src/cldr/ca-gregorian.json
Normal file
@@ -0,0 +1,569 @@
|
||||
{
|
||||
"main": {
|
||||
"de": {
|
||||
"identity": {
|
||||
"language": "de"
|
||||
},
|
||||
"dates": {
|
||||
"calendars": {
|
||||
"gregorian": {
|
||||
"months": {
|
||||
"format": {
|
||||
"abbreviated": {
|
||||
"1": "Jan.",
|
||||
"2": "Feb.",
|
||||
"3": "März",
|
||||
"4": "Apr.",
|
||||
"5": "Mai",
|
||||
"6": "Juni",
|
||||
"7": "Juli",
|
||||
"8": "Aug.",
|
||||
"9": "Sept.",
|
||||
"10": "Okt.",
|
||||
"11": "Nov.",
|
||||
"12": "Dez."
|
||||
},
|
||||
"narrow": {
|
||||
"1": "J",
|
||||
"2": "F",
|
||||
"3": "M",
|
||||
"4": "A",
|
||||
"5": "M",
|
||||
"6": "J",
|
||||
"7": "J",
|
||||
"8": "A",
|
||||
"9": "S",
|
||||
"10": "O",
|
||||
"11": "N",
|
||||
"12": "D"
|
||||
},
|
||||
"wide": {
|
||||
"1": "Januar",
|
||||
"2": "Februar",
|
||||
"3": "März",
|
||||
"4": "April",
|
||||
"5": "Mai",
|
||||
"6": "Juni",
|
||||
"7": "Juli",
|
||||
"8": "August",
|
||||
"9": "September",
|
||||
"10": "Oktober",
|
||||
"11": "November",
|
||||
"12": "Dezember"
|
||||
}
|
||||
},
|
||||
"stand-alone": {
|
||||
"abbreviated": {
|
||||
"1": "Jan",
|
||||
"2": "Feb",
|
||||
"3": "Mär",
|
||||
"4": "Apr",
|
||||
"5": "Mai",
|
||||
"6": "Jun",
|
||||
"7": "Jul",
|
||||
"8": "Aug",
|
||||
"9": "Sep",
|
||||
"10": "Okt",
|
||||
"11": "Nov",
|
||||
"12": "Dez"
|
||||
},
|
||||
"narrow": {
|
||||
"1": "J",
|
||||
"2": "F",
|
||||
"3": "M",
|
||||
"4": "A",
|
||||
"5": "M",
|
||||
"6": "J",
|
||||
"7": "J",
|
||||
"8": "A",
|
||||
"9": "S",
|
||||
"10": "O",
|
||||
"11": "N",
|
||||
"12": "D"
|
||||
},
|
||||
"wide": {
|
||||
"1": "Januar",
|
||||
"2": "Februar",
|
||||
"3": "März",
|
||||
"4": "April",
|
||||
"5": "Mai",
|
||||
"6": "Juni",
|
||||
"7": "Juli",
|
||||
"8": "August",
|
||||
"9": "September",
|
||||
"10": "Oktober",
|
||||
"11": "November",
|
||||
"12": "Dezember"
|
||||
}
|
||||
}
|
||||
},
|
||||
"days": {
|
||||
"format": {
|
||||
"abbreviated": {
|
||||
"sun": "So.",
|
||||
"mon": "Mo.",
|
||||
"tue": "Di.",
|
||||
"wed": "Mi.",
|
||||
"thu": "Do.",
|
||||
"fri": "Fr.",
|
||||
"sat": "Sa."
|
||||
},
|
||||
"narrow": {
|
||||
"sun": "S",
|
||||
"mon": "M",
|
||||
"tue": "D",
|
||||
"wed": "M",
|
||||
"thu": "D",
|
||||
"fri": "F",
|
||||
"sat": "S"
|
||||
},
|
||||
"short": {
|
||||
"sun": "So.",
|
||||
"mon": "Mo.",
|
||||
"tue": "Di.",
|
||||
"wed": "Mi.",
|
||||
"thu": "Do.",
|
||||
"fri": "Fr.",
|
||||
"sat": "Sa."
|
||||
},
|
||||
"wide": {
|
||||
"sun": "Sonntag",
|
||||
"mon": "Montag",
|
||||
"tue": "Dienstag",
|
||||
"wed": "Mittwoch",
|
||||
"thu": "Donnerstag",
|
||||
"fri": "Freitag",
|
||||
"sat": "Samstag"
|
||||
}
|
||||
},
|
||||
"stand-alone": {
|
||||
"abbreviated": {
|
||||
"sun": "So",
|
||||
"mon": "Mo",
|
||||
"tue": "Di",
|
||||
"wed": "Mi",
|
||||
"thu": "Do",
|
||||
"fri": "Fr",
|
||||
"sat": "Sa"
|
||||
},
|
||||
"narrow": {
|
||||
"sun": "S",
|
||||
"mon": "M",
|
||||
"tue": "D",
|
||||
"wed": "M",
|
||||
"thu": "D",
|
||||
"fri": "F",
|
||||
"sat": "S"
|
||||
},
|
||||
"short": {
|
||||
"sun": "So.",
|
||||
"mon": "Mo.",
|
||||
"tue": "Di.",
|
||||
"wed": "Mi.",
|
||||
"thu": "Do.",
|
||||
"fri": "Fr.",
|
||||
"sat": "Sa."
|
||||
},
|
||||
"wide": {
|
||||
"sun": "Sonntag",
|
||||
"mon": "Montag",
|
||||
"tue": "Dienstag",
|
||||
"wed": "Mittwoch",
|
||||
"thu": "Donnerstag",
|
||||
"fri": "Freitag",
|
||||
"sat": "Samstag"
|
||||
}
|
||||
}
|
||||
},
|
||||
"quarters": {
|
||||
"format": {
|
||||
"abbreviated": {
|
||||
"1": "Q1",
|
||||
"2": "Q2",
|
||||
"3": "Q3",
|
||||
"4": "Q4"
|
||||
},
|
||||
"narrow": {
|
||||
"1": "1",
|
||||
"2": "2",
|
||||
"3": "3",
|
||||
"4": "4"
|
||||
},
|
||||
"wide": {
|
||||
"1": "1. Quartal",
|
||||
"2": "2. Quartal",
|
||||
"3": "3. Quartal",
|
||||
"4": "4. Quartal"
|
||||
}
|
||||
},
|
||||
"stand-alone": {
|
||||
"abbreviated": {
|
||||
"1": "Q1",
|
||||
"2": "Q2",
|
||||
"3": "Q3",
|
||||
"4": "Q4"
|
||||
},
|
||||
"narrow": {
|
||||
"1": "1",
|
||||
"2": "2",
|
||||
"3": "3",
|
||||
"4": "4"
|
||||
},
|
||||
"wide": {
|
||||
"1": "1. Quartal",
|
||||
"2": "2. Quartal",
|
||||
"3": "3. Quartal",
|
||||
"4": "4. Quartal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dayPeriods": {
|
||||
"format": {
|
||||
"abbreviated": {
|
||||
"midnight": "Mitternacht",
|
||||
"am": "AM",
|
||||
"pm": "PM",
|
||||
"morning1": "morgens",
|
||||
"morning2": "vorm.",
|
||||
"afternoon1": "mittags",
|
||||
"afternoon2": "nachm.",
|
||||
"evening1": "abends",
|
||||
"night1": "nachts"
|
||||
},
|
||||
"narrow": {
|
||||
"midnight": "Mitternacht",
|
||||
"am": "AM",
|
||||
"pm": "PM",
|
||||
"morning1": "morgens",
|
||||
"morning2": "vorm.",
|
||||
"afternoon1": "mittags",
|
||||
"afternoon2": "nachm.",
|
||||
"evening1": "abends",
|
||||
"night1": "nachts"
|
||||
},
|
||||
"wide": {
|
||||
"midnight": "Mitternacht",
|
||||
"am": "AM",
|
||||
"pm": "PM",
|
||||
"morning1": "morgens",
|
||||
"morning2": "vormittags",
|
||||
"afternoon1": "mittags",
|
||||
"afternoon2": "nachmittags",
|
||||
"evening1": "abends",
|
||||
"night1": "nachts"
|
||||
}
|
||||
},
|
||||
"stand-alone": {
|
||||
"abbreviated": {
|
||||
"midnight": "Mitternacht",
|
||||
"am": "AM",
|
||||
"pm": "PM",
|
||||
"morning1": "Morgen",
|
||||
"morning2": "Vorm.",
|
||||
"afternoon1": "Mittag",
|
||||
"afternoon2": "Nachm.",
|
||||
"evening1": "Abend",
|
||||
"night1": "Nacht"
|
||||
},
|
||||
"narrow": {
|
||||
"midnight": "Mitternacht",
|
||||
"am": "AM",
|
||||
"pm": "PM",
|
||||
"morning1": "Morgen",
|
||||
"morning2": "Vorm.",
|
||||
"afternoon1": "Mittag",
|
||||
"afternoon2": "Nachm.",
|
||||
"evening1": "Abend",
|
||||
"night1": "Nacht"
|
||||
},
|
||||
"wide": {
|
||||
"midnight": "Mitternacht",
|
||||
"am": "AM",
|
||||
"pm": "PM",
|
||||
"morning1": "Morgen",
|
||||
"morning2": "Vormittag",
|
||||
"afternoon1": "Mittag",
|
||||
"afternoon2": "Nachmittag",
|
||||
"evening1": "Abend",
|
||||
"night1": "Nacht"
|
||||
}
|
||||
}
|
||||
},
|
||||
"eras": {
|
||||
"eraNames": {
|
||||
"0": "v. Chr.",
|
||||
"0-alt-variant": "vor unserer Zeitrechnung",
|
||||
"1": "n. Chr.",
|
||||
"1-alt-variant": "unserer Zeitrechnung"
|
||||
},
|
||||
"eraAbbr": {
|
||||
"0": "v. Chr.",
|
||||
"0-alt-variant": "v. u. Z.",
|
||||
"1": "n. Chr.",
|
||||
"1-alt-variant": "u. Z."
|
||||
},
|
||||
"eraNarrow": {
|
||||
"0": "v. Chr.",
|
||||
"0-alt-variant": "v. u. Z.",
|
||||
"1": "n. Chr.",
|
||||
"1-alt-variant": "u. Z."
|
||||
}
|
||||
},
|
||||
"dateFormats": {
|
||||
"full": "EEEE, d. MMMM y",
|
||||
"long": "d. MMMM y",
|
||||
"medium": "dd.MM.y",
|
||||
"short": "dd.MM.yy"
|
||||
},
|
||||
"dateSkeletons": {
|
||||
"full": "yMMMMEEEEd",
|
||||
"long": "yMMMMd",
|
||||
"medium": "yMMdd",
|
||||
"short": "yyMMdd"
|
||||
},
|
||||
"timeFormats": {
|
||||
"full": "HH:mm:ss zzzz",
|
||||
"long": "HH:mm:ss z",
|
||||
"medium": "HH:mm:ss",
|
||||
"short": "HH:mm"
|
||||
},
|
||||
"timeSkeletons": {
|
||||
"full": "HHmmsszzzz",
|
||||
"long": "HHmmssz",
|
||||
"medium": "HHmmss",
|
||||
"short": "HHmm"
|
||||
},
|
||||
"dateTimeFormats": {
|
||||
"full": "{1}, {0}",
|
||||
"long": "{1}, {0}",
|
||||
"medium": "{1}, {0}",
|
||||
"short": "{1}, {0}",
|
||||
"availableFormats": {
|
||||
"Bh": "h B",
|
||||
"Bhm": "h:mm B",
|
||||
"Bhms": "h:mm:ss B",
|
||||
"d": "d",
|
||||
"E": "ccc",
|
||||
"EBhm": "E h:mm B",
|
||||
"EBhms": "E h:mm:ss B",
|
||||
"Ed": "E, d.",
|
||||
"Ehm": "E h:mm a",
|
||||
"EHm": "E, HH:mm",
|
||||
"Ehms": "E, h:mm:ss a",
|
||||
"EHms": "E, HH:mm:ss",
|
||||
"Gy": "y G",
|
||||
"GyMd": "dd.MM.y G",
|
||||
"GyMMM": "MMM y G",
|
||||
"GyMMMd": "d. MMM y G",
|
||||
"GyMMMEd": "E, d. MMM y G",
|
||||
"h": "h 'Uhr' a",
|
||||
"H": "HH 'Uhr'",
|
||||
"hm": "h:mm a",
|
||||
"Hm": "HH:mm",
|
||||
"hms": "h:mm:ss a",
|
||||
"Hms": "HH:mm:ss",
|
||||
"hmsv": "h:mm:ss a v",
|
||||
"Hmsv": "HH:mm:ss v",
|
||||
"hmv": "h:mm a v",
|
||||
"Hmv": "HH:mm v",
|
||||
"M": "L",
|
||||
"Md": "d.M.",
|
||||
"MEd": "E, d.M.",
|
||||
"MMd": "d.MM.",
|
||||
"MMdd": "dd.MM.",
|
||||
"MMM": "LLL",
|
||||
"MMMd": "d. MMM",
|
||||
"MMMEd": "E, d. MMM",
|
||||
"MMMMd": "d. MMMM",
|
||||
"MMMMEd": "E, d. MMMM",
|
||||
"MMMMW-count-one": "'Woche' W 'im' MMMM",
|
||||
"MMMMW-count-other": "'Woche' W 'im' MMMM",
|
||||
"ms": "mm:ss",
|
||||
"y": "y",
|
||||
"yM": "M/y",
|
||||
"yMd": "d.M.y",
|
||||
"yMEd": "E, d.M.y",
|
||||
"yMM": "MM.y",
|
||||
"yMMdd": "dd.MM.y",
|
||||
"yMMM": "MMM y",
|
||||
"yMMMd": "d. MMM y",
|
||||
"yMMMEd": "E, d. MMM y",
|
||||
"yMMMM": "MMMM y",
|
||||
"yQQQ": "QQQ y",
|
||||
"yQQQQ": "QQQQ y",
|
||||
"yw-count-one": "'Woche' w 'des' 'Jahres' Y",
|
||||
"yw-count-other": "'Woche' w 'des' 'Jahres' Y"
|
||||
},
|
||||
"appendItems": {
|
||||
"Day": "{0} ({2}: {1})",
|
||||
"Day-Of-Week": "{0} {1}",
|
||||
"Era": "{1} {0}",
|
||||
"Hour": "{0} ({2}: {1})",
|
||||
"Minute": "{0} ({2}: {1})",
|
||||
"Month": "{0} ({2}: {1})",
|
||||
"Quarter": "{0} ({2}: {1})",
|
||||
"Second": "{0} ({2}: {1})",
|
||||
"Timezone": "{0} {1}",
|
||||
"Week": "{0} ({2}: {1})",
|
||||
"Year": "{1} {0}"
|
||||
},
|
||||
"intervalFormats": {
|
||||
"intervalFormatFallback": "{0} – {1}",
|
||||
"Bh": {
|
||||
"B": "h 'Uhr' B – h 'Uhr' B",
|
||||
"h": "h–h 'Uhr' B"
|
||||
},
|
||||
"Bhm": {
|
||||
"B": "h:mm 'Uhr' B – h:mm 'Uhr' B",
|
||||
"h": "h:mm – h:mm 'Uhr' B",
|
||||
"m": "h:mm – h:mm 'Uhr' B"
|
||||
},
|
||||
"d": {
|
||||
"d": "d.–d."
|
||||
},
|
||||
"Gy": {
|
||||
"G": "y G – y G",
|
||||
"y": "y–y G"
|
||||
},
|
||||
"GyM": {
|
||||
"G": "MM/y G – MM/y G",
|
||||
"M": "MM/y – MM/y G",
|
||||
"y": "MM/y – MM/y G"
|
||||
},
|
||||
"GyMd": {
|
||||
"d": "dd.–dd.MM.y G",
|
||||
"G": "dd.MM.y G – dd.MM.y G",
|
||||
"M": "dd.MM. – dd.MM.y G",
|
||||
"y": "dd.MM.y – dd.MM.y G"
|
||||
},
|
||||
"GyMEd": {
|
||||
"d": "E, dd.MM.y – E, dd.MM.y G",
|
||||
"G": "E, dd.MM.y G – E, dd.MM.y G",
|
||||
"M": "E, dd.MM. – E, dd.MM.y G",
|
||||
"y": "E, dd.MM.y – E, dd.MM.y G"
|
||||
},
|
||||
"GyMMM": {
|
||||
"G": "MMM y G – MMM y G",
|
||||
"M": "MMM–MMM y G",
|
||||
"y": "MMM y – MMM y G"
|
||||
},
|
||||
"GyMMMd": {
|
||||
"d": "d.–d. MMM y G",
|
||||
"G": "d. MMM y G – d. MMM y G",
|
||||
"M": "d. MMM – d. MMM y G",
|
||||
"y": "d. MMM y – d. MMM y G"
|
||||
},
|
||||
"GyMMMEd": {
|
||||
"d": "E, d. – E, d. MMM y G",
|
||||
"G": "E, d. MMM y G – E E, d. MMM y G",
|
||||
"M": "E, d. MMM – E, d. MMM y G",
|
||||
"y": "E, d. MMM y – E, d. MMM y G"
|
||||
},
|
||||
"h": {
|
||||
"a": "h 'Uhr' a – h 'Uhr' a",
|
||||
"h": "h – h 'Uhr' a"
|
||||
},
|
||||
"H": {
|
||||
"H": "HH–HH 'Uhr'"
|
||||
},
|
||||
"hm": {
|
||||
"a": "h:mm a – h:mm a",
|
||||
"h": "h:mm–h:mm a",
|
||||
"m": "h:mm–h:mm a"
|
||||
},
|
||||
"Hm": {
|
||||
"H": "HH:mm–HH:mm 'Uhr'",
|
||||
"m": "HH:mm–HH:mm 'Uhr'"
|
||||
},
|
||||
"hmv": {
|
||||
"a": "h:mm a – h:mm a v",
|
||||
"h": "h:mm–h:mm a v",
|
||||
"m": "h:mm–h:mm a v"
|
||||
},
|
||||
"Hmv": {
|
||||
"H": "HH:mm–HH:mm 'Uhr' v",
|
||||
"m": "HH:mm–HH:mm 'Uhr' v"
|
||||
},
|
||||
"hv": {
|
||||
"a": "h a – h a v",
|
||||
"h": "h–h a v"
|
||||
},
|
||||
"Hv": {
|
||||
"H": "HH–HH 'Uhr' v"
|
||||
},
|
||||
"M": {
|
||||
"M": "MM–MM"
|
||||
},
|
||||
"Md": {
|
||||
"d": "dd.–dd.MM.",
|
||||
"M": "dd.MM. – dd.MM."
|
||||
},
|
||||
"MEd": {
|
||||
"d": "E, dd. – E, dd.MM.",
|
||||
"M": "E, dd.MM. – E, dd.MM."
|
||||
},
|
||||
"MMM": {
|
||||
"M": "MMM–MMM"
|
||||
},
|
||||
"MMMd": {
|
||||
"d": "d.–d. MMM",
|
||||
"M": "d. MMM – d. MMM"
|
||||
},
|
||||
"MMMEd": {
|
||||
"d": "E, d. – E, d. MMM",
|
||||
"M": "E, d. MMM – E, d. MMM"
|
||||
},
|
||||
"MMMM": {
|
||||
"M": "LLLL–LLLL"
|
||||
},
|
||||
"y": {
|
||||
"y": "y–y"
|
||||
},
|
||||
"yM": {
|
||||
"M": "M/y – M/y",
|
||||
"y": "M/y – M/y"
|
||||
},
|
||||
"yMd": {
|
||||
"d": "dd.–dd.MM.y",
|
||||
"M": "dd.MM. – dd.MM.y",
|
||||
"y": "dd.MM.y – dd.MM.y"
|
||||
},
|
||||
"yMEd": {
|
||||
"d": "E, dd. – E, dd.MM.y",
|
||||
"M": "E, dd.MM. – E, dd.MM.y",
|
||||
"y": "E, dd.MM.y – E, dd.MM.y"
|
||||
},
|
||||
"yMMM": {
|
||||
"M": "MMM–MMM y",
|
||||
"y": "MMM y – MMM y"
|
||||
},
|
||||
"yMMMd": {
|
||||
"d": "d.–d. MMM y",
|
||||
"M": "d. MMM – d. MMM y",
|
||||
"y": "d. MMM y – d. MMM y"
|
||||
},
|
||||
"yMMMEd": {
|
||||
"d": "E, d. – E, d. MMM y",
|
||||
"M": "E, d. MMM – E, d. MMM y",
|
||||
"y": "E, d. MMM y – E, d. MMM y"
|
||||
},
|
||||
"yMMMM": {
|
||||
"M": "MMMM–MMMM y",
|
||||
"y": "MMMM y – MMMM y"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dateTimeFormats-atTime": {
|
||||
"standard": {
|
||||
"full": "{1} 'um' {0}",
|
||||
"long": "{1} 'um' {0}",
|
||||
"medium": "{1}, {0}",
|
||||
"short": "{1}, {0}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
394
dashboard/src/cldr/numberingSystems.json
Normal file
394
dashboard/src/cldr/numberingSystems.json
Normal file
@@ -0,0 +1,394 @@
|
||||
{
|
||||
"supplemental": {
|
||||
"version": {
|
||||
"_unicodeVersion": "16.0.0",
|
||||
"_cldrVersion": "47"
|
||||
},
|
||||
"numberingSystems": {
|
||||
"adlm": {
|
||||
"_digits": "𞥐𞥑𞥒𞥓𞥔𞥕𞥖𞥗𞥘𞥙",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"ahom": {
|
||||
"_digits": "𑜰𑜱𑜲𑜳𑜴𑜵𑜶𑜷𑜸𑜹",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"arab": {
|
||||
"_digits": "٠١٢٣٤٥٦٧٨٩",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"arabext": {
|
||||
"_digits": "۰۱۲۳۴۵۶۷۸۹",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"armn": {
|
||||
"_rules": "armenian-upper",
|
||||
"_type": "algorithmic"
|
||||
},
|
||||
"armnlow": {
|
||||
"_rules": "armenian-lower",
|
||||
"_type": "algorithmic"
|
||||
},
|
||||
"bali": {
|
||||
"_digits": "᭐᭑᭒᭓᭔᭕᭖᭗᭘᭙",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"beng": {
|
||||
"_digits": "০১২৩৪৫৬৭৮৯",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"bhks": {
|
||||
"_digits": "𑱐𑱑𑱒𑱓𑱔𑱕𑱖𑱗𑱘𑱙",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"brah": {
|
||||
"_digits": "𑁦𑁧𑁨𑁩𑁪𑁫𑁬𑁭𑁮𑁯",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"cakm": {
|
||||
"_digits": "𑄶𑄷𑄸𑄹𑄺𑄻𑄼𑄽𑄾𑄿",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"cham": {
|
||||
"_digits": "꩐꩑꩒꩓꩔꩕꩖꩗꩘꩙",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"cyrl": {
|
||||
"_rules": "cyrillic-lower",
|
||||
"_type": "algorithmic"
|
||||
},
|
||||
"deva": {
|
||||
"_digits": "०१२३४५६७८९",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"diak": {
|
||||
"_digits": "𑥐𑥑𑥒𑥓𑥔𑥕𑥖𑥗𑥘𑥙",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"ethi": {
|
||||
"_rules": "ethiopic",
|
||||
"_type": "algorithmic"
|
||||
},
|
||||
"fullwide": {
|
||||
"_digits": "0123456789",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"gara": {
|
||||
"_digits": "",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"geor": {
|
||||
"_rules": "georgian",
|
||||
"_type": "algorithmic"
|
||||
},
|
||||
"gong": {
|
||||
"_digits": "𑶠𑶡𑶢𑶣𑶤𑶥𑶦𑶧𑶨𑶩",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"gonm": {
|
||||
"_digits": "𑵐𑵑𑵒𑵓𑵔𑵕𑵖𑵗𑵘𑵙",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"grek": {
|
||||
"_rules": "greek-upper",
|
||||
"_type": "algorithmic"
|
||||
},
|
||||
"greklow": {
|
||||
"_rules": "greek-lower",
|
||||
"_type": "algorithmic"
|
||||
},
|
||||
"gujr": {
|
||||
"_digits": "૦૧૨૩૪૫૬૭૮૯",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"gukh": {
|
||||
"_digits": "",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"guru": {
|
||||
"_digits": "੦੧੨੩੪੫੬੭੮੯",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"hanidays": {
|
||||
"_rules": "zh/SpelloutRules/spellout-numbering-days",
|
||||
"_type": "algorithmic"
|
||||
},
|
||||
"hanidec": {
|
||||
"_digits": "〇一二三四五六七八九",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"hans": {
|
||||
"_rules": "zh/SpelloutRules/spellout-cardinal",
|
||||
"_type": "algorithmic"
|
||||
},
|
||||
"hansfin": {
|
||||
"_rules": "zh/SpelloutRules/spellout-cardinal-financial",
|
||||
"_type": "algorithmic"
|
||||
},
|
||||
"hant": {
|
||||
"_rules": "zh_Hant/SpelloutRules/spellout-cardinal",
|
||||
"_type": "algorithmic"
|
||||
},
|
||||
"hantfin": {
|
||||
"_rules": "zh_Hant/SpelloutRules/spellout-cardinal-financial",
|
||||
"_type": "algorithmic"
|
||||
},
|
||||
"hebr": {
|
||||
"_rules": "hebrew",
|
||||
"_type": "algorithmic"
|
||||
},
|
||||
"hmng": {
|
||||
"_digits": "𖭐𖭑𖭒𖭓𖭔𖭕𖭖𖭗𖭘𖭙",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"hmnp": {
|
||||
"_digits": "𞅀𞅁𞅂𞅃𞅄𞅅𞅆𞅇𞅈𞅉",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"java": {
|
||||
"_digits": "꧐꧑꧒꧓꧔꧕꧖꧗꧘꧙",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"jpan": {
|
||||
"_rules": "ja/SpelloutRules/spellout-cardinal",
|
||||
"_type": "algorithmic"
|
||||
},
|
||||
"jpanfin": {
|
||||
"_rules": "ja/SpelloutRules/spellout-cardinal-financial",
|
||||
"_type": "algorithmic"
|
||||
},
|
||||
"jpanyear": {
|
||||
"_rules": "ja/SpelloutRules/spellout-numbering-year-latn",
|
||||
"_type": "algorithmic"
|
||||
},
|
||||
"kali": {
|
||||
"_digits": "꤀꤁꤂꤃꤄꤅꤆꤇꤈꤉",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"kawi": {
|
||||
"_digits": "𑽐𑽑𑽒𑽓𑽔𑽕𑽖𑽗𑽘𑽙",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"khmr": {
|
||||
"_digits": "០១២៣៤៥៦៧៨៩",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"knda": {
|
||||
"_digits": "೦೧೨೩೪೫೬೭೮೯",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"krai": {
|
||||
"_digits": "",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"lana": {
|
||||
"_digits": "᪀᪁᪂᪃᪄᪅᪆᪇᪈᪉",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"lanatham": {
|
||||
"_digits": "᪐᪑᪒᪓᪔᪕᪖᪗᪘᪙",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"laoo": {
|
||||
"_digits": "໐໑໒໓໔໕໖໗໘໙",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"latn": {
|
||||
"_digits": "0123456789",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"lepc": {
|
||||
"_digits": "᱀᱁᱂᱃᱄᱅᱆᱇᱈᱉",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"limb": {
|
||||
"_digits": "᥆᥇᥈᥉᥊᥋᥌᥍᥎᥏",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"mathbold": {
|
||||
"_digits": "𝟎𝟏𝟐𝟑𝟒𝟓𝟔𝟕𝟖𝟗",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"mathdbl": {
|
||||
"_digits": "𝟘𝟙𝟚𝟛𝟜𝟝𝟞𝟟𝟠𝟡",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"mathmono": {
|
||||
"_digits": "𝟶𝟷𝟸𝟹𝟺𝟻𝟼𝟽𝟾𝟿",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"mathsanb": {
|
||||
"_digits": "𝟬𝟭𝟮𝟯𝟰𝟱𝟲𝟳𝟴𝟵",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"mathsans": {
|
||||
"_digits": "𝟢𝟣𝟤𝟥𝟦𝟧𝟨𝟩𝟪𝟫",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"mlym": {
|
||||
"_digits": "൦൧൨൩൪൫൬൭൮൯",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"modi": {
|
||||
"_digits": "𑙐𑙑𑙒𑙓𑙔𑙕𑙖𑙗𑙘𑙙",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"mong": {
|
||||
"_digits": "᠐᠑᠒᠓᠔᠕᠖᠗᠘᠙",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"mroo": {
|
||||
"_digits": "𖩠𖩡𖩢𖩣𖩤𖩥𖩦𖩧𖩨𖩩",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"mtei": {
|
||||
"_digits": "꯰꯱꯲꯳꯴꯵꯶꯷꯸꯹",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"mymr": {
|
||||
"_digits": "၀၁၂၃၄၅၆၇၈၉",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"mymrepka": {
|
||||
"_digits": "",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"mymrpao": {
|
||||
"_digits": "",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"mymrshan": {
|
||||
"_digits": "႐႑႒႓႔႕႖႗႘႙",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"mymrtlng": {
|
||||
"_digits": "꧰꧱꧲꧳꧴꧵꧶꧷꧸꧹",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"nagm": {
|
||||
"_digits": "𞓰𞓱𞓲𞓳𞓴𞓵𞓶𞓷𞓸𞓹",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"newa": {
|
||||
"_digits": "𑑐𑑑𑑒𑑓𑑔𑑕𑑖𑑗𑑘𑑙",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"nkoo": {
|
||||
"_digits": "߀߁߂߃߄߅߆߇߈߉",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"olck": {
|
||||
"_digits": "᱐᱑᱒᱓᱔᱕᱖᱗᱘᱙",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"onao": {
|
||||
"_digits": "",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"orya": {
|
||||
"_digits": "୦୧୨୩୪୫୬୭୮୯",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"osma": {
|
||||
"_digits": "𐒠𐒡𐒢𐒣𐒤𐒥𐒦𐒧𐒨𐒩",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"outlined": {
|
||||
"_digits": "",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"rohg": {
|
||||
"_digits": "𐴰𐴱𐴲𐴳𐴴𐴵𐴶𐴷𐴸𐴹",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"roman": {
|
||||
"_rules": "roman-upper",
|
||||
"_type": "algorithmic"
|
||||
},
|
||||
"romanlow": {
|
||||
"_rules": "roman-lower",
|
||||
"_type": "algorithmic"
|
||||
},
|
||||
"saur": {
|
||||
"_digits": "꣐꣑꣒꣓꣔꣕꣖꣗꣘꣙",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"segment": {
|
||||
"_digits": "🯰🯱🯲🯳🯴🯵🯶🯷🯸🯹",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"shrd": {
|
||||
"_digits": "𑇐𑇑𑇒𑇓𑇔𑇕𑇖𑇗𑇘𑇙",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"sind": {
|
||||
"_digits": "𑋰𑋱𑋲𑋳𑋴𑋵𑋶𑋷𑋸𑋹",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"sinh": {
|
||||
"_digits": "෦෧෨෩෪෫෬෭෮෯",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"sora": {
|
||||
"_digits": "𑃰𑃱𑃲𑃳𑃴𑃵𑃶𑃷𑃸𑃹",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"sund": {
|
||||
"_digits": "᮰᮱᮲᮳᮴᮵᮶᮷᮸᮹",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"sunu": {
|
||||
"_digits": "",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"takr": {
|
||||
"_digits": "𑛀𑛁𑛂𑛃𑛄𑛅𑛆𑛇𑛈𑛉",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"talu": {
|
||||
"_digits": "᧐᧑᧒᧓᧔᧕᧖᧗᧘᧙",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"taml": {
|
||||
"_rules": "tamil",
|
||||
"_type": "algorithmic"
|
||||
},
|
||||
"tamldec": {
|
||||
"_digits": "௦௧௨௩௪௫௬௭௮௯",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"telu": {
|
||||
"_digits": "౦౧౨౩౪౫౬౭౮౯",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"thai": {
|
||||
"_digits": "๐๑๒๓๔๕๖๗๘๙",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"tibt": {
|
||||
"_digits": "༠༡༢༣༤༥༦༧༨༩",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"tirh": {
|
||||
"_digits": "𑓐𑓑𑓒𑓓𑓔𑓕𑓖𑓗𑓘𑓙",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"tnsa": {
|
||||
"_digits": "𖫀𖫁𖫂𖫃𖫄𖫅𖫆𖫇𖫈𖫉",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"vaii": {
|
||||
"_digits": "꘠꘡꘢꘣꘤꘥꘦꘧꘨꘩",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"wara": {
|
||||
"_digits": "𑣠𑣡𑣢𑣣𑣤𑣥𑣦𑣧𑣨𑣩",
|
||||
"_type": "numeric"
|
||||
},
|
||||
"wcho": {
|
||||
"_digits": "𞋰𞋱𞋲𞋳𞋴𞋵𞋶𞋷𞋸𞋹",
|
||||
"_type": "numeric"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
164
dashboard/src/cldr/numbers.json
Normal file
164
dashboard/src/cldr/numbers.json
Normal file
@@ -0,0 +1,164 @@
|
||||
{
|
||||
"main": {
|
||||
"de": {
|
||||
"identity": {
|
||||
"language": "de"
|
||||
},
|
||||
"numbers": {
|
||||
"defaultNumberingSystem": "latn",
|
||||
"otherNumberingSystems": {
|
||||
"native": "latn"
|
||||
},
|
||||
"minimumGroupingDigits": "1",
|
||||
"symbols-numberSystem-latn": {
|
||||
"decimal": ",",
|
||||
"group": ".",
|
||||
"list": ";",
|
||||
"percentSign": "%",
|
||||
"plusSign": "+",
|
||||
"minusSign": "-",
|
||||
"approximatelySign": "≈",
|
||||
"exponential": "E",
|
||||
"superscriptingExponent": "·",
|
||||
"perMille": "‰",
|
||||
"infinity": "∞",
|
||||
"nan": "NaN",
|
||||
"timeSeparator": ":"
|
||||
},
|
||||
"decimalFormats-numberSystem-latn": {
|
||||
"standard": "#,##0.###",
|
||||
"long": {
|
||||
"decimalFormat": {
|
||||
"1000-count-one": "0 Tausend",
|
||||
"1000-count-other": "0 Tausend",
|
||||
"10000-count-one": "00 Tausend",
|
||||
"10000-count-other": "00 Tausend",
|
||||
"100000-count-one": "000 Tausend",
|
||||
"100000-count-other": "000 Tausend",
|
||||
"1000000-count-one": "0 Million",
|
||||
"1000000-count-other": "0 Millionen",
|
||||
"10000000-count-one": "00 Millionen",
|
||||
"10000000-count-other": "00 Millionen",
|
||||
"100000000-count-one": "000 Millionen",
|
||||
"100000000-count-other": "000 Millionen",
|
||||
"1000000000-count-one": "0 Milliarde",
|
||||
"1000000000-count-other": "0 Milliarden",
|
||||
"10000000000-count-one": "00 Milliarden",
|
||||
"10000000000-count-other": "00 Milliarden",
|
||||
"100000000000-count-one": "000 Milliarden",
|
||||
"100000000000-count-other": "000 Milliarden",
|
||||
"1000000000000-count-one": "0 Billion",
|
||||
"1000000000000-count-other": "0 Billionen",
|
||||
"10000000000000-count-one": "00 Billionen",
|
||||
"10000000000000-count-other": "00 Billionen",
|
||||
"100000000000000-count-one": "000 Billionen",
|
||||
"100000000000000-count-other": "000 Billionen"
|
||||
}
|
||||
},
|
||||
"short": {
|
||||
"decimalFormat": {
|
||||
"1000-count-one": "0",
|
||||
"1000-count-other": "0",
|
||||
"10000-count-one": "0",
|
||||
"10000-count-other": "0",
|
||||
"100000-count-one": "0",
|
||||
"100000-count-other": "0",
|
||||
"1000000-count-one": "0 Mio'.'",
|
||||
"1000000-count-other": "0 Mio'.'",
|
||||
"10000000-count-one": "00 Mio'.'",
|
||||
"10000000-count-other": "00 Mio'.'",
|
||||
"100000000-count-one": "000 Mio'.'",
|
||||
"100000000-count-other": "000 Mio'.'",
|
||||
"1000000000-count-one": "0 Mrd'.'",
|
||||
"1000000000-count-other": "0 Mrd'.'",
|
||||
"10000000000-count-one": "00 Mrd'.'",
|
||||
"10000000000-count-other": "00 Mrd'.'",
|
||||
"100000000000-count-one": "000 Mrd'.'",
|
||||
"100000000000-count-other": "000 Mrd'.'",
|
||||
"1000000000000-count-one": "0 Bio'.'",
|
||||
"1000000000000-count-other": "0 Bio'.'",
|
||||
"10000000000000-count-one": "00 Bio'.'",
|
||||
"10000000000000-count-other": "00 Bio'.'",
|
||||
"100000000000000-count-one": "000 Bio'.'",
|
||||
"100000000000000-count-other": "000 Bio'.'"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scientificFormats-numberSystem-latn": {
|
||||
"standard": "#E0"
|
||||
},
|
||||
"percentFormats-numberSystem-latn": {
|
||||
"standard": "#,##0 %"
|
||||
},
|
||||
"currencyFormats-numberSystem-latn": {
|
||||
"currencySpacing": {
|
||||
"beforeCurrency": {
|
||||
"currencyMatch": "[[:^S:]&[:^Z:]]",
|
||||
"surroundingMatch": "[:digit:]",
|
||||
"insertBetween": " "
|
||||
},
|
||||
"afterCurrency": {
|
||||
"currencyMatch": "[[:^S:]&[:^Z:]]",
|
||||
"surroundingMatch": "[:digit:]",
|
||||
"insertBetween": " "
|
||||
}
|
||||
},
|
||||
"standard": "#,##0.00 ¤",
|
||||
"standard-alphaNextToNumber": "¤ #,##0.00",
|
||||
"standard-noCurrency": "#,##0.00",
|
||||
"accounting": "#,##0.00 ¤",
|
||||
"accounting-alphaNextToNumber": "¤ #,##0.00",
|
||||
"accounting-noCurrency": "#,##0.00",
|
||||
"short": {
|
||||
"standard": {
|
||||
"1000-count-one": "0",
|
||||
"1000-count-other": "0",
|
||||
"10000-count-one": "0",
|
||||
"10000-count-other": "0",
|
||||
"100000-count-one": "0",
|
||||
"100000-count-other": "0",
|
||||
"1000000-count-one": "0 Mio'.' ¤",
|
||||
"1000000-count-other": "0 Mio'.' ¤",
|
||||
"10000000-count-one": "00 Mio'.' ¤",
|
||||
"10000000-count-other": "00 Mio'.' ¤",
|
||||
"100000000-count-one": "000 Mio'.' ¤",
|
||||
"100000000-count-other": "000 Mio'.' ¤",
|
||||
"1000000000-count-one": "0 Mrd'.' ¤",
|
||||
"1000000000-count-other": "0 Mrd'.' ¤",
|
||||
"10000000000-count-one": "00 Mrd'.' ¤",
|
||||
"10000000000-count-other": "00 Mrd'.' ¤",
|
||||
"100000000000-count-one": "000 Mrd'.' ¤",
|
||||
"100000000000-count-other": "000 Mrd'.' ¤",
|
||||
"1000000000000-count-one": "0 Bio'.' ¤",
|
||||
"1000000000000-count-other": "0 Bio'.' ¤",
|
||||
"10000000000000-count-one": "00 Bio'.' ¤",
|
||||
"10000000000000-count-other": "00 Bio'.' ¤",
|
||||
"100000000000000-count-one": "000 Bio'.' ¤",
|
||||
"100000000000000-count-other": "000 Bio'.' ¤"
|
||||
}
|
||||
},
|
||||
"currencyPatternAppendISO": "{0} ¤¤",
|
||||
"unitPattern-count-other": "{0} {1}"
|
||||
},
|
||||
"miscPatterns-numberSystem-latn": {
|
||||
"approximately": "≈{0}",
|
||||
"atLeast": "{0}+",
|
||||
"atMost": "≤{0}",
|
||||
"range": "{0}–{1}"
|
||||
},
|
||||
"minimalPairs": {
|
||||
"pluralMinimalPairs-count-one": "{0} Tag",
|
||||
"pluralMinimalPairs-count-other": "{0} Tage",
|
||||
"other": "{0}. Abzweigung nach rechts nehmen",
|
||||
"accusative": "… für {0} …",
|
||||
"dative": "… mit {0} …",
|
||||
"genitive": "Anstatt {0} …",
|
||||
"nominative": "{0} kostet (kosten) € 3,50.",
|
||||
"feminine": "Die {0} ist …",
|
||||
"masculine": "Der {0} ist …",
|
||||
"neuter": "Das {0} ist …"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1563
dashboard/src/cldr/timeZoneNames.json
Normal file
1563
dashboard/src/cldr/timeZoneNames.json
Normal file
File diff suppressed because it is too large
Load Diff
278
dashboard/src/clients.tsx
Normal file
278
dashboard/src/clients.tsx
Normal 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;
|
||||
515
dashboard/src/components/CustomEventModal.tsx
Normal file
515
dashboard/src/components/CustomEventModal.tsx
Normal 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;
|
||||
58
dashboard/src/components/CustomMediaInfoPanel.tsx
Normal file
58
dashboard/src/components/CustomMediaInfoPanel.tsx
Normal 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;
|
||||
119
dashboard/src/components/CustomSelectUploadEventModal.tsx
Normal file
119
dashboard/src/components/CustomSelectUploadEventModal.tsx
Normal 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;
|
||||
19
dashboard/src/components/SetupModeButton.tsx
Normal file
19
dashboard/src/components/SetupModeButton.tsx
Normal 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;
|
||||
24
dashboard/src/components/ToastProvider.tsx
Normal file
24
dashboard/src/components/ToastProvider.tsx
Normal 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
204
dashboard/src/dashboard.tsx
Normal 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;
|
||||
87
dashboard/src/einstellungen.tsx
Normal file
87
dashboard/src/einstellungen.tsx
Normal 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;
|
||||
52
dashboard/src/groupColors.ts
Normal file
52
dashboard/src/groupColors.ts
Normal 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];
|
||||
}
|
||||
36
dashboard/src/hooks/useClientDelete.ts
Normal file
36
dashboard/src/hooks/useClientDelete.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useState } from 'react';
|
||||
import { deleteClient } from '../apiClients';
|
||||
|
||||
export function useClientDelete(onDeleted?: (uuid: string) => void) {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [deleteClientId, setDeleteClientId] = useState<string | null>(null);
|
||||
|
||||
// Details-Modal separat im Parent verwalten!
|
||||
|
||||
const handleDelete = (uuid: string) => {
|
||||
setDeleteClientId(uuid);
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (deleteClientId) {
|
||||
await deleteClient(deleteClientId);
|
||||
setShowDialog(false);
|
||||
if (onDeleted) onDeleted(deleteClientId);
|
||||
setDeleteClientId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelDelete = () => {
|
||||
setShowDialog(false);
|
||||
setDeleteClientId(null);
|
||||
};
|
||||
|
||||
return {
|
||||
showDialog,
|
||||
deleteClientId,
|
||||
handleDelete,
|
||||
confirmDelete,
|
||||
cancelDelete,
|
||||
};
|
||||
}
|
||||
76
dashboard/src/index.css
Normal file
76
dashboard/src/index.css
Normal 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;
|
||||
}
|
||||
} */
|
||||
519
dashboard/src/infoscreen_groups.tsx
Normal file
519
dashboard/src/infoscreen_groups.tsx
Normal 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
12
dashboard/src/logout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
const Logout: React.FC = () => (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold mb-4">Abmeldung</h2>
|
||||
<p>Sie haben sich erfolgreich abgemeldet.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Logout;
|
||||
19
dashboard/src/main.tsx
Normal file
19
dashboard/src/main.tsx
Normal 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
117
dashboard/src/media.tsx
Normal 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;
|
||||
172
dashboard/src/programminfo.tsx
Normal file
172
dashboard/src/programminfo.tsx
Normal 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;
|
||||
8
dashboard/src/ressourcen.tsx
Normal file
8
dashboard/src/ressourcen.tsx
Normal 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
4
dashboard/src/types/json.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.json' {
|
||||
const value: unknown;
|
||||
export default value;
|
||||
}
|
||||
1
dashboard/src/vite-env.d.ts
vendored
Normal file
1
dashboard/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
10
dashboard/tailwind.config.cjs
Normal file
10
dashboard/tailwind.config.cjs
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
corePlugins: {
|
||||
preflight: false,
|
||||
},
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
27
dashboard/tsconfig.app.json
Normal file
27
dashboard/tsconfig.app.json
Normal 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
10
dashboard/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"typeRoots": ["./src/types", "./node_modules/@types"]
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
25
dashboard/tsconfig.node.json
Normal file
25
dashboard/tsconfig.node.json
Normal 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
54
dashboard/vite.config.ts
Normal 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
24
dashboard/wait-for-backend.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
# wait-for-backend.sh
|
||||
|
||||
# Stellt sicher, dass das Skript bei einem Fehler abbricht
|
||||
set -e
|
||||
|
||||
# Der erste Parameter ist der Host, der erreicht werden soll
|
||||
host="$1"
|
||||
# Alle weiteren Parameter bilden den Befehl, der danach ausgeführt werden soll
|
||||
shift
|
||||
cmd="$@"
|
||||
|
||||
# Schleife, die so lange läuft, bis der Host mit einem erfolgreichen HTTP-Status antwortet
|
||||
# curl -s: silent mode (kein Fortschrittsbalken)
|
||||
# curl -f: fail silently (gibt einen Fehlercode > 0 zurück, wenn der HTTP-Status nicht 2xx ist)
|
||||
until curl -s -f "$host" > /dev/null; do
|
||||
>&2 echo "Backend ist noch nicht erreichbar - schlafe für 2 Sekunden"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Wenn die Schleife beendet ist, ist das Backend erreichbar
|
||||
>&2 echo "Backend ist erreichbar - starte Vite-Server..."
|
||||
# Führe den eigentlichen Befehl aus (z.B. npm run dev)
|
||||
exec $cmd
|
||||
Reference in New Issue
Block a user