initial feature/react-migration commit
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
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,39 @@
|
||||
# dashboard/Dockerfile
|
||||
# Produktions-Dockerfile für die Dash-Applikation
|
||||
# ==========================================
|
||||
# dashboard/Dockerfile (Production)
|
||||
# ==========================================
|
||||
FROM node:lts-alpine AS builder
|
||||
|
||||
# --- Basis-Image ---
|
||||
FROM python:3.13-slim
|
||||
|
||||
# --- Arbeitsverzeichnis im Container ---
|
||||
WORKDIR /app
|
||||
|
||||
# --- Systemabhängigkeiten installieren (falls benötigt) ---
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY pnpm-lock.yaml* ./
|
||||
|
||||
# --- Python-Abhängigkeiten kopieren und installieren ---
|
||||
COPY dash_using_fullcalendar-0.1.0.tar.gz ./
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Install pnpm and dependencies
|
||||
RUN npm install -g pnpm
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# --- Applikationscode kopieren ---
|
||||
COPY dashboard/ /app
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# --- Non-Root-User anlegen und Rechte setzen ---
|
||||
ARG USER_ID=1000
|
||||
ARG GROUP_ID=1000
|
||||
RUN groupadd -g ${GROUP_ID} infoscreen_taa \
|
||||
&& useradd -u ${USER_ID} -g ${GROUP_ID} \
|
||||
--shell /bin/bash --create-home infoscreen_taa \
|
||||
&& chown -R infoscreen_taa:infoscreen_taa /app
|
||||
USER infoscreen_taa
|
||||
# Build arguments
|
||||
ARG NODE_ENV=production
|
||||
ARG VITE_API_URL
|
||||
|
||||
# --- Port für Dash exposed ---
|
||||
EXPOSE 8050
|
||||
# Build the application
|
||||
RUN pnpm build
|
||||
|
||||
# --- Startbefehl: Gunicorn mit Dash-Server ---
|
||||
# "app.py" enthält: app = dash.Dash(...); server = app.server
|
||||
CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:8050", "app:server"]
|
||||
# Production stage with nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built files to nginx
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy custom nginx config (optional)
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -1,51 +1,24 @@
|
||||
# dashboard/Dockerfile.dev
|
||||
# Entwicklungs-Dockerfile für das Dash-Dashboard
|
||||
# ==========================================
|
||||
# dashboard/Dockerfile.dev (Development)
|
||||
# ==========================================
|
||||
FROM node:lts-alpine
|
||||
|
||||
FROM python:3.13-slim
|
||||
WORKDIR /workspace/dashboard
|
||||
|
||||
# Build args für UID/GID
|
||||
ARG USER_ID=1000
|
||||
ARG GROUP_ID=1000
|
||||
# Install dependencies manager (pnpm optional, npm reicht für Compose-Setup)
|
||||
# RUN npm install -g pnpm
|
||||
|
||||
# Systemabhängigkeiten (falls nötig)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends locales curl \
|
||||
&& sed -i 's/# de_DE.UTF-8 UTF-8/de_DE.UTF-8 UTF-8/' /etc/locale.gen \
|
||||
&& locale-gen \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Locale setzen
|
||||
ENV LANG=de_DE.UTF-8 \
|
||||
LANGUAGE=de_DE:de \
|
||||
LC_ALL=de_DE.UTF-8
|
||||
# Install dependencies (nutze npm, da Compose "npm run dev" nutzt)
|
||||
RUN npm install
|
||||
|
||||
# Non-root User anlegen
|
||||
RUN groupadd -g ${GROUP_ID} infoscreen_taa \
|
||||
&& useradd -u ${USER_ID} -g ${GROUP_ID} --shell /bin/bash --create-home infoscreen_taa
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Arbeitsverzeichnis
|
||||
WORKDIR /app
|
||||
# Expose ports
|
||||
EXPOSE 3000 9229
|
||||
|
||||
# Kopiere nur Requirements für schnellen Rebuild
|
||||
COPY dash_using_fullcalendar-0.1.0.tar.gz ./
|
||||
COPY requirements.txt ./
|
||||
COPY requirements-dev.txt ./
|
||||
|
||||
# Installiere Abhängigkeiten
|
||||
RUN pip install --upgrade pip \
|
||||
&& pip install --no-cache-dir -r requirements.txt \
|
||||
&& pip install --no-cache-dir -r requirements-dev.txt
|
||||
|
||||
# Setze Entwicklungs-Modus
|
||||
ENV DASH_DEBUG_MODE=True
|
||||
ENV API_URL=http://server:8000/api
|
||||
|
||||
# Ports für Dash und optional Live-Reload
|
||||
EXPOSE 8050
|
||||
EXPOSE 5678
|
||||
|
||||
# Wechsle zum non-root User
|
||||
USER infoscreen_taa
|
||||
|
||||
# Dev-Start: Dash mit Hot-Reload
|
||||
CMD ["python", "-m", "debugpy", "--listen", "0.0.0.0:5637", "app.py"]
|
||||
# Standard-Dev-Command (wird von Compose überschrieben)
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
|
||||
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,
|
||||
},
|
||||
})
|
||||
```
|
||||
@@ -1,79 +0,0 @@
|
||||
# dashboard/app.py
|
||||
import sys
|
||||
sys.path.append('/workspace')
|
||||
|
||||
from dash import Dash, html, dcc, page_container
|
||||
from flask import Flask
|
||||
import dash_bootstrap_components as dbc
|
||||
import dash_mantine_components as dmc
|
||||
from components.header import Header
|
||||
import callbacks.ui_callbacks
|
||||
import dashboard.callbacks.overview_callbacks
|
||||
import dashboard.callbacks.appointments_callbacks
|
||||
import dashboard.callbacks.appointment_modal_callbacks
|
||||
from config import SECRET_KEY, ENV
|
||||
import os
|
||||
import threading
|
||||
import logging
|
||||
|
||||
# Logging konfigurieren
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if ENV == "development" else logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
server = Flask(__name__)
|
||||
server.secret_key = SECRET_KEY
|
||||
|
||||
# Flask's eigene Logs aktivieren
|
||||
if ENV == "development":
|
||||
logging.getLogger('werkzeug').setLevel(logging.INFO)
|
||||
|
||||
app = Dash(
|
||||
__name__,
|
||||
server=server,
|
||||
use_pages=True,
|
||||
external_stylesheets=[dbc.themes.BOOTSTRAP],
|
||||
suppress_callback_exceptions=True,
|
||||
serve_locally=True,
|
||||
meta_tags=[
|
||||
{"name": "viewport", "content": "width=device-width, initial-scale=1"},
|
||||
{"charset": "utf-8"}
|
||||
]
|
||||
)
|
||||
|
||||
app.layout = dmc.MantineProvider([
|
||||
Header(),
|
||||
html.Div([
|
||||
html.Div(id="sidebar"),
|
||||
html.Div(page_container, className="page-content"),
|
||||
dcc.Store(id="sidebar-state", data={"collapsed": False}),
|
||||
], style={"display": "flex"}),
|
||||
])
|
||||
|
||||
# def open_browser():
|
||||
# """Öffnet die HTTPS-URL im Standardbrowser."""
|
||||
# os.system('$BROWSER https://localhost:8050') # Entferne das "&", um sicherzustellen, dass der Browser korrekt geöffnet wird
|
||||
|
||||
print("Testausgabe: Debug-Print funktioniert!") # Testausgabe
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_mode = ENV == "development"
|
||||
|
||||
logger.info(f"Starting application in {'DEBUG' if debug_mode else 'PRODUCTION'} mode")
|
||||
logger.info(f"Environment: {ENV}")
|
||||
logger.info("🔧 Debug features: print(), logging, hot reload all active")
|
||||
logger.info("🚀 Dashboard starting up...")
|
||||
|
||||
# Browser nur einmal öffnen, nicht bei Reload-Prozessen
|
||||
# if debug_mode and os.environ.get("WERKZEUG_RUN_MAIN") != "true":
|
||||
# threading.Timer(1.0, open_browser).start()
|
||||
|
||||
app.run(
|
||||
host="0.0.0.0",
|
||||
port=8050,
|
||||
debug=debug_mode,
|
||||
ssl_context=("/workspace/certs/dev.crt", "/workspace/certs/dev.key"),
|
||||
use_reloader=False # Verhindert doppeltes Öffnen durch Dash
|
||||
)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
@@ -1,249 +0,0 @@
|
||||
/* ==========================
|
||||
Allgemeines Layout
|
||||
========================== */
|
||||
:root {
|
||||
--mb-z-index: 2000 !important;
|
||||
--mantine-z-index-popover: 2100 !important;
|
||||
--mantine-z-index-overlay: 2999 !important;
|
||||
--mantine-z-index-dropdown: 2100 !important;
|
||||
--mantine-z-index-max: 9999 !important;
|
||||
--mantine-z-index-modal: 3000 !important;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
padding-top: 60px; /* Platz für den fixen Header schaffen */
|
||||
}
|
||||
|
||||
/* page-content (rechts neben der Sidebar) */
|
||||
.page-content {
|
||||
flex: 1 1 0%;
|
||||
padding: 20px;
|
||||
min-width: 0; /* verhindert Überlauf bei zu breitem Inhalt */
|
||||
transition: margin-left 0.3s ease;
|
||||
min-height: calc(100vh - 60px); /* Mindesthöhe minus Header-Höhe */
|
||||
margin-left: 220px; /* <--- Ergänzen */
|
||||
}
|
||||
|
||||
/* Wenn Sidebar collapsed ist, reduziere margin-left */
|
||||
.sidebar.collapsed ~ .page-content {
|
||||
margin-left: 70px;
|
||||
}
|
||||
|
||||
/* ==========================
|
||||
Header
|
||||
========================== */
|
||||
.app-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 60px;
|
||||
width: 100%;
|
||||
background-color: #e4d5c1;
|
||||
color: #7c5617;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
z-index: 1100;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 40px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.org-name {
|
||||
font-size: 1rem;
|
||||
color: #7c5617;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ==========================
|
||||
Sidebar
|
||||
========================== */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
transition: width 0.3s ease;
|
||||
background-color: #e4d5c1;
|
||||
color: black;
|
||||
height: calc(100vh - 60px); /* Höhe minus Header */
|
||||
top: 60px; /* Den gleichen Wert wie Header-Höhe verwenden */
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
position: fixed; /* <--- Ändere das von relative zu fixed */
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
/* Sidebar Toggle Button (Burger-Icon) */
|
||||
.sidebar-toggle {
|
||||
text-align: right;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #7c5617;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
transition: transform 0.1s ease, background-color 0.2s ease;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.toggle-button:hover {
|
||||
background-color: #7c5617;
|
||||
color: #e4d5c1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.toggle-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Navigation in der Sidebar */
|
||||
.sidebar-nav .nav-link {
|
||||
color: #7c5617;
|
||||
padding: 10px 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
margin: 2px 8px;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link:hover {
|
||||
background-color: #7c5617;
|
||||
color: #e4d5c1;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link.active {
|
||||
background-color: #7c5617;
|
||||
color: #e4d5c1;
|
||||
}
|
||||
|
||||
/* Text neben Icons */
|
||||
.sidebar-label {
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.3s ease, width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Wenn Sidebar collapsed ist, blendet das Label aus */
|
||||
.sidebar.collapsed .sidebar-label {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Tooltips (Bootstrap-Tooltips) */
|
||||
.tooltip {
|
||||
z-index: 2000;
|
||||
background-color: #7c5617;
|
||||
color: #e4d5c1;
|
||||
}
|
||||
|
||||
/* Optional: Tooltips nur anzeigen, wenn Sidebar collapsed ist */
|
||||
/* Da dash-bootstrap-components Tooltips in einen anderen DOM-Layer rendert,
|
||||
kann man bei Bedarf per Callback steuern, ob sie geöffnet sind oder nicht.
|
||||
Dieser Block ist nur ein Zusatz – das Haupt-Show/Hiding erfolgt per
|
||||
is_open-Callback. */
|
||||
.sidebar:not(.collapsed) ~ .tooltip {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ==========================
|
||||
Responsive (bei Bedarf)
|
||||
========================== */
|
||||
/* @media (max-width: 768px) {
|
||||
body {
|
||||
padding-top: 60px; /* Header-Platz auch auf mobilen Geräten */
|
||||
/* }
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
height: calc(100vh - 60px);
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.sidebar.collapsed ~ .page-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
} */
|
||||
|
||||
.mantine-Modal-modal {
|
||||
z-index: var(--mantine-z-index-modal, 3000) !important;
|
||||
}
|
||||
|
||||
/* Modalbox */
|
||||
.mantine-Modal-inner,
|
||||
.mantine-Modal-content {
|
||||
z-index: 4000 !important;
|
||||
}
|
||||
|
||||
/* Popups (Dropdowns, Datepicker, Autocomplete, Menüs) innerhalb der Modalbox */
|
||||
.mantine-Popover-dropdown,
|
||||
.mantine-Select-dropdown,
|
||||
.mantine-DatePicker-dropdown,
|
||||
.mantine-Autocomplete-dropdown,
|
||||
.mantine-Menu-dropdown {
|
||||
z-index: 4100 !important;
|
||||
}
|
||||
|
||||
/* Optional: Overlay für Popups noch höher, falls benötigt */
|
||||
.mantine-Popover-root,
|
||||
.mantine-Select-root,
|
||||
.mantine-DatePicker-root,
|
||||
.mantine-Autocomplete-root,
|
||||
.mantine-Menu-root {
|
||||
z-index: 4101 !important;
|
||||
}
|
||||
|
||||
/* Sidebar collapsed: Icon-Farbe normal */
|
||||
.sidebar.collapsed .sidebar-item-collapsed svg {
|
||||
color: #7c5617; /* Icon-Linie/Text */
|
||||
fill: #e4d5c1; /* Icon-Fläche */
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
transition: color 0.2s, fill 0.2s;
|
||||
}
|
||||
|
||||
/* Sidebar collapsed: Hintergrund und Icon invertieren bei Hover/Active */
|
||||
.sidebar.collapsed .nav-link:hover,
|
||||
.sidebar.collapsed .nav-link.active {
|
||||
background-color: #7c5617 !important;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-link:hover svg,
|
||||
.sidebar.collapsed .nav-link.active svg {
|
||||
color: #e4d5c1; /* Icon-Linie/Text invertiert */
|
||||
fill: #7c5617; /* Icon-Fläche invertiert */
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 225 KiB |
@@ -1,370 +0,0 @@
|
||||
from dash import Input, Output, State, callback, html, dcc, no_update, ctx
|
||||
import dash_mantine_components as dmc
|
||||
from dash_iconify import DashIconify
|
||||
import dash_quill
|
||||
from datetime import datetime, date, timedelta
|
||||
import re
|
||||
|
||||
# --- Typ-spezifische Felder anzeigen ---
|
||||
@callback(
|
||||
Output('type-specific-fields', 'children'),
|
||||
Input('type-input', 'value'),
|
||||
prevent_initial_call=True
|
||||
)
|
||||
def show_type_specific_fields(event_type):
|
||||
if not event_type:
|
||||
return html.Div()
|
||||
|
||||
if event_type == "presentation":
|
||||
return dmc.Stack([
|
||||
dmc.Divider(label="Präsentations-Details", labelPosition="center"),
|
||||
dmc.Group([
|
||||
dcc.Upload(
|
||||
id='presentation-upload',
|
||||
children=dmc.Button(
|
||||
"Datei hochladen",
|
||||
leftSection=DashIconify(icon="mdi:upload"),
|
||||
variant="outline"
|
||||
),
|
||||
style={'width': 'auto'}
|
||||
),
|
||||
dmc.TextInput(
|
||||
label="Präsentationslink",
|
||||
placeholder="https://...",
|
||||
leftSection=DashIconify(icon="mdi:link"),
|
||||
id="presentation-link",
|
||||
style={"minWidth": 0, "flex": 1, "marginBottom": 0}
|
||||
)
|
||||
], grow=True, align="flex-end", style={"marginBottom": 10}),
|
||||
html.Div(id="presentation-upload-status")
|
||||
], gap="sm")
|
||||
|
||||
elif event_type == "video":
|
||||
return dmc.Stack([
|
||||
dmc.Divider(label="Video-Details", labelPosition="center"),
|
||||
dmc.Group([
|
||||
dcc.Upload(
|
||||
id='video-upload',
|
||||
children=dmc.Button(
|
||||
"Video hochladen",
|
||||
leftSection=DashIconify(icon="mdi:video-plus"),
|
||||
variant="outline"
|
||||
),
|
||||
style={'width': 'auto'}
|
||||
),
|
||||
dmc.TextInput(
|
||||
label="Videolink",
|
||||
placeholder="https://youtube.com/...",
|
||||
leftSection=DashIconify(icon="mdi:youtube"),
|
||||
id="video-link",
|
||||
style={"minWidth": 0, "flex": 1, "marginBottom": 0}
|
||||
)
|
||||
], grow=True, align="flex-end", style={"marginBottom": 10}),
|
||||
dmc.Group([
|
||||
dmc.Checkbox(
|
||||
label="Endlos wiederholen",
|
||||
id="video-endless",
|
||||
checked=True,
|
||||
style={"marginRight": 20}
|
||||
),
|
||||
dmc.NumberInput(
|
||||
label="Wiederholungen",
|
||||
id="video-repeats",
|
||||
value=1,
|
||||
min=1,
|
||||
max=99,
|
||||
step=1,
|
||||
disabled=True,
|
||||
style={"width": 150}
|
||||
),
|
||||
dmc.Slider(
|
||||
label="Lautstärke",
|
||||
id="video-volume",
|
||||
value=70,
|
||||
min=0,
|
||||
max=100,
|
||||
step=5,
|
||||
marks=[
|
||||
{"value": 0, "label": "0%"},
|
||||
{"value": 50, "label": "50%"},
|
||||
{"value": 100, "label": "100%"}
|
||||
],
|
||||
style={"flex": 1, "marginLeft": 20}
|
||||
)
|
||||
], grow=True, align="flex-end"),
|
||||
html.Div(id="video-upload-status")
|
||||
], gap="sm")
|
||||
|
||||
elif event_type == "website":
|
||||
return dmc.Stack([
|
||||
dmc.Divider(label="Website-Details", labelPosition="center"),
|
||||
dmc.TextInput(
|
||||
label="Website-URL",
|
||||
placeholder="https://example.com",
|
||||
leftSection=DashIconify(icon="mdi:web"),
|
||||
id="website-url",
|
||||
required=True,
|
||||
style={"flex": 1}
|
||||
)
|
||||
], gap="sm")
|
||||
|
||||
elif event_type == "message":
|
||||
return dmc.Stack([
|
||||
dmc.Divider(label="Nachrichten-Details", labelPosition="center"),
|
||||
dash_quill.Quill(
|
||||
id="message-content",
|
||||
value="",
|
||||
),
|
||||
dmc.Group([
|
||||
dmc.Select(
|
||||
label="Schriftgröße",
|
||||
data=[
|
||||
{"value": "small", "label": "Klein"},
|
||||
{"value": "medium", "label": "Normal"},
|
||||
{"value": "large", "label": "Groß"},
|
||||
{"value": "xlarge", "label": "Sehr groß"}
|
||||
],
|
||||
id="message-font-size",
|
||||
value="medium",
|
||||
style={"flex": 1}
|
||||
),
|
||||
dmc.ColorPicker(
|
||||
id="message-color",
|
||||
value="#000000",
|
||||
format="hex",
|
||||
swatches=[
|
||||
"#000000", "#ffffff", "#ff0000", "#00ff00",
|
||||
"#0000ff", "#ffff00", "#ff00ff", "#00ffff"
|
||||
]
|
||||
)
|
||||
], grow=True, align="flex-end")
|
||||
], gap="sm")
|
||||
|
||||
return html.Div()
|
||||
|
||||
# --- Callback zum Aktivieren/Deaktivieren des Wiederholungsfelds bei Video ---
|
||||
@callback(
|
||||
Output("video-repeats", "disabled"),
|
||||
Input("video-endless", "checked"),
|
||||
prevent_initial_call=True
|
||||
)
|
||||
def toggle_video_repeats(endless_checked):
|
||||
return endless_checked
|
||||
|
||||
# --- Upload-Status für Präsentation ---
|
||||
@callback(
|
||||
Output('presentation-upload-status', 'children'),
|
||||
Input('presentation-upload', 'contents'),
|
||||
State('presentation-upload', 'filename'),
|
||||
prevent_initial_call=True
|
||||
)
|
||||
def update_presentation_upload_status(contents, filename):
|
||||
if contents is not None and filename is not None:
|
||||
return dmc.Alert(
|
||||
f"✓ Datei '{filename}' erfolgreich hochgeladen",
|
||||
color="green",
|
||||
className="mt-2"
|
||||
)
|
||||
return html.Div()
|
||||
|
||||
# --- Upload-Status für Video ---
|
||||
@callback(
|
||||
Output('video-upload-status', 'children'),
|
||||
Input('video-upload', 'contents'),
|
||||
State('video-upload', 'filename'),
|
||||
prevent_initial_call=True
|
||||
)
|
||||
def update_video_upload_status(contents, filename):
|
||||
if contents is not None and filename is not None:
|
||||
return dmc.Alert(
|
||||
f"✓ Video '{filename}' erfolgreich hochgeladen",
|
||||
color="green",
|
||||
className="mt-2"
|
||||
)
|
||||
return html.Div()
|
||||
|
||||
# --- Wiederholungsoptionen aktivieren/deaktivieren ---
|
||||
@callback(
|
||||
[
|
||||
Output('weekdays-select', 'disabled'),
|
||||
Output('repeat-until-date', 'disabled'),
|
||||
Output('skip-holidays-checkbox', 'disabled'),
|
||||
Output('weekdays-select', 'value'),
|
||||
Output('repeat-until-date', 'value'),
|
||||
Output('skip-holidays-checkbox', 'checked')
|
||||
],
|
||||
Input('repeat-checkbox', 'checked'),
|
||||
prevent_initial_call=True
|
||||
)
|
||||
def toggle_repeat_options(is_repeat):
|
||||
if is_repeat:
|
||||
next_month = datetime.now().date() + timedelta(weeks=4)
|
||||
return (
|
||||
False, # weekdays-select enabled
|
||||
False, # repeat-until-date enabled
|
||||
False, # skip-holidays-checkbox enabled
|
||||
None, # weekdays value
|
||||
next_month, # repeat-until-date value
|
||||
False # skip-holidays-checkbox checked
|
||||
)
|
||||
else:
|
||||
return (
|
||||
True, # weekdays-select disabled
|
||||
True, # repeat-until-date disabled
|
||||
True, # skip-holidays-checkbox disabled
|
||||
None, # weekdays value
|
||||
None, # repeat-until-date value
|
||||
False # skip-holidays-checkbox checked
|
||||
)
|
||||
|
||||
# --- Dynamische Zeitoptionen für Startzeit ---
|
||||
def validate_and_format_time(time_str):
|
||||
if not time_str:
|
||||
return None, "Keine Zeit angegeben"
|
||||
if re.match(r'^\d{2}:\d{2}$', time_str):
|
||||
try:
|
||||
h, m = map(int, time_str.split(':'))
|
||||
if 0 <= h <= 23 and 0 <= m <= 59:
|
||||
return time_str, "Gültige Zeit"
|
||||
except:
|
||||
pass
|
||||
patterns = [
|
||||
(r'^(\d{1,2}):(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
|
||||
(r'^(\d{1,2})\.(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
|
||||
(r'^(\d{1,2})(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
|
||||
(r'^(\d{1,2})$', lambda m: (int(m.group(1)), 0)),
|
||||
]
|
||||
for pattern, extractor in patterns:
|
||||
match = re.match(pattern, time_str.strip())
|
||||
if match:
|
||||
try:
|
||||
hours, minutes = extractor(match)
|
||||
if 0 <= hours <= 23 and 0 <= minutes <= 59:
|
||||
return f"{hours:02d}:{minutes:02d}", "Automatisch formatiert"
|
||||
except:
|
||||
continue
|
||||
return None, "Ungültiges Zeitformat"
|
||||
|
||||
@callback(
|
||||
[
|
||||
Output('time-start', 'data'),
|
||||
Output('start-time-feedback', 'children')
|
||||
],
|
||||
Input('time-start', 'searchValue'),
|
||||
prevent_initial_call=True
|
||||
)
|
||||
def update_start_time_options(search_value):
|
||||
time_options = [
|
||||
{"value": f"{h:02d}:{m:02d}", "label": f"{h:02d}:{m:02d}"}
|
||||
for h in range(6, 24) for m in [0, 30]
|
||||
]
|
||||
base_options = time_options.copy()
|
||||
feedback = None
|
||||
if search_value:
|
||||
validated_time, status = validate_and_format_time(search_value)
|
||||
if validated_time:
|
||||
if not any(opt["value"] == validated_time for opt in base_options):
|
||||
base_options.insert(0, {
|
||||
"value": validated_time,
|
||||
"label": f"{validated_time} (Ihre Eingabe)"
|
||||
})
|
||||
feedback = dmc.Text(f"✓ {status}: {validated_time}", size="xs", c="green")
|
||||
else:
|
||||
feedback = dmc.Text(f"✗ {status}", size="xs", c="red")
|
||||
return base_options, feedback
|
||||
|
||||
# --- Dynamische Zeitoptionen für Endzeit ---
|
||||
@callback(
|
||||
[
|
||||
Output('time-end', 'data'),
|
||||
Output('end-time-feedback', 'children')
|
||||
],
|
||||
Input('time-end', 'searchValue'),
|
||||
prevent_initial_call=True
|
||||
)
|
||||
def update_end_time_options(search_value):
|
||||
time_options = [
|
||||
{"value": f"{h:02d}:{m:02d}", "label": f"{h:02d}:{m:02d}"}
|
||||
for h in range(6, 24) for m in [0, 30]
|
||||
]
|
||||
base_options = time_options.copy()
|
||||
feedback = None
|
||||
if search_value:
|
||||
validated_time, status = validate_and_format_time(search_value)
|
||||
if validated_time:
|
||||
if not any(opt["value"] == validated_time for opt in base_options):
|
||||
base_options.insert(0, {
|
||||
"value": validated_time,
|
||||
"label": f"{validated_time} (Ihre Eingabe)"
|
||||
})
|
||||
feedback = dmc.Text(f"✓ {status}: {validated_time}", size="xs", c="green")
|
||||
else:
|
||||
feedback = dmc.Text(f"✗ {status}", size="xs", c="red")
|
||||
return base_options, feedback
|
||||
|
||||
# --- Automatische Endzeit-Berechnung mit Validation ---
|
||||
@callback(
|
||||
Output('time-end', 'value'),
|
||||
[
|
||||
Input('time-start', 'value'),
|
||||
Input('btn-reset', 'n_clicks')
|
||||
],
|
||||
State('time-end', 'value'),
|
||||
prevent_initial_call=True
|
||||
)
|
||||
def handle_end_time(start_time, reset_clicks, current_end_time):
|
||||
if not ctx.triggered:
|
||||
return no_update
|
||||
trigger_id = ctx.triggered[0]['prop_id'].split('.')[0]
|
||||
if trigger_id == 'btn-reset' and reset_clicks:
|
||||
return None
|
||||
if trigger_id == 'time-start' and start_time:
|
||||
if current_end_time:
|
||||
return no_update
|
||||
try:
|
||||
validated_start, _ = validate_and_format_time(start_time)
|
||||
if validated_start:
|
||||
start_dt = datetime.strptime(validated_start, "%H:%M")
|
||||
end_dt = start_dt + timedelta(hours=1, minutes=30)
|
||||
if end_dt.hour >= 24:
|
||||
end_dt = end_dt.replace(hour=23, minute=59)
|
||||
return end_dt.strftime("%H:%M")
|
||||
except:
|
||||
pass
|
||||
return no_update
|
||||
|
||||
# --- Reset-Funktion erweitert ---
|
||||
@callback(
|
||||
[
|
||||
Output('title-input', 'value'),
|
||||
Output('start-date-input', 'value', allow_duplicate=True),
|
||||
Output('time-start', 'value'),
|
||||
Output('type-input', 'value'),
|
||||
Output('description-input', 'value'),
|
||||
Output('repeat-checkbox', 'checked'),
|
||||
Output('weekdays-select', 'value', allow_duplicate=True),
|
||||
Output('repeat-until-date', 'value', allow_duplicate=True),
|
||||
Output('skip-holidays-checkbox', 'checked', allow_duplicate=True)
|
||||
],
|
||||
Input('btn-reset', 'n_clicks'),
|
||||
prevent_initial_call=True
|
||||
)
|
||||
def reset_form(n_clicks):
|
||||
if n_clicks:
|
||||
return "", datetime.now().date(), "09:00", None, "", False, None, None, False
|
||||
return no_update
|
||||
|
||||
# --- Speichern-Funktion (Demo) ---
|
||||
@callback(
|
||||
Output('save-feedback', 'children'),
|
||||
Input('btn-save', 'n_clicks'),
|
||||
prevent_initial_call=True
|
||||
)
|
||||
def save_appointments_demo(n_clicks):
|
||||
if not n_clicks:
|
||||
return no_update
|
||||
return dmc.Alert(
|
||||
"Demo: Termine würden hier gespeichert werden",
|
||||
color="blue",
|
||||
title="Speichern (Demo)"
|
||||
)
|
||||
@@ -1,168 +0,0 @@
|
||||
import logging
|
||||
from math import fabs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# dashboard/callbacks/appointments_callbacks.py
|
||||
import requests
|
||||
import json
|
||||
from flask import session
|
||||
from dash import Input, Output, State, callback, ctx, dash, no_update
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# This message will now appear in the terminal during startup
|
||||
logger.debug("Registering appointments page...")
|
||||
|
||||
|
||||
# --- Modalbox öffnen: jetzt auch auf Kalenderklick reagieren ---
|
||||
|
||||
sys.path.append('/workspace')
|
||||
|
||||
print("appointments_callbacks.py geladen")
|
||||
|
||||
API_BASE_URL = os.getenv("API_BASE_URL", "http://192.168.43.100")
|
||||
ENV = os.getenv("ENV", "development")
|
||||
|
||||
|
||||
@callback(
|
||||
dash.Output('output', 'children'),
|
||||
dash.Input('calendar', 'lastDateClick')
|
||||
)
|
||||
def display_date(date_str):
|
||||
if date_str:
|
||||
return f"Letzter Klick auf: {date_str}"
|
||||
return "Klicke auf ein Datum im Kalender!"
|
||||
|
||||
|
||||
@callback(
|
||||
dash.Output('event-output', 'children'),
|
||||
dash.Input('calendar', 'lastEventClick')
|
||||
)
|
||||
def display_event(event_id):
|
||||
if event_id:
|
||||
return f"Letztes Event geklickt: {event_id}"
|
||||
return "Klicke auf ein Event im Kalender!"
|
||||
|
||||
|
||||
@callback(
|
||||
dash.Output('select-output', 'children'),
|
||||
dash.Input('calendar', 'lastSelect')
|
||||
)
|
||||
def display_select(select_info):
|
||||
if select_info:
|
||||
return f"Markiert: {select_info['start']} bis {select_info['end']} (ganztägig: {select_info['allDay']})"
|
||||
return "Markiere einen Bereich im Kalender!"
|
||||
|
||||
|
||||
@callback(
|
||||
dash.Output('calendar', 'events'),
|
||||
dash.Input('calendar', 'lastNavClick'),
|
||||
)
|
||||
def load_events(view_dates):
|
||||
logger.info(f"Lade Events für Zeitraum: {view_dates}")
|
||||
if not view_dates or "start" not in view_dates or "end" not in view_dates:
|
||||
return []
|
||||
start = view_dates["start"]
|
||||
end = view_dates["end"]
|
||||
try:
|
||||
verify_ssl = True if ENV == "production" else False
|
||||
resp = requests.get(
|
||||
f"{API_BASE_URL}/api/events",
|
||||
params={"start": start, "end": end},
|
||||
verify=verify_ssl
|
||||
)
|
||||
resp.raise_for_status()
|
||||
events = resp.json()
|
||||
return events
|
||||
except Exception as e:
|
||||
logger.info(f"Fehler beim Laden der Events: {e}")
|
||||
return []
|
||||
|
||||
# --- Modalbox öffnen ---
|
||||
|
||||
|
||||
@callback(
|
||||
[
|
||||
Output("appointment-modal", "opened"),
|
||||
Output("start-date-input", "value", allow_duplicate=True),
|
||||
Output("time-start", "value", allow_duplicate=True),
|
||||
Output("time-end", "value", allow_duplicate=True),
|
||||
],
|
||||
[
|
||||
Input("calendar", "lastDateClick"),
|
||||
Input("calendar", "lastSelect"),
|
||||
Input("open-appointment-modal-btn", "n_clicks"),
|
||||
Input("close-appointment-modal-btn", "n_clicks"),
|
||||
],
|
||||
State("appointment-modal", "opened"),
|
||||
prevent_initial_call=True
|
||||
)
|
||||
def open_modal(date_click, select, open_click, close_click, is_open):
|
||||
trigger = ctx.triggered_id
|
||||
|
||||
# Bereichsauswahl (lastSelect)
|
||||
if trigger == "calendar" and select:
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(select["start"])
|
||||
end_dt = datetime.fromisoformat(select["end"])
|
||||
|
||||
return (
|
||||
True,
|
||||
start_dt.date().isoformat(),
|
||||
start_dt.strftime("%H:%M"),
|
||||
end_dt.strftime("%H:%M"),
|
||||
)
|
||||
except Exception as e:
|
||||
print("Fehler beim Parsen von select:", e)
|
||||
return no_update, no_update, no_update, no_update
|
||||
|
||||
# Einzelklick (lastDateClick)
|
||||
if trigger == "calendar" and date_click:
|
||||
try:
|
||||
dt = datetime.fromisoformat(date_click)
|
||||
# Versuche, die Slotlänge aus dem Kalender zu übernehmen (optional)
|
||||
# Hier als Beispiel 30 Minuten aufaddieren, falls keine Endzeit vorhanden
|
||||
end_dt = dt + timedelta(minutes=30)
|
||||
return (
|
||||
True,
|
||||
dt.date().isoformat(),
|
||||
dt.strftime("%H:%M"),
|
||||
end_dt.strftime("%H:%M"),
|
||||
)
|
||||
except Exception as e:
|
||||
print("Fehler beim Parsen von date_click:", e)
|
||||
return no_update, no_update, no_update, no_update
|
||||
|
||||
# Modal öffnen per Button
|
||||
if trigger == "open-appointment-modal-btn" and open_click:
|
||||
now = datetime.now()
|
||||
end_dt = now + timedelta(minutes=30)
|
||||
return True, now.date().isoformat(), now.strftime("%H:%M"), end_dt.strftime("%H:%M")
|
||||
|
||||
# Modal schließen
|
||||
if trigger == "close-appointment-modal-btn" and close_click:
|
||||
return False, no_update, no_update, no_update
|
||||
|
||||
return is_open, no_update, no_update, no_update
|
||||
|
||||
|
||||
# @callback(
|
||||
# Output("time-end", "value", allow_duplicate=True),
|
||||
# Input("time-start", "value"),
|
||||
# prevent_initial_call=True
|
||||
# )
|
||||
# def handle_end_time(start_time, duration="00:30"):
|
||||
# trigger = ctx.triggered_id
|
||||
# if trigger == "time-start" and start_time and duration:
|
||||
# try:
|
||||
# # Beispiel für start_time: "09:00"
|
||||
# start_dt = datetime.strptime(start_time, "%H:%M")
|
||||
# # Dauer in Stunden und Minuten, z.B. "01:30"
|
||||
# hours, minutes = map(int, duration.split(":"))
|
||||
# # Endzeit berechnen: Dauer addieren!
|
||||
# end_dt = start_dt + timedelta(hours=hours, minutes=minutes)
|
||||
# return end_dt.strftime("%H:%M")
|
||||
# except Exception as e:
|
||||
# print("Fehler bei der Berechnung der Endzeit:", e)
|
||||
# return no_update
|
||||
@@ -1,31 +0,0 @@
|
||||
# dashboard/callbacks/auth_callbacks.py
|
||||
import dash
|
||||
from dash import Input, Output, State, dcc
|
||||
from flask import session
|
||||
from utils.auth import check_password, get_user_role
|
||||
from config import ENV
|
||||
from utils.db import execute_query
|
||||
|
||||
@dash.callback(
|
||||
Output("login-feedback", "children"),
|
||||
Output("header-right", "children"),
|
||||
Input("btn-login", "n_clicks"),
|
||||
State("input-user", "value"),
|
||||
State("input-pass", "value"),
|
||||
prevent_initial_call=True
|
||||
)
|
||||
def login_user(n_clicks, username, password):
|
||||
if ENV == "development":
|
||||
# Dev‐Bypass: setze immer Admin‐Session und leite weiter
|
||||
session["username"] = "dev_admin"
|
||||
session["role"] = "admin"
|
||||
return dcc.Location(href="/overview", id="redirect-dev"), None
|
||||
|
||||
# Produktions‐Login: User in DB suchen
|
||||
user = execute_query("SELECT username, pwd_hash, role FROM users WHERE username=%s", (username,), fetch_one=True)
|
||||
if user and check_password(password, user["pwd_hash"]):
|
||||
session["username"] = user["username"]
|
||||
session["role"] = user["role"]
|
||||
return dcc.Location(href="/overview", id="redirect-ok"), None
|
||||
else:
|
||||
return "Ungültige Zugangsdaten.", None
|
||||
@@ -1,139 +0,0 @@
|
||||
# dashboard/callbacks/overview_callbacks.py
|
||||
import sys
|
||||
sys.path.append('/workspace')
|
||||
import threading
|
||||
import dash
|
||||
import requests
|
||||
from dash import Input, Output, State, MATCH, html, dcc
|
||||
from flask import session
|
||||
from utils.db import get_session # Diese Funktion muss eine SQLAlchemy-Session liefern!
|
||||
from utils.mqtt_client import publish, start_loop
|
||||
from config import ENV
|
||||
import dash_bootstrap_components as dbc
|
||||
import os
|
||||
import time
|
||||
import pytz
|
||||
from datetime import datetime
|
||||
|
||||
print("overview_callbacks.py geladen")
|
||||
|
||||
API_BASE_URL = os.getenv("API_BASE_URL", "https://192.168.43.100")
|
||||
|
||||
mqtt_thread_started = False
|
||||
SCREENSHOT_DIR = "received-screenshots"
|
||||
|
||||
def ensure_mqtt_running():
|
||||
global mqtt_thread_started
|
||||
if not mqtt_thread_started:
|
||||
thread = threading.Thread(target=start_loop, daemon=True)
|
||||
thread.start()
|
||||
mqtt_thread_started = True
|
||||
|
||||
def get_latest_screenshot(client_uuid):
|
||||
cache_buster = int(time.time()) # aktuelle Unix-Zeit in Sekunden
|
||||
# TODO: Hier genau im Produkitv-Modus die IPs testen!
|
||||
# Wenn API_BASE_URL auf "http" beginnt, absolute URL verwenden (z.B. im lokalen Dev)
|
||||
if API_BASE_URL.startswith("http"):
|
||||
return f"{API_BASE_URL}/screenshots/{client_uuid}?t={cache_buster}"
|
||||
# Sonst relative URL (nginx-Proxy übernimmt das Routing)
|
||||
return f"/screenshots/{client_uuid}?t={cache_buster}"
|
||||
|
||||
def fetch_clients():
|
||||
try:
|
||||
verify_ssl = True if ENV == "production" else False
|
||||
resp = requests.get(
|
||||
f"{API_BASE_URL}/api/clients",
|
||||
verify=verify_ssl
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
print("Fehler beim Abrufen der Clients:", e)
|
||||
return []
|
||||
|
||||
@dash.callback(
|
||||
Output("clients-cards-container", "children"),
|
||||
Input("interval-update", "n_intervals")
|
||||
)
|
||||
def update_clients(n):
|
||||
# ... Session-Handling wie gehabt ...
|
||||
ensure_mqtt_running()
|
||||
clients = fetch_clients()
|
||||
cards = []
|
||||
for client in clients:
|
||||
uuid = client["uuid"]
|
||||
screenshot = get_latest_screenshot(uuid)
|
||||
last_alive_utc = client.get("last_alive")
|
||||
if last_alive_utc:
|
||||
try:
|
||||
# Unterstützt sowohl "2024-06-08T12:34:56Z" als auch "2024-06-08T12:34:56"
|
||||
if last_alive_utc.endswith("Z"):
|
||||
dt_utc = datetime.strptime(last_alive_utc, "%Y-%m-%dT%H:%M:%SZ")
|
||||
else:
|
||||
dt_utc = datetime.strptime(last_alive_utc, "%Y-%m-%dT%H:%M:%S")
|
||||
dt_utc = dt_utc.replace(tzinfo=pytz.UTC)
|
||||
# Lokale Zeitzone fest angeben, z.B. Europe/Berlin
|
||||
local_tz = pytz.timezone("Europe/Berlin")
|
||||
dt_local = dt_utc.astimezone(local_tz)
|
||||
last_alive_str = dt_local.strftime("%d.%m.%Y %H:%M:%S")
|
||||
except Exception:
|
||||
last_alive_str = last_alive_utc
|
||||
else:
|
||||
last_alive_str = "-"
|
||||
|
||||
card = dbc.Card(
|
||||
[
|
||||
dbc.CardHeader(client["location"]),
|
||||
dbc.CardBody([
|
||||
html.Img(
|
||||
src=screenshot,
|
||||
style={
|
||||
"width": "240px",
|
||||
"height": "135px",
|
||||
"object-fit": "cover",
|
||||
"display": "block",
|
||||
"margin-left": "auto",
|
||||
"margin-right": "auto"
|
||||
},
|
||||
),
|
||||
html.P(f"IP: {client['ip_address'] or '-'}", className="card-text"),
|
||||
html.P(f"Letzte Aktivität: {last_alive_str}", className="card-text"),
|
||||
dbc.ButtonGroup([
|
||||
dbc.Button("Reload Page", color="primary", id={"type": "btn-reload", "index": uuid}, n_clicks=0),
|
||||
dbc.Button("Restart Client", color="danger", id={"type": "btn-restart", "index": uuid}, n_clicks=0),
|
||||
], className="mt-2"),
|
||||
html.Div(id={"type": "restart-feedback", "index": uuid}),
|
||||
html.Div(id={"type": "reload-feedback", "index": uuid}),
|
||||
]),
|
||||
],
|
||||
className="mb-4",
|
||||
style={"width": "18rem"},
|
||||
)
|
||||
cards.append(dbc.Col(card, width=4))
|
||||
return dbc.Row(cards)
|
||||
|
||||
@dash.callback(
|
||||
Output({"type": "restart-feedback", "index": MATCH}, "children"),
|
||||
Input({"type": "btn-restart", "index": MATCH}, "n_clicks"),
|
||||
State({"type": "btn-restart", "index": MATCH}, "id")
|
||||
)
|
||||
def on_restart(n_clicks, btn_id):
|
||||
if n_clicks and n_clicks > 0:
|
||||
cid = btn_id["index"]
|
||||
payload = '{"command": "restart"}'
|
||||
ok = publish(f"clients/{cid}/control", payload)
|
||||
return "Befehl gesendet." if ok else "Fehler beim Senden."
|
||||
return ""
|
||||
|
||||
@dash.callback(
|
||||
Output({"type": "reload-feedback", "index": MATCH}, "children"),
|
||||
Input({"type": "btn-reload", "index": MATCH}, "n_clicks"),
|
||||
State({"type": "btn-reload", "index": MATCH}, "id")
|
||||
)
|
||||
def on_reload(n_clicks, btn_id):
|
||||
if n_clicks and n_clicks > 0:
|
||||
cid = btn_id["index"]
|
||||
payload = '{"command": "reload"}'
|
||||
ok = publish(f"clients/{cid}/control", payload)
|
||||
return "Befehl gesendet." if ok else "Fehler beim Senden."
|
||||
return ""
|
||||
@@ -1,20 +0,0 @@
|
||||
# dashboard/callbacks/settings_callbacks.py
|
||||
import dash
|
||||
from dash import Input, Output, State, dcc
|
||||
from flask import session
|
||||
from utils.db import execute_query, execute_non_query
|
||||
|
||||
@dash.callback(
|
||||
Output("settings-feedback", "children"),
|
||||
Input("btn-save-settings", "n_clicks"),
|
||||
State("input-default-volume", "value"),
|
||||
prevent_initial_call=True
|
||||
)
|
||||
def save_settings(n_clicks, volume):
|
||||
if "role" not in session:
|
||||
return dcc.Location(href="/login")
|
||||
if n_clicks and n_clicks > 0:
|
||||
sql = "UPDATE global_settings SET value=%s WHERE key='default_volume'"
|
||||
rc = execute_non_query(sql, (volume,))
|
||||
return "Einstellungen gespeichert." if rc else "Speichern fehlgeschlagen."
|
||||
return ""
|
||||
@@ -1,26 +0,0 @@
|
||||
# dashboard/callbacks/ui_callbacks.py
|
||||
|
||||
from dash import Input, Output, State, callback
|
||||
from components.sidebar import Sidebar
|
||||
|
||||
@callback(
|
||||
Output("sidebar", "children"),
|
||||
Output("sidebar", "className"),
|
||||
Input("sidebar-state", "data"),
|
||||
)
|
||||
def render_sidebar(data):
|
||||
collapsed = data.get("collapsed", False)
|
||||
return Sidebar(collapsed=collapsed), f"sidebar{' collapsed' if collapsed else ''}"
|
||||
|
||||
@callback(
|
||||
Output("sidebar-state", "data"),
|
||||
Input("btn-toggle-sidebar", "n_clicks"),
|
||||
State("sidebar-state", "data"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def toggle_sidebar(n, data):
|
||||
if n is None:
|
||||
# Kein Klick, nichts ändern!
|
||||
return data
|
||||
collapsed = not data.get("collapsed", False)
|
||||
return {"collapsed": collapsed}
|
||||
@@ -1,24 +0,0 @@
|
||||
# dashboard/callbacks/users_callbacks.py
|
||||
import dash
|
||||
from dash import Input, Output, State, dcc
|
||||
from flask import session
|
||||
from utils.db import execute_query, execute_non_query
|
||||
from utils.auth import hash_password
|
||||
|
||||
@dash.callback(
|
||||
Output("users-feedback", "children"),
|
||||
Input("btn-new-user", "n_clicks"),
|
||||
State("input-new-username", "value"),
|
||||
State("input-new-password", "value"),
|
||||
State("input-new-role", "value"),
|
||||
prevent_initial_call=True
|
||||
)
|
||||
def create_user(n_clicks, uname, pwd, role):
|
||||
if session.get("role") != "admin":
|
||||
return "Keine Berechtigung."
|
||||
if n_clicks and n_clicks > 0:
|
||||
pwd_hash = hash_password(pwd)
|
||||
sql = "INSERT INTO users (username, pwd_hash, role) VALUES (%s, %s, %s)"
|
||||
rc = execute_non_query(sql, (uname, pwd_hash, role))
|
||||
return "Benutzer erstellt." if rc else "Fehler beim Erstellen."
|
||||
return ""
|
||||
@@ -1,258 +0,0 @@
|
||||
from dash import html, dcc
|
||||
import dash_mantine_components as dmc
|
||||
from dash_iconify import DashIconify
|
||||
import dash_quill
|
||||
|
||||
def create_input_with_tooltip_full(component, tooltip_text):
|
||||
return dmc.Stack([
|
||||
dmc.Group([
|
||||
component,
|
||||
dmc.Tooltip(
|
||||
children=[
|
||||
dmc.ActionIcon(
|
||||
DashIconify(icon="mdi:help-circle-outline", width=16),
|
||||
variant="subtle",
|
||||
color="gray",
|
||||
size="sm"
|
||||
)
|
||||
],
|
||||
label=tooltip_text,
|
||||
position="top",
|
||||
multiline=True,
|
||||
w=300
|
||||
)
|
||||
], gap="xs", align="flex-end")
|
||||
], gap=0)
|
||||
|
||||
def create_input_with_tooltip_time(component, tooltip_text):
|
||||
return dmc.Group([
|
||||
component,
|
||||
dmc.Tooltip(
|
||||
children=[
|
||||
dmc.ActionIcon(
|
||||
DashIconify(icon="mdi:help-circle-outline", width=16),
|
||||
variant="subtle",
|
||||
color="gray",
|
||||
size="sm"
|
||||
)
|
||||
],
|
||||
label=tooltip_text,
|
||||
position="top",
|
||||
multiline=True,
|
||||
w=300
|
||||
)
|
||||
], gap="xs", align="flex-end")
|
||||
|
||||
def get_appointment_modal():
|
||||
weekday_options = [
|
||||
{"value": "0", "label": "Montag"},
|
||||
{"value": "1", "label": "Dienstag"},
|
||||
{"value": "2", "label": "Mittwoch"},
|
||||
{"value": "3", "label": "Donnerstag"},
|
||||
{"value": "4", "label": "Freitag"},
|
||||
{"value": "5", "label": "Samstag"},
|
||||
{"value": "6", "label": "Sonntag"}
|
||||
]
|
||||
time_options = [
|
||||
{"value": f"{h:02d}:{m:02d}", "label": f"{h:02d}:{m:02d}"}
|
||||
for h in range(6, 24) for m in [0, 30]
|
||||
]
|
||||
return dmc.Modal(
|
||||
id="appointment-modal",
|
||||
title="Neuen Termin anlegen",
|
||||
centered=True,
|
||||
size="auto", # oder "80vw"
|
||||
children=[
|
||||
dmc.Container([
|
||||
dmc.Grid([
|
||||
dmc.GridCol([
|
||||
dmc.Paper([
|
||||
dmc.Title("Termindetails", order=3, className="mb-3"),
|
||||
dmc.Stack([
|
||||
create_input_with_tooltip_full(
|
||||
dmc.TextInput(
|
||||
label="Titel",
|
||||
placeholder="Terminbezeichnung eingeben",
|
||||
leftSection=DashIconify(icon="mdi:calendar-text"),
|
||||
id="title-input",
|
||||
required=True,
|
||||
style={"flex": 1}
|
||||
),
|
||||
"Geben Sie eine aussagekräftige Bezeichnung für den Termin ein"
|
||||
),
|
||||
create_input_with_tooltip_full(
|
||||
dmc.DatePickerInput(
|
||||
label="Startdatum",
|
||||
id="start-date-input",
|
||||
firstDayOfWeek=1,
|
||||
weekendDays=[0, 6],
|
||||
valueFormat="DD.MM.YYYY",
|
||||
placeholder="Datum auswählen",
|
||||
leftSection=DashIconify(icon="mdi:calendar"),
|
||||
clearable=False,
|
||||
style={"flex": 1}
|
||||
),
|
||||
"Wählen Sie das Datum für den Termin aus dem Kalender"
|
||||
),
|
||||
dmc.Grid([
|
||||
dmc.GridCol([
|
||||
dmc.Stack([
|
||||
create_input_with_tooltip_time(
|
||||
dmc.Select(
|
||||
label="Startzeit",
|
||||
placeholder="Zeit auswählen",
|
||||
data=time_options,
|
||||
searchable=True,
|
||||
clearable=True,
|
||||
id="time-start",
|
||||
value="09:00",
|
||||
required=True,
|
||||
style={"flex": 1}
|
||||
),
|
||||
"Eingabe: 10:25, 10.25, 1025 oder 10 (wird automatisch formatiert)"
|
||||
),
|
||||
html.Div(id="start-time-feedback")
|
||||
], gap="xs")
|
||||
], span=6),
|
||||
dmc.GridCol([
|
||||
dmc.Stack([
|
||||
create_input_with_tooltip_time(
|
||||
dmc.Select(
|
||||
label="Endzeit",
|
||||
placeholder="Zeit auswählen",
|
||||
data=time_options,
|
||||
searchable=True,
|
||||
clearable=True,
|
||||
id="time-end",
|
||||
required=True,
|
||||
style={"flex": 1}
|
||||
),
|
||||
"Endzeit muss nach der Startzeit am selben Tag liegen. Termine können nicht über Mitternacht hinausgehen."
|
||||
),
|
||||
html.Div(id="end-time-feedback")
|
||||
], gap="xs")
|
||||
], span=6)
|
||||
]),
|
||||
create_input_with_tooltip_full(
|
||||
dmc.Select(
|
||||
label="Termintyp",
|
||||
placeholder="Typ auswählen",
|
||||
data=[
|
||||
{"value": "presentation", "label": "Präsentation"},
|
||||
{"value": "website", "label": "Website"},
|
||||
{"value": "video", "label": "Video"},
|
||||
{"value": "message", "label": "Nachricht"},
|
||||
{"value": "webuntis", "label": "WebUntis"},
|
||||
{"value": "other", "label": "Sonstiges"}
|
||||
],
|
||||
id="type-input",
|
||||
required=True,
|
||||
style={"flex": 1}
|
||||
),
|
||||
"Wählen Sie die Art der Präsentation aus."
|
||||
),
|
||||
html.Div(id="type-specific-fields"),
|
||||
create_input_with_tooltip_full(
|
||||
dmc.Textarea(
|
||||
label="Beschreibung",
|
||||
placeholder="Zusätzliche Informationen...",
|
||||
minRows=3,
|
||||
autosize=True,
|
||||
id="description-input",
|
||||
style={"flex": 1}
|
||||
),
|
||||
"Optionale Beschreibung mit weiteren Details zum Termin"
|
||||
)
|
||||
], gap="md")
|
||||
], p="md", shadow="sm")
|
||||
], span=6),
|
||||
dmc.GridCol([
|
||||
dmc.Paper([
|
||||
dmc.Title("Wiederholung", order=3, className="mb-3"),
|
||||
dmc.Stack([
|
||||
create_input_with_tooltip_full(
|
||||
dmc.Checkbox(
|
||||
label="Wiederholender Termin",
|
||||
id="repeat-checkbox",
|
||||
description="Aktivieren für wöchentliche Wiederholung"
|
||||
),
|
||||
"Aktivieren Sie diese Option, um den Termin an mehreren Wochentagen zu wiederholen"
|
||||
),
|
||||
html.Div(id="repeat-options", children=[
|
||||
create_input_with_tooltip_full(
|
||||
dmc.MultiSelect(
|
||||
label="Wochentage",
|
||||
placeholder="Wochentage auswählen",
|
||||
data=weekday_options,
|
||||
id="weekdays-select",
|
||||
description="An welchen Wochentagen soll der Termin stattfinden?",
|
||||
disabled=True,
|
||||
style={"flex": 1}
|
||||
),
|
||||
"Wählen Sie mindestens einen Wochentag für die Wiederholung aus"
|
||||
),
|
||||
dmc.Space(h="md"),
|
||||
create_input_with_tooltip_full(
|
||||
dmc.DatePickerInput(
|
||||
label="Wiederholung bis",
|
||||
id="repeat-until-date",
|
||||
firstDayOfWeek=1,
|
||||
weekendDays=[0, 6],
|
||||
valueFormat="DD.MM.YYYY",
|
||||
placeholder="Enddatum auswählen",
|
||||
leftSection=DashIconify(icon="mdi:calendar-end"),
|
||||
description="Letzter Tag der Wiederholung",
|
||||
disabled=True,
|
||||
clearable=True,
|
||||
style={"flex": 1}
|
||||
),
|
||||
"Datum bis wann die Wiederholung stattfinden soll. Muss nach dem Startdatum liegen."
|
||||
),
|
||||
dmc.Space(h="lg"),
|
||||
create_input_with_tooltip_full(
|
||||
dmc.Checkbox(
|
||||
label="Ferientage berücksichtigen",
|
||||
id="skip-holidays-checkbox",
|
||||
description="Termine an Feiertagen und in Schulferien auslassen",
|
||||
disabled=True
|
||||
),
|
||||
"Aktivieren Sie diese Option, um Termine an deutschen Feiertagen und in Schulferien automatisch zu überspringen"
|
||||
)
|
||||
])
|
||||
], gap="md")
|
||||
], p="md", shadow="sm"),
|
||||
dmc.Paper([
|
||||
dmc.Title("Aktionen", order=3, className="mb-3"),
|
||||
dmc.Stack([
|
||||
dmc.Button(
|
||||
"Termin(e) speichern",
|
||||
color="green",
|
||||
leftSection=DashIconify(icon="mdi:content-save"),
|
||||
id="btn-save",
|
||||
size="lg",
|
||||
fullWidth=True
|
||||
),
|
||||
dmc.Button(
|
||||
"Zurücksetzen",
|
||||
color="gray",
|
||||
variant="outline",
|
||||
leftSection=DashIconify(icon="mdi:refresh"),
|
||||
id="btn-reset",
|
||||
fullWidth=True
|
||||
),
|
||||
dmc.Button(
|
||||
"Schließen",
|
||||
id="close-appointment-modal-btn",
|
||||
color="red", # oder "danger"
|
||||
leftSection=DashIconify(icon="mdi:close"),
|
||||
variant="filled",
|
||||
style={"marginBottom": 10}
|
||||
),
|
||||
html.Div(id="save-feedback", className="mt-3")
|
||||
], gap="md")
|
||||
], p="md", shadow="sm", className="mt-3")
|
||||
], span=6)
|
||||
])
|
||||
], size="lg")
|
||||
]
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
# dashboard/components/header.py
|
||||
from dash import html
|
||||
|
||||
def Header():
|
||||
return html.Div(
|
||||
className="app-header",
|
||||
children=[
|
||||
html.Img(src="/assets/logo.png", className="logo"),
|
||||
html.Span("Infoscreen-Manager", className="app-title"),
|
||||
html.Span(" – Organisationsname", className="org-name"),
|
||||
html.Div(id="header-right", className="header-right") # Platzhalter für Login/Profil‐Button
|
||||
]
|
||||
)
|
||||
@@ -1,72 +0,0 @@
|
||||
# dashboard/components/sidebar.py
|
||||
|
||||
from dash import html
|
||||
import dash_bootstrap_components as dbc
|
||||
from dash_iconify import DashIconify
|
||||
|
||||
def Sidebar(collapsed: bool = False):
|
||||
"""
|
||||
Gibt nur den Inhalt der Sidebar zurück (ohne das äußere div mit id="sidebar").
|
||||
Das äußere div wird bereits in app.py definiert.
|
||||
"""
|
||||
|
||||
nav_items = [
|
||||
{"label": "Übersicht", "href": "/overview", "icon": "mdi:view-dashboard"},
|
||||
{"label": "Termine", "href": "/appointments","icon": "mdi:calendar"},
|
||||
{"label": "Bildschirme", "href": "/clients", "icon": "mdi:monitor"},
|
||||
{"label": "Einstellungen","href": "/settings", "icon": "mdi:cog"},
|
||||
{"label": "Benutzer", "href": "/users", "icon": "mdi:account"},
|
||||
]
|
||||
|
||||
if collapsed:
|
||||
nav_links = [
|
||||
dbc.NavLink(
|
||||
DashIconify(icon=item["icon"], width=24),
|
||||
href=item["href"],
|
||||
active="exact",
|
||||
className="sidebar-item-collapsed",
|
||||
id={"type": "nav-item", "index": item["label"]},
|
||||
)
|
||||
for item in nav_items
|
||||
]
|
||||
else:
|
||||
nav_links = [
|
||||
dbc.NavLink(
|
||||
[
|
||||
DashIconify(icon=item["icon"], width=24),
|
||||
html.Span(item["label"], className="ms-2 sidebar-label"),
|
||||
],
|
||||
href=item["href"],
|
||||
active="exact",
|
||||
className="sidebar-item",
|
||||
id={"type": "nav-item", "index": item["label"]},
|
||||
)
|
||||
for item in nav_items
|
||||
]
|
||||
|
||||
return [
|
||||
html.Div(
|
||||
className="sidebar-toggle",
|
||||
children=html.Button(
|
||||
DashIconify(icon="mdi:menu", width=28),
|
||||
id="btn-toggle-sidebar",
|
||||
className="toggle-button",
|
||||
)
|
||||
),
|
||||
dbc.Collapse(
|
||||
dbc.Nav(
|
||||
nav_links,
|
||||
vertical=True,
|
||||
pills=True,
|
||||
className="sidebar-nav",
|
||||
),
|
||||
is_open=not collapsed,
|
||||
className="sidebar-nav",
|
||||
) if not collapsed else
|
||||
dbc.Nav(
|
||||
nav_links,
|
||||
vertical=True,
|
||||
pills=True,
|
||||
className="sidebar-nav-collapsed",
|
||||
),
|
||||
]
|
||||
@@ -1,28 +0,0 @@
|
||||
# dashboard/config.py
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# .env aus Root‐Verzeichnis laden
|
||||
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
load_dotenv(os.path.join(base_dir, ".env"))
|
||||
|
||||
# DB‐Einstellungen
|
||||
DB_HOST = os.getenv("DB_HOST")
|
||||
DB_PORT = int(os.getenv("DB_PORT", "3306"))
|
||||
DB_USER = os.getenv("DB_USER")
|
||||
DB_PASSWORD = os.getenv("DB_PASSWORD")
|
||||
DB_NAME = os.getenv("DB_NAME")
|
||||
DB_POOL_NAME = os.getenv("DB_POOL_NAME", "my_pool")
|
||||
DB_POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "5"))
|
||||
|
||||
# MQTT‐Einstellungen
|
||||
MQTT_BROKER_HOST = os.getenv("MQTT_BROKER_HOST")
|
||||
MQTT_BROKER_PORT = int(os.getenv("MQTT_BROKER_PORT", "1883"))
|
||||
MQTT_USERNAME = os.getenv("MQTT_USERNAME")
|
||||
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD")
|
||||
MQTT_KEEPALIVE = int(os.getenv("MQTT_KEEPALIVE", "60"))
|
||||
MQTT_CLIENT_ID = os.getenv("MQTT_CLIENT_ID")
|
||||
|
||||
# Sonstige Einstellungen
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "changeme")
|
||||
ENV = os.getenv("ENV", "development")
|
||||
Binary file not shown.
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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -1,402 +0,0 @@
|
||||
import dash
|
||||
import full_calendar_component as fcc
|
||||
from dash import *
|
||||
import dash_mantine_components as dmc
|
||||
from dash.exceptions import PreventUpdate
|
||||
from datetime import datetime, date, timedelta
|
||||
import dash_quill
|
||||
|
||||
# dash._dash_renderer._set_react_version('18.2.0')
|
||||
|
||||
app = Dash(__name__, prevent_initial_callbacks=True)
|
||||
|
||||
quill_mods = [
|
||||
[{"header": "1"}, {"header": "2"}, {"font": []}],
|
||||
[{"size": []}],
|
||||
["bold", "italic", "underline", "strike", "blockquote"],
|
||||
[{"list": "ordered"}, {"list": "bullet"}, {"indent": "-1"}, {"indent": "+1"}],
|
||||
["link", "image"],
|
||||
]
|
||||
|
||||
# Get today's date
|
||||
today = datetime.now()
|
||||
|
||||
# Format the date
|
||||
formatted_date = today.strftime("%Y-%m-%d")
|
||||
|
||||
app.layout = html.Div(
|
||||
[
|
||||
fcc.FullCalendarComponent(
|
||||
id="calendar", # Unique ID for the component
|
||||
initialView="listWeek", # dayGridMonth, timeGridWeek, timeGridDay, listWeek,
|
||||
# dayGridWeek, dayGridYear, multiMonthYear, resourceTimeline, resourceTimeGridDay, resourceTimeLineWeek
|
||||
headerToolbar={
|
||||
"left": "prev,next today",
|
||||
"center": "",
|
||||
"right": "listWeek,timeGridDay,timeGridWeek,dayGridMonth",
|
||||
}, # Calendar header
|
||||
initialDate=f"{formatted_date}", # Start date for calendar
|
||||
editable=True, # Allow events to be edited
|
||||
selectable=True, # Allow dates to be selected
|
||||
events=[],
|
||||
nowIndicator=True, # Show current time indicator
|
||||
navLinks=True, # Allow navigation to other dates
|
||||
),
|
||||
dmc.MantineProvider(
|
||||
theme={"colorScheme": "dark"},
|
||||
children=[
|
||||
dmc.Modal(
|
||||
id="modal",
|
||||
size="xl",
|
||||
title="Event Details",
|
||||
zIndex=10000,
|
||||
children=[
|
||||
html.Div(id="modal_event_display_context"),
|
||||
dmc.Space(h=20),
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Button(
|
||||
"Close",
|
||||
color="red",
|
||||
variant="outline",
|
||||
id="modal-close-button",
|
||||
),
|
||||
],
|
||||
pos="right",
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
dmc.MantineProvider(
|
||||
theme={"colorScheme": "dark"},
|
||||
children=[
|
||||
dmc.Modal(
|
||||
id="add_modal",
|
||||
title="New Event",
|
||||
size="xl",
|
||||
children=[
|
||||
dmc.Grid(
|
||||
children=[
|
||||
dmc.GridCol(
|
||||
html.Div(
|
||||
dmc.DatePickerInput(
|
||||
id="start_date",
|
||||
label="Start Date",
|
||||
value=datetime.now().date(),
|
||||
styles={"width": "100%"},
|
||||
disabled=True,
|
||||
),
|
||||
style={"width": "100%"},
|
||||
),
|
||||
span=6,
|
||||
),
|
||||
dmc.GridCol(
|
||||
html.Div(
|
||||
dmc.TimeInput(
|
||||
label="Start Time",
|
||||
withSeconds=True,
|
||||
value=datetime.now(),
|
||||
# format="12",
|
||||
id="start_time",
|
||||
),
|
||||
style={"width": "100%"},
|
||||
),
|
||||
span=6,
|
||||
),
|
||||
],
|
||||
gutter="xl",
|
||||
),
|
||||
dmc.Grid(
|
||||
children=[
|
||||
dmc.GridCol(
|
||||
html.Div(
|
||||
dmc.DatePickerInput(
|
||||
id="end_date",
|
||||
label="End Date",
|
||||
value=datetime.now().date(),
|
||||
styles={"width": "100%"},
|
||||
),
|
||||
style={"width": "100%"},
|
||||
),
|
||||
span=6,
|
||||
),
|
||||
dmc.GridCol(
|
||||
html.Div(
|
||||
dmc.TimeInput(
|
||||
label="End Time",
|
||||
withSeconds=True,
|
||||
value=datetime.now(),
|
||||
# format="12",
|
||||
id="end_time",
|
||||
),
|
||||
style={"width": "100%"},
|
||||
),
|
||||
span=6,
|
||||
),
|
||||
],
|
||||
gutter="xl",
|
||||
),
|
||||
dmc.Grid(
|
||||
children=[
|
||||
dmc.GridCol(
|
||||
span=6,
|
||||
children=[
|
||||
dmc.TextInput(
|
||||
label="Event Title:",
|
||||
style={"width": "100%"},
|
||||
id="event_name_input",
|
||||
required=True,
|
||||
)
|
||||
],
|
||||
),
|
||||
dmc.GridCol(
|
||||
span=6,
|
||||
children=[
|
||||
dmc.Select(
|
||||
label="Select event color",
|
||||
placeholder="Select one",
|
||||
id="event_color_select",
|
||||
value="ng",
|
||||
data=[
|
||||
{
|
||||
"value": "bg-gradient-primary",
|
||||
"label": "bg-gradient-primary",
|
||||
},
|
||||
{
|
||||
"value": "bg-gradient-secondary",
|
||||
"label": "bg-gradient-secondary",
|
||||
},
|
||||
{
|
||||
"value": "bg-gradient-success",
|
||||
"label": "bg-gradient-success",
|
||||
},
|
||||
{
|
||||
"value": "bg-gradient-info",
|
||||
"label": "bg-gradient-info",
|
||||
},
|
||||
{
|
||||
"value": "bg-gradient-warning",
|
||||
"label": "bg-gradient-warning",
|
||||
},
|
||||
{
|
||||
"value": "bg-gradient-danger",
|
||||
"label": "bg-gradient-danger",
|
||||
},
|
||||
{
|
||||
"value": "bg-gradient-light",
|
||||
"label": "bg-gradient-light",
|
||||
},
|
||||
{
|
||||
"value": "bg-gradient-dark",
|
||||
"label": "bg-gradient-dark",
|
||||
},
|
||||
{
|
||||
"value": "bg-gradient-white",
|
||||
"label": "bg-gradient-white",
|
||||
},
|
||||
],
|
||||
style={"width": "100%", "marginBottom": 10},
|
||||
required=True,
|
||||
)
|
||||
],
|
||||
),
|
||||
]
|
||||
),
|
||||
dash_quill.Quill(
|
||||
id="rich_text_input",
|
||||
modules={
|
||||
"toolbar": quill_mods,
|
||||
"clipboard": {
|
||||
"matchVisual": False,
|
||||
},
|
||||
},
|
||||
),
|
||||
dmc.Accordion(
|
||||
children=[
|
||||
dmc.AccordionItem(
|
||||
[
|
||||
dmc.AccordionControl("Raw HTML"),
|
||||
dmc.AccordionPanel(
|
||||
html.Div(
|
||||
id="rich_text_output",
|
||||
style={
|
||||
"height": "300px",
|
||||
"overflowY": "scroll",
|
||||
},
|
||||
)
|
||||
),
|
||||
],
|
||||
value="raw_html",
|
||||
),
|
||||
],
|
||||
),
|
||||
dmc.Space(h=20),
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Button(
|
||||
"Submit",
|
||||
id="modal_submit_new_event_button",
|
||||
color="green",
|
||||
),
|
||||
dmc.Button(
|
||||
"Close",
|
||||
color="red",
|
||||
variant="outline",
|
||||
id="modal_close_new_event_button",
|
||||
),
|
||||
],
|
||||
pos="right",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@app.callback(
|
||||
Output("modal", "opened"),
|
||||
Output("modal", "title"),
|
||||
Output("modal_event_display_context", "children"),
|
||||
Input("modal-close-button", "n_clicks"),
|
||||
Input("calendar", "clickedEvent"),
|
||||
State("modal", "opened"),
|
||||
)
|
||||
def open_event_modal(n, clickedEvent, opened):
|
||||
ctx = callback_context
|
||||
|
||||
if not ctx.triggered:
|
||||
raise PreventUpdate
|
||||
else:
|
||||
button_id = ctx.triggered[0]["prop_id"].split(".")[0]
|
||||
|
||||
if button_id == "calendar" and clickedEvent is not None:
|
||||
event_title = clickedEvent["title"]
|
||||
event_context = clickedEvent["extendedProps"]["context"]
|
||||
return (
|
||||
True,
|
||||
event_title,
|
||||
html.Div(
|
||||
dash_quill.Quill(
|
||||
id="input3",
|
||||
value=f"{event_context}",
|
||||
modules={
|
||||
"toolbar": False,
|
||||
"clipboard": {
|
||||
"matchVisual": False,
|
||||
},
|
||||
},
|
||||
),
|
||||
style={"width": "100%", "overflowY": "auto"},
|
||||
),
|
||||
)
|
||||
elif button_id == "modal-close-button" and n is not None:
|
||||
return False, dash.no_update, dash.no_update
|
||||
|
||||
return opened, dash.no_update
|
||||
|
||||
|
||||
@app.callback(
|
||||
Output("add_modal", "opened"),
|
||||
Output("start_date", "value"),
|
||||
Output("end_date", "value"),
|
||||
Output("start_time", "value"),
|
||||
Output("end_time", "value"),
|
||||
Input("calendar", "dateClicked"),
|
||||
Input("modal_close_new_event_button", "n_clicks"),
|
||||
State("add_modal", "opened"),
|
||||
)
|
||||
def open_add_modal(dateClicked, close_clicks, opened):
|
||||
|
||||
ctx = callback_context
|
||||
|
||||
if not ctx.triggered:
|
||||
raise PreventUpdate
|
||||
else:
|
||||
button_id = ctx.triggered[0]["prop_id"].split(".")[0]
|
||||
|
||||
if button_id == "calendar" and dateClicked is not None:
|
||||
try:
|
||||
start_time = datetime.strptime(dateClicked, "%Y-%m-%dT%H:%M:%S%z").time()
|
||||
start_date_obj = datetime.strptime(dateClicked, "%Y-%m-%dT%H:%M:%S%z")
|
||||
start_date = start_date_obj.strftime("%Y-%m-%d")
|
||||
end_date = start_date_obj.strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
start_time = datetime.now().time()
|
||||
start_date_obj = datetime.strptime(dateClicked, "%Y-%m-%d")
|
||||
start_date = start_date_obj.strftime("%Y-%m-%d")
|
||||
end_date = start_date_obj.strftime("%Y-%m-%d")
|
||||
end_time = datetime.combine(date.today(), start_time) + timedelta(hours=1)
|
||||
start_time_str = start_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
end_time_str = end_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return True, start_date, end_date, start_time_str, end_time_str
|
||||
|
||||
elif button_id == "modal_close_new_event_button" and close_clicks is not None:
|
||||
return False, dash.no_update, dash.no_update, dash.no_update, dash.no_update
|
||||
|
||||
return opened, dash.no_update, dash.no_update, dash.no_update, dash.no_update
|
||||
|
||||
|
||||
@app.callback(
|
||||
Output("calendar", "events"),
|
||||
Output("add_modal", "opened", allow_duplicate=True),
|
||||
Output("event_name_input", "value"),
|
||||
Output("event_color_select", "value"),
|
||||
Output("rich_text_input", "value"),
|
||||
Input("modal_submit_new_event_button", "n_clicks"),
|
||||
State("start_date", "value"),
|
||||
State("start_time", "value"),
|
||||
State("end_date", "value"),
|
||||
State("end_time", "value"),
|
||||
State("event_name_input", "value"),
|
||||
State("event_color_select", "value"),
|
||||
State("rich_text_output", "children"),
|
||||
State("calendar", "events"),
|
||||
)
|
||||
def add_new_event(
|
||||
n,
|
||||
start_date,
|
||||
start_time,
|
||||
end_date,
|
||||
end_time,
|
||||
event_name,
|
||||
event_color,
|
||||
event_context,
|
||||
current_events,
|
||||
):
|
||||
if n is None:
|
||||
raise PreventUpdate
|
||||
|
||||
start_time_obj = datetime.strptime(start_time, "%Y-%m-%d %H:%M:%S")
|
||||
end_time_obj = datetime.strptime(end_time, "%Y-%m-%d %H:%M:%S")
|
||||
|
||||
start_time_str = start_time_obj.strftime("%H:%M:%S")
|
||||
end_time_str = end_time_obj.strftime("%H:%M:%S")
|
||||
|
||||
start_date = f"{start_date}T{start_time_str}"
|
||||
end_date = f"{end_date}T{end_time_str}"
|
||||
|
||||
new_event = {
|
||||
"title": event_name,
|
||||
"start": start_date,
|
||||
"end": end_date,
|
||||
"className": event_color,
|
||||
"context": event_context,
|
||||
}
|
||||
|
||||
return current_events + [new_event], False, "", "bg-gradient-primary", ""
|
||||
|
||||
|
||||
@app.callback(
|
||||
Output("rich_text_output", "children"),
|
||||
[Input("rich_text_input", "value")],
|
||||
[State("rich_text_input", "charCount")],
|
||||
)
|
||||
def display_output(value, charCount):
|
||||
return value
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host='0.0.0.0', debug=True, port=8050)
|
||||
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>
|
||||
@@ -1,83 +0,0 @@
|
||||
"""
|
||||
Collapsible navbar on both desktop and mobile
|
||||
"""
|
||||
|
||||
|
||||
import dash_mantine_components as dmc
|
||||
from dash import Dash, Input, Output, State, callback
|
||||
from dash_iconify import DashIconify
|
||||
|
||||
app = Dash(external_stylesheets=dmc.styles.ALL)
|
||||
|
||||
logo = "https://github.com/user-attachments/assets/c1ff143b-4365-4fd1-880f-3e97aab5c302"
|
||||
|
||||
def get_icon(icon):
|
||||
return DashIconify(icon=icon, height=16)
|
||||
|
||||
layout = dmc.AppShell(
|
||||
[
|
||||
dmc.AppShellHeader(
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Burger(
|
||||
id="mobile-burger",
|
||||
size="sm",
|
||||
hiddenFrom="sm",
|
||||
opened=False,
|
||||
),
|
||||
dmc.Burger(
|
||||
id="desktop-burger",
|
||||
size="sm",
|
||||
visibleFrom="sm",
|
||||
opened=True,
|
||||
),
|
||||
dmc.Image(src=logo, h=40),
|
||||
dmc.Title("Demo App", c="blue"),
|
||||
],
|
||||
h="100%",
|
||||
px="md",
|
||||
)
|
||||
),
|
||||
dmc.AppShellNavbar(
|
||||
id="navbar",
|
||||
children=[
|
||||
"Navbar",
|
||||
dmc.NavLink(
|
||||
label="With icon",
|
||||
leftSection=get_icon(icon="bi:house-door-fill"),
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
),
|
||||
dmc.AppShellMain("Main"),
|
||||
],
|
||||
header={"height": 60},
|
||||
navbar={
|
||||
"width": 300,
|
||||
"breakpoint": "sm",
|
||||
"collapsed": {"mobile": True, "desktop": False},
|
||||
},
|
||||
padding="md",
|
||||
id="appshell",
|
||||
)
|
||||
|
||||
app.layout = dmc.MantineProvider(layout)
|
||||
|
||||
|
||||
@callback(
|
||||
Output("appshell", "navbar"),
|
||||
Input("mobile-burger", "opened"),
|
||||
Input("desktop-burger", "opened"),
|
||||
State("appshell", "navbar"),
|
||||
)
|
||||
def toggle_navbar(mobile_opened, desktop_opened, navbar):
|
||||
navbar["collapsed"] = {
|
||||
"mobile": not mobile_opened,
|
||||
"desktop": not desktop_opened,
|
||||
}
|
||||
return navbar
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host='0.0.0.0', debug=True, port=8050)
|
||||
7968
dashboard/package-lock.json
generated
Normal file
7968
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
dashboard/package.json
Normal file
49
dashboard/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"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-react-buttons": "^29.2.5",
|
||||
"@syncfusion/ej2-react-calendars": "^29.2.11",
|
||||
"@syncfusion/ej2-react-grids": "^29.2.11",
|
||||
"@syncfusion/ej2-react-schedule": "^29.2.10",
|
||||
"cldr-data": "^36.0.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"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",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
# dashboard/pages/appointments.py
|
||||
from dash import html, dcc
|
||||
import dash
|
||||
from dash_using_fullcalendar import DashUsingFullcalendar
|
||||
import dash_bootstrap_components as dbc
|
||||
from dashboard.components.appointment_modal import get_appointment_modal
|
||||
|
||||
dash.register_page(__name__, path="/appointments", name="Termine")
|
||||
|
||||
layout = dbc.Container([
|
||||
dbc.Row([
|
||||
dbc.Col(html.H2("Dash FullCalendar"))
|
||||
]),
|
||||
# Button zum Öffnen der Modalbox
|
||||
dbc.Row([
|
||||
dbc.Col(
|
||||
dbc.Button(
|
||||
"Neuen Termin anlegen",
|
||||
id="open-appointment-modal-btn",
|
||||
color="primary",
|
||||
className="mb-3"
|
||||
)
|
||||
)
|
||||
]),
|
||||
dbc.Row([
|
||||
dbc.Col(
|
||||
DashUsingFullcalendar(
|
||||
id='calendar',
|
||||
events=[],
|
||||
initialView="timeGridWeek",
|
||||
headerToolbar={
|
||||
"left": "prev,next today",
|
||||
"center": "title",
|
||||
# "right": "dayGridMonth,timeGridWeek,timeGridDay"
|
||||
},
|
||||
height=600,
|
||||
locale="de",
|
||||
slotDuration="00:30:00",
|
||||
slotMinTime="00:00:00",
|
||||
slotMaxTime="24:00:00",
|
||||
scrollTime="07:00:00",
|
||||
weekends=True,
|
||||
allDaySlot=False,
|
||||
firstDay=1,
|
||||
# themeSystem kann auf "bootstrap5" gesetzt werden, wenn das Plugin eingebunden ist
|
||||
# themeSystem="bootstrap5"
|
||||
)
|
||||
)
|
||||
]),
|
||||
dbc.Row([
|
||||
dbc.Col(html.Div(id='output'))
|
||||
]),
|
||||
dbc.Row([
|
||||
dbc.Col(html.Div(id='event-output'))
|
||||
]),
|
||||
dbc.Row([
|
||||
dbc.Col(html.Div(id='select-output'))
|
||||
]),
|
||||
dbc.Row([
|
||||
dbc.Col(html.Div(id='modal-output', children=get_appointment_modal()))
|
||||
])
|
||||
], fluid=True)
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# dashboard/pages/clients.py
|
||||
from dash import html, dcc
|
||||
import dash
|
||||
|
||||
dash.register_page(__name__, path="/clients", name="Bildschirme")
|
||||
|
||||
layout = html.Div(
|
||||
className="clients-page",
|
||||
children=[
|
||||
html.H3("Bildschirme"),
|
||||
]
|
||||
)
|
||||
@@ -1,16 +0,0 @@
|
||||
# dashboard/pages/login.py
|
||||
from dash import html, dcc
|
||||
import dash
|
||||
|
||||
dash.register_page(__name__, path="/login", name="Login")
|
||||
|
||||
layout = html.Div(
|
||||
className="login-page",
|
||||
children=[
|
||||
html.H2("Bitte einloggen"),
|
||||
dcc.Input(id="input-user", type="text", placeholder="Benutzername"),
|
||||
dcc.Input(id="input-pass", type="password", placeholder="Passwort"),
|
||||
html.Button("Einloggen", id="btn-login"),
|
||||
html.Div(id="login-feedback", className="text-danger")
|
||||
]
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
# dashboard/pages/overview.py
|
||||
from dash import html, dcc
|
||||
import dash
|
||||
|
||||
dash.register_page(__name__, path="/overview", name="Übersicht")
|
||||
|
||||
layout = html.Div(
|
||||
className="overview-page",
|
||||
children=[
|
||||
dcc.Interval(id="interval-update", interval=10_000, n_intervals=0),
|
||||
html.Div(id="clients-cards-container")
|
||||
]
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
# dashboard/pages/settings.py
|
||||
from dash import html
|
||||
import dash
|
||||
|
||||
dash.register_page(__name__, path="/settings", name="Einstellungen")
|
||||
|
||||
layout = html.Div(
|
||||
className="settings-page",
|
||||
children=[
|
||||
html.H3("Allgemeine Einstellungen"),
|
||||
# Formularfelder / Tabs für globale Optionen
|
||||
]
|
||||
)
|
||||
@@ -1,15 +0,0 @@
|
||||
# dashboard/pages/users.py
|
||||
from dash import html, dash_table, dcc
|
||||
import dash
|
||||
|
||||
dash.register_page(__name__, path="/users", name="Benutzer")
|
||||
|
||||
layout = html.Div(
|
||||
className="users-page",
|
||||
children=[
|
||||
html.H3("Benutzerverwaltung"),
|
||||
html.Button("Neuen Benutzer anlegen", id="btn-new-user"),
|
||||
html.Div(id="users-table-container"),
|
||||
html.Div(id="users-feedback")
|
||||
]
|
||||
)
|
||||
2310
dashboard/pnpm-lock.yaml
generated
Normal file
2310
dashboard/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
dashboard/postcss.config.cjs
Normal file
6
dashboard/postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
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 |
@@ -1 +0,0 @@
|
||||
debugpy
|
||||
@@ -1,13 +0,0 @@
|
||||
bcrypt>=4.3.0
|
||||
dash>=3.0.4
|
||||
dash-bootstrap-components>=2.0.3
|
||||
dash_iconify>=0.1.2
|
||||
dash_mantine_components>=1.2.0
|
||||
dash-quill>=0.0.4
|
||||
full-calendar-component>=0.0.4
|
||||
pandas>=2.2.3
|
||||
paho-mqtt>=2.1.0
|
||||
python-dotenv>=1.1.0
|
||||
PyMySQL>=1.1.1
|
||||
SQLAlchemy>=2.0.41
|
||||
./dash_using_fullcalendar-0.1.0.tar.gz
|
||||
11
dashboard/src/App.css
Normal file
11
dashboard/src/App.css
Normal file
@@ -0,0 +1,11 @@
|
||||
@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";
|
||||
|
||||
113
dashboard/src/App.tsx
Normal file
113
dashboard/src/App.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
// import 'react-app-polyfill/ie11'; // optional, falls benötigt
|
||||
import './App.css';
|
||||
import React from 'react';
|
||||
import {
|
||||
ScheduleComponent,
|
||||
Day,
|
||||
Week,
|
||||
WorkWeek,
|
||||
Month,
|
||||
Agenda,
|
||||
TimelineViews,
|
||||
TimelineMonth,
|
||||
Inject,
|
||||
ViewsDirective,
|
||||
ViewDirective,
|
||||
ResourcesDirective,
|
||||
ResourceDirective,
|
||||
} from '@syncfusion/ej2-react-schedule';
|
||||
import { L10n, loadCldr, setCulture } from '@syncfusion/ej2-base';
|
||||
import * as de from 'cldr-data/main/de/ca-gregorian.json';
|
||||
import * as numbers from 'cldr-data/main/de/numbers.json';
|
||||
import * as timeZoneNames from 'cldr-data/main/de/timeZoneNames.json';
|
||||
import * as numberingSystems from 'cldr-data/supplemental/numberingSystems.json';
|
||||
|
||||
// CLDR-Daten laden
|
||||
loadCldr(
|
||||
(de as unknown as { default: object }).default,
|
||||
(numbers as unknown as { default: object }).default,
|
||||
(timeZoneNames as unknown as { default: object }).default,
|
||||
(numberingSystems as unknown as { default: object }).default
|
||||
);
|
||||
|
||||
// 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Kultur setzen
|
||||
setCulture('de');
|
||||
|
||||
// Ressourcen-Daten
|
||||
const resources = [
|
||||
{ text: 'Raum A', id: 1, color: '#1aaa55' },
|
||||
{ text: 'Raum B', id: 2, color: '#357cd2' },
|
||||
{ text: 'Raum C', id: 3, color: '#7fa900' },
|
||||
];
|
||||
|
||||
// Dummy-Termine generieren
|
||||
const now = new Date();
|
||||
const appointments = Array.from({ length: 10 }).map((_, i) => {
|
||||
const dayOffset = Math.floor(i * 1.4); // verteilt auf 14 Tage
|
||||
const start = new Date(now);
|
||||
start.setDate(now.getDate() + dayOffset);
|
||||
start.setHours(9 + (i % 4), 0, 0, 0);
|
||||
const end = new Date(start);
|
||||
end.setHours(start.getHours() + 1);
|
||||
|
||||
return {
|
||||
Id: i + 1,
|
||||
Subject: `Termin ${i + 1}`,
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
ResourceId: (i % 3) + 1,
|
||||
Location: resources[i % 3].text,
|
||||
};
|
||||
});
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<div className="p-8 bg-gray-100 min-h-screen">
|
||||
<h1 className="text-2xl font-bold mb-4">Infoscreen Kalendersteuerung</h1>
|
||||
<ScheduleComponent
|
||||
height="650px"
|
||||
locale="de"
|
||||
currentView="TimelineWeek"
|
||||
eventSettings={{ dataSource: appointments }}
|
||||
group={{ resources: ['Räume'] }}
|
||||
>
|
||||
<ViewsDirective>
|
||||
<ViewDirective option="Week" />
|
||||
<ViewDirective option="TimelineWeek" />
|
||||
<ViewDirective option="Month" />
|
||||
<ViewDirective option="TimelineMonth" />
|
||||
</ViewsDirective>
|
||||
<ResourcesDirective>
|
||||
<ResourceDirective
|
||||
field="ResourceId"
|
||||
title="Räume"
|
||||
name="Räume"
|
||||
allowMultiple={false}
|
||||
dataSource={resources}
|
||||
textField="text"
|
||||
idField="id"
|
||||
colorField="color"
|
||||
/>
|
||||
</ResourcesDirective>
|
||||
<Inject services={[Day, Week, WorkWeek, Month, Agenda, TimelineViews, TimelineMonth]} />
|
||||
</ScheduleComponent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
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 |
75
dashboard/src/index.css
Normal file
75
dashboard/src/index.css
Normal file
@@ -0,0 +1,75 @@
|
||||
@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;
|
||||
}
|
||||
} */
|
||||
14
dashboard/src/main.tsx
Normal file
14
dashboard/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
import { registerLicense } from '@syncfusion/ej2-base';
|
||||
|
||||
// Setze hier deinen Lizenzschlüssel ein
|
||||
registerLicense('Ngo9BigBOggjHTQxAR8/V1NNaF1cWWhPYVFxWmFZfVtgd19FaFZRQ2Y/P1ZhSXxWdkNhWX5bc3xVQWZUUkF9XUs=');
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
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"]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
# dashboard/utils/auth.py
|
||||
import bcrypt
|
||||
|
||||
def hash_password(plain_text: str) -> str:
|
||||
return bcrypt.hashpw(plain_text.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
def check_password(plain_text: str, hashed: str) -> bool:
|
||||
return bcrypt.checkpw(plain_text.encode("utf-8"), hashed.encode("utf-8"))
|
||||
|
||||
def get_user_role(username: str) -> str:
|
||||
# Beispiel: aus der Datenbank auslesen (oder Hardcode während Dev-Phase)
|
||||
pass
|
||||
@@ -1,46 +0,0 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# .env laden
|
||||
load_dotenv()
|
||||
|
||||
# Datenbank-Zugangsdaten aus .env
|
||||
DB_USER = os.getenv("DB_USER")
|
||||
DB_PASSWORD = os.getenv("DB_PASSWORD")
|
||||
DB_HOST = os.getenv("DB_HOST", "localhost")
|
||||
DB_PORT = os.getenv("DB_PORT", "3306")
|
||||
DB_NAME = os.getenv("DB_NAME")
|
||||
|
||||
# Pooling Parameter aus .env (optional mit Default-Werten)
|
||||
POOL_SIZE = int(os.getenv("POOL_SIZE", 10))
|
||||
MAX_OVERFLOW = int(os.getenv("MAX_OVERFLOW", 20))
|
||||
POOL_TIMEOUT = int(os.getenv("POOL_TIMEOUT", 30))
|
||||
POOL_RECYCLE = int(os.getenv("POOL_RECYCLE", 1800))
|
||||
|
||||
# Connection-String zusammenbauen
|
||||
DATABASE_URL = (
|
||||
f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
||||
)
|
||||
|
||||
# Engine mit Pooling konfigurieren
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
pool_size=POOL_SIZE,
|
||||
max_overflow=MAX_OVERFLOW,
|
||||
pool_timeout=POOL_TIMEOUT,
|
||||
pool_recycle=POOL_RECYCLE,
|
||||
echo=True, # für Debug, später False
|
||||
)
|
||||
|
||||
# Session Factory
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
def get_session():
|
||||
return SessionLocal()
|
||||
|
||||
def execute_query(query):
|
||||
with engine.connect() as connection:
|
||||
result = connection.execute(text(query))
|
||||
return [dict(row) for row in result]
|
||||
@@ -1,124 +0,0 @@
|
||||
# dashboard/utils/mqtt_client.py
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from dotenv import load_dotenv
|
||||
import paho.mqtt.client as mqtt
|
||||
import random
|
||||
|
||||
# 1. Laden der Umgebungsvariablen aus .env
|
||||
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), "..", ".env"))
|
||||
|
||||
# 2. Lese MQTT‐Einstellungen
|
||||
MQTT_BROKER_HOST = os.getenv("MQTT_BROKER_HOST", "localhost")
|
||||
MQTT_BROKER_PORT = int(os.getenv("MQTT_BROKER_PORT", "1883"))
|
||||
MQTT_USERNAME = os.getenv("MQTT_USERNAME", None)
|
||||
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD", None)
|
||||
MQTT_KEEPALIVE = int(os.getenv("MQTT_KEEPALIVE", "60"))
|
||||
base_id = os.getenv("MQTT_CLIENT_ID", "dash")
|
||||
unique_part = f"{os.getpid()}_{random.randint(1000,9999)}"
|
||||
MQTT_CLIENT_ID = f"{base_id}-{unique_part}"
|
||||
|
||||
# 3. Erstelle eine globale Client‐Instanz
|
||||
client = mqtt.Client(client_id=MQTT_CLIENT_ID)
|
||||
|
||||
# Falls Nutzer/Passwort gesetzt sind, authentifizieren
|
||||
if MQTT_USERNAME and MQTT_PASSWORD:
|
||||
client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
|
||||
|
||||
|
||||
# 4. Callback‐Stubs (kannst du bei Bedarf anpassen)
|
||||
def _on_connect(client, userdata, flags, rc):
|
||||
if rc == 0:
|
||||
print(f"[mqtt_client.py] Erfolgreich mit MQTT‐Broker verbunden (Code {rc})")
|
||||
else:
|
||||
print(f"[mqtt_client.py] Verbindungsfehler, rc={rc}")
|
||||
|
||||
|
||||
def _on_disconnect(client, userdata, rc):
|
||||
print(f"[mqtt_client.py] Verbindung getrennt (rc={rc}). Versuche, neu zu verbinden …")
|
||||
|
||||
|
||||
def _on_message(client, userdata, msg):
|
||||
"""
|
||||
Diese Callback‐Funktion wird aufgerufen, sobald eine Nachricht auf einem
|
||||
Topic ankommt, auf das wir subscribed haben. Du kannst hier eine Queue
|
||||
füllen oder direkt eine Datenbank‐Funktion aufrufen.
|
||||
"""
|
||||
topic = msg.topic
|
||||
payload = msg.payload.decode("utf-8", errors="ignore")
|
||||
print(f"[mqtt_client.py] Nachricht eingegangen – Topic: {topic}, Payload: {payload}")
|
||||
# Beispiel: Wenn du Live‐Statusdaten in die Datenbank schreibst,
|
||||
# könntest du hier utils/db.execute_non_query(...) aufrufen.
|
||||
|
||||
|
||||
# 5. Setze die Callbacks
|
||||
client.on_connect = _on_connect
|
||||
client.on_disconnect = _on_disconnect
|
||||
client.on_message = _on_message
|
||||
|
||||
|
||||
def start_loop():
|
||||
"""
|
||||
Startet die Endlos‐Schleife, in der der Client auf eingehende
|
||||
MQTT‐Nachrichten hört und automatisch reconnectet.
|
||||
Muss idealerweise in einem eigenen Thread laufen, damit Dash‐Callbacks
|
||||
nicht blockieren.
|
||||
"""
|
||||
try:
|
||||
client.connect(MQTT_BROKER_HOST, MQTT_BROKER_PORT, keepalive=MQTT_KEEPALIVE)
|
||||
client.loop_start()
|
||||
except Exception as e:
|
||||
print(f"[mqtt_client.py] Konnte keine Verbindung zum MQTT‐Broker herstellen: {e}")
|
||||
|
||||
|
||||
def stop_loop():
|
||||
"""
|
||||
Stoppt die MQTT‐Loop und trennt die Verbindung.
|
||||
"""
|
||||
try:
|
||||
client.loop_stop()
|
||||
client.disconnect()
|
||||
except Exception as e:
|
||||
print(f"[mqtt_client.py] Fehler beim Stoppen der MQTT‐Schleife: {e}")
|
||||
|
||||
|
||||
def publish(topic: str, payload: str, qos: int = 0, retain: bool = False) -> bool:
|
||||
"""
|
||||
Verschickt eine MQTT‐Nachricht:
|
||||
- topic: z. B. "clients/{client_id}/control"
|
||||
- payload: z. B. '{"command":"restart"}'
|
||||
- qos: 0, 1 oder 2
|
||||
- retain: True/False
|
||||
Rückgabe: True, falls Veröffentlichung bestätigt wurde; sonst False.
|
||||
"""
|
||||
try:
|
||||
result = client.publish(topic, payload, qos=qos, retain=retain)
|
||||
status = result.rc # 0=Erfolg, sonst Fehler
|
||||
if status == mqtt.MQTT_ERR_SUCCESS:
|
||||
return True
|
||||
else:
|
||||
print(f"[mqtt_client.py] Publish-Fehler für Topic {topic}, rc={status}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"[mqtt_client.py] Exception beim Publish: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def subscribe(topic: str, qos: int = 0) -> bool:
|
||||
"""
|
||||
Abonniert ein MQTT‐Topic, sodass _on_message gerufen wird, sobald Nachrichten
|
||||
ankommen.
|
||||
Rückgabe: True bei Erfolg, ansonsten False.
|
||||
"""
|
||||
try:
|
||||
result, mid = client.subscribe(topic, qos=qos)
|
||||
if result == mqtt.MQTT_ERR_SUCCESS:
|
||||
return True
|
||||
else:
|
||||
print(f"[mqtt_client.py] Subscribe‐Fehler für Topic {topic}, rc={result}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"[mqtt_client.py] Exception beim Subscribe: {e}")
|
||||
return False
|
||||
7
dashboard/vite.config.ts
Normal file
7
dashboard/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user