initial feature/react-migration commit

This commit is contained in:
2025-06-22 20:57:21 +00:00
parent 6653f3cf72
commit 76f6baf533
66 changed files with 12038 additions and 91 deletions

6
.stylelintrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"extends": [
"stylelint-config-standard",
"stylelint-config-tailwindcss"
]
}

View File

@@ -0,0 +1,38 @@
# dashboard/Dockerfile
# Produktions-Dockerfile für die Dash-Applikation
# --- Basis-Image ---
FROM python:3.13-slim
# --- Arbeitsverzeichnis im Container ---
WORKDIR /app
# --- Systemabhängigkeiten installieren (falls benötigt) ---
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential git \
&& rm -rf /var/lib/apt/lists/*
# --- Python-Abhängigkeiten kopieren und installieren ---
COPY dash_using_fullcalendar-0.1.0.tar.gz ./
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# --- Applikationscode kopieren ---
COPY dashboard/ /app
# --- Non-Root-User anlegen und Rechte setzen ---
ARG USER_ID=1000
ARG GROUP_ID=1000
RUN groupadd -g ${GROUP_ID} infoscreen_taa \
&& useradd -u ${USER_ID} -g ${GROUP_ID} \
--shell /bin/bash --create-home infoscreen_taa \
&& chown -R infoscreen_taa:infoscreen_taa /app
USER infoscreen_taa
# --- Port für Dash exposed ---
EXPOSE 8050
# --- Startbefehl: Gunicorn mit Dash-Server ---
# "app.py" enthält: app = dash.Dash(...); server = app.server
CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:8050", "app:server"]

View File

@@ -0,0 +1,51 @@
# dashboard/Dockerfile.dev
# Entwicklungs-Dockerfile für das Dash-Dashboard
FROM python:3.13-slim
# Build args für UID/GID
ARG USER_ID=1000
ARG GROUP_ID=1000
# Systemabhängigkeiten (falls nötig)
RUN apt-get update \
&& apt-get install -y --no-install-recommends locales curl \
&& sed -i 's/# de_DE.UTF-8 UTF-8/de_DE.UTF-8 UTF-8/' /etc/locale.gen \
&& locale-gen \
&& rm -rf /var/lib/apt/lists/*
# Locale setzen
ENV LANG=de_DE.UTF-8 \
LANGUAGE=de_DE:de \
LC_ALL=de_DE.UTF-8
# Non-root User anlegen
RUN groupadd -g ${GROUP_ID} infoscreen_taa \
&& useradd -u ${USER_ID} -g ${GROUP_ID} --shell /bin/bash --create-home infoscreen_taa
# Arbeitsverzeichnis
WORKDIR /app
# Kopiere nur Requirements für schnellen Rebuild
COPY dash_using_fullcalendar-0.1.0.tar.gz ./
COPY requirements.txt ./
COPY requirements-dev.txt ./
# Installiere Abhängigkeiten
RUN pip install --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt \
&& pip install --no-cache-dir -r requirements-dev.txt
# Setze Entwicklungs-Modus
ENV DASH_DEBUG_MODE=True
ENV API_URL=http://server:8000/api
# Ports für Dash und optional Live-Reload
EXPOSE 8050
EXPOSE 5678
# Wechsle zum non-root User
USER infoscreen_taa
# Dev-Start: Dash mit Hot-Reload
CMD ["python", "-m", "debugpy", "--listen", "0.0.0.0:5637", "app.py"]

View File

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 225 KiB

View File

@@ -0,0 +1,892 @@
import dash
from dash import html, Input, Output, State, callback, dcc
import dash_mantine_components as dmc
from dash_iconify import DashIconify
from datetime import datetime, date, timedelta
import re
import base64
import dash_quill
app = dash.Dash(__name__, suppress_callback_exceptions=True) # Wichtig für dynamische IDs
# Deutsche Lokalisierung für Mantine
german_dates_provider_props = {
"settings": {
"locale": "de",
"firstDayOfWeek": 1,
"weekendDays": [0, 6],
"labels": {
"ok": "OK",
"cancel": "Abbrechen",
"clear": "Löschen",
"monthPickerControl": "Monat auswählen",
"yearPickerControl": "Jahr auswählen",
"nextMonth": "Nächster Monat",
"previousMonth": "Vorheriger Monat",
"nextYear": "Nächstes Jahr",
"previousYear": "Vorheriges Jahr",
"nextDecade": "Nächstes Jahrzehnt",
"previousDecade": "Vorheriges Jahrzehnt"
}
}
}
# Wochentage für Wiederholung
weekday_options = [
{"value": "0", "label": "Montag"},
{"value": "1", "label": "Dienstag"},
{"value": "2", "label": "Mittwoch"},
{"value": "3", "label": "Donnerstag"},
{"value": "4", "label": "Freitag"},
{"value": "5", "label": "Samstag"},
{"value": "6", "label": "Sonntag"}
]
# Deutsche Feiertage (vereinfacht, ohne Berechnung von Ostern etc.)
GERMAN_HOLIDAYS_2025 = [
date(2025, 1, 1), # Neujahr
date(2025, 1, 6), # Heilige Drei Könige
date(2025, 4, 18), # Karfreitag (Beispiel - muss berechnet werden)
date(2025, 4, 21), # Ostermontag (Beispiel - muss berechnet werden)
date(2025, 5, 1), # Tag der Arbeit
date(2025, 5, 29), # Christi Himmelfahrt (Beispiel - muss berechnet werden)
date(2025, 6, 9), # Pfingstmontag (Beispiel - muss berechnet werden)
date(2025, 10, 3), # Tag der Deutschen Einheit
date(2025, 12, 25), # 1. Weihnachtstag
date(2025, 12, 26), # 2. Weihnachtstag
]
# Schulferien (Beispiel für NRW 2025)
SCHOOL_HOLIDAYS_2025 = [
# Weihnachtsferien
(date(2024, 12, 23), date(2025, 1, 6)),
# Osterferien
(date(2025, 4, 14), date(2025, 4, 26)),
# Sommerferien
(date(2025, 7, 14), date(2025, 8, 26)),
# Herbstferien
(date(2025, 10, 14), date(2025, 10, 25)),
]
def is_holiday_or_vacation(check_date):
"""Prüft, ob ein Datum ein Feiertag oder in den Ferien liegt"""
# Feiertage prüfen
if check_date in GERMAN_HOLIDAYS_2025:
return True
# Schulferien prüfen
for start_vacation, end_vacation in SCHOOL_HOLIDAYS_2025:
if start_vacation <= check_date <= end_vacation:
return True
return False
# Zeitraster für 30-Minuten-Intervalle generieren
def generate_time_options():
options = []
# Basis: 30-Minuten-Raster
for h in range(6, 24): # Bis 23:30
for m in [0, 30]:
options.append({"value": f"{h:02d}:{m:02d}", "label": f"{h:02d}:{m:02d}"})
return options
time_options = generate_time_options()
# Hilfsfunktion für Input-Felder mit Tooltip - volle Breite
def create_input_with_tooltip_full(component, tooltip_text):
"""Erstellt ein Input-Feld mit Tooltip-Icon über die volle Breite"""
return dmc.Stack([
dmc.Group([
component,
dmc.Tooltip(
children=[
dmc.ActionIcon(
DashIconify(icon="mdi:help-circle-outline", width=16),
variant="subtle",
color="gray",
size="sm"
)
],
label=tooltip_text,
position="top",
multiline=True,
w=300
)
], gap="xs", align="flex-end")
], gap=0)
# Hilfsfunktion für Input-Felder mit Tooltip - für Zeit-Grid
def create_input_with_tooltip_time(component, tooltip_text):
"""Erstellt ein Input-Feld mit Tooltip-Icon für Zeit-Eingaben"""
return dmc.Group([
component,
dmc.Tooltip(
children=[
dmc.ActionIcon(
DashIconify(icon="mdi:help-circle-outline", width=16),
variant="subtle",
color="gray",
size="sm"
)
],
label=tooltip_text,
position="top",
multiline=True,
w=300
)
], gap="xs", align="flex-end")
app.layout = dmc.MantineProvider([
dmc.DatesProvider(**german_dates_provider_props, children=[
dmc.Container([
dmc.Title("Erweiterte Terminverwaltung", order=1, className="mb-4"),
dmc.Grid([
dmc.GridCol([
dmc.Paper([
dmc.Title("Termindetails", order=3, className="mb-3"),
dmc.Stack([
create_input_with_tooltip_full(
dmc.TextInput(
label="Titel",
placeholder="Terminbezeichnung eingeben",
leftSection=DashIconify(icon="mdi:calendar-text"),
id="title-input",
required=True,
style={"flex": 1}
),
"Geben Sie eine aussagekräftige Bezeichnung für den Termin ein"
),
create_input_with_tooltip_full(
dmc.DatePickerInput(
label="Startdatum",
value=datetime.now().date(),
id="start-date-input",
firstDayOfWeek=1,
weekendDays=[0, 6],
valueFormat="DD.MM.YYYY",
placeholder="Datum auswählen",
leftSection=DashIconify(icon="mdi:calendar"),
clearable=False,
style={"flex": 1}
),
"Wählen Sie das Datum für den Termin aus dem Kalender"
),
# Zeitbereich - nebeneinander
dmc.Grid([
dmc.GridCol([
dmc.Stack([
create_input_with_tooltip_time(
dmc.Select(
label="Startzeit",
placeholder="Zeit auswählen",
data=time_options,
searchable=True,
clearable=True,
id="time-start",
value="09:00",
required=True,
style={"flex": 1}
),
"Eingabe: 10:25, 10.25, 1025 oder 10 (wird automatisch formatiert)"
),
html.Div(id="start-time-feedback")
], gap="xs")
], span=6),
dmc.GridCol([
dmc.Stack([
create_input_with_tooltip_time(
dmc.Select(
label="Endzeit",
placeholder="Zeit auswählen",
data=time_options,
searchable=True,
clearable=True,
id="time-end",
required=True,
style={"flex": 1}
),
"Endzeit muss nach der Startzeit am selben Tag liegen. Termine können nicht über Mitternacht hinausgehen."
),
html.Div(id="end-time-feedback")
], gap="xs")
], span=6)
]),
create_input_with_tooltip_full(
dmc.Select(
label="Termintyp",
placeholder="Typ auswählen",
data=[
{"value": "presentation", "label": "Präsentation"},
{"value": "website", "label": "Website"},
{"value": "video", "label": "Video"},
{"value": "message", "label": "Nachricht"},
{"value": "other", "label": "Sonstiges"}
],
id="type-input",
required=True,
style={"flex": 1}
),
"Wählen Sie den Typ des Termins für bessere Kategorisierung"
),
# Dynamische typ-spezifische Felder
html.Div(id="type-specific-fields"),
create_input_with_tooltip_full(
dmc.Textarea(
label="Beschreibung",
placeholder="Zusätzliche Informationen...",
minRows=3,
autosize=True,
id="description-input",
style={"flex": 1}
),
"Optionale Beschreibung mit weiteren Details zum Termin"
)
], gap="md")
], p="md", shadow="sm")
], span=6),
dmc.GridCol([
dmc.Paper([
dmc.Title("Wiederholung", order=3, className="mb-3"),
dmc.Stack([
create_input_with_tooltip_full(
dmc.Checkbox(
label="Wiederholender Termin",
id="repeat-checkbox",
description="Aktivieren für wöchentliche Wiederholung"
),
"Aktivieren Sie diese Option, um den Termin an mehreren Wochentagen zu wiederholen"
),
html.Div(id="repeat-options", children=[
create_input_with_tooltip_full(
dmc.MultiSelect(
label="Wochentage",
placeholder="Wochentage auswählen",
data=weekday_options,
id="weekdays-select",
description="An welchen Wochentagen soll der Termin stattfinden?",
disabled=True,
style={"flex": 1}
),
"Wählen Sie mindestens einen Wochentag für die Wiederholung aus"
),
dmc.Space(h="md"), # Abstand zwischen DatePicker und Ferientage
create_input_with_tooltip_full(
dmc.DatePickerInput(
label="Wiederholung bis",
id="repeat-until-date",
firstDayOfWeek=1,
weekendDays=[0, 6],
valueFormat="DD.MM.YYYY",
placeholder="Enddatum auswählen",
leftSection=DashIconify(icon="mdi:calendar-end"),
description="Letzter Tag der Wiederholung",
disabled=True,
clearable=True,
style={"flex": 1}
),
"Datum bis wann die Wiederholung stattfinden soll. Muss nach dem Startdatum liegen."
),
dmc.Space(h="lg"), # Größerer Abstand vor Ferientage
create_input_with_tooltip_full(
dmc.Checkbox(
label="Ferientage berücksichtigen",
id="skip-holidays-checkbox",
description="Termine an Feiertagen und in Schulferien auslassen",
disabled=True
),
"Aktivieren Sie diese Option, um Termine an deutschen Feiertagen und in Schulferien automatisch zu überspringen"
)
])
], gap="md")
], p="md", shadow="sm"),
dmc.Paper([
dmc.Title("Aktionen", order=3, className="mb-3"),
dmc.Stack([
dmc.Button(
"Termin(e) speichern",
color="green",
leftSection=DashIconify(icon="mdi:content-save"),
id="btn-save",
size="lg",
fullWidth=True
),
dmc.Button(
"Zurücksetzen",
color="gray",
variant="outline",
leftSection=DashIconify(icon="mdi:refresh"),
id="btn-reset",
fullWidth=True
),
html.Div(id="save-feedback", className="mt-3")
], gap="md")
], p="md", shadow="sm", className="mt-3")
], span=6)
]),
# Vorschau-Bereich
dmc.Paper([
dmc.Title("Vorschau", order=3, className="mb-3"),
html.Div(id="preview-area")
], p="md", shadow="sm", className="mt-4")
], size="lg")
])
])
# Zeit-Validierungsfunktion
def validate_and_format_time(time_str):
"""Validiert und formatiert Zeitangaben"""
if not time_str:
return None, "Keine Zeit angegeben"
# Bereits korrektes Format
if re.match(r'^\d{2}:\d{2}$', time_str):
try:
h, m = map(int, time_str.split(':'))
if 0 <= h <= 23 and 0 <= m <= 59:
return time_str, "Gültige Zeit"
except:
pass
# Verschiedene Eingabeformate versuchen
patterns = [
(r'^(\d{1,2}):(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
(r'^(\d{1,2})\.(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
(r'^(\d{1,2})(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
(r'^(\d{1,2})$', lambda m: (int(m.group(1)), 0)),
]
for pattern, extractor in patterns:
match = re.match(pattern, time_str.strip())
if match:
try:
hours, minutes = extractor(match)
if 0 <= hours <= 23 and 0 <= minutes <= 59:
return f"{hours:02d}:{minutes:02d}", "Automatisch formatiert"
except:
continue
return None, "Ungültiges Zeitformat"
# Typ-spezifische Felder anzeigen
@callback(
Output('type-specific-fields', 'children'),
Input('type-input', 'value'),
prevent_initial_call=True
)
def show_type_specific_fields(event_type):
if not event_type:
return html.Div()
if event_type == "presentation":
return dmc.Stack([
dmc.Divider(label="Präsentations-Details", labelPosition="center"),
dmc.Group([
dcc.Upload(
id='presentation-upload',
children=dmc.Button(
"Datei hochladen",
leftSection=DashIconify(icon="mdi:upload"),
variant="outline"
),
style={'width': 'auto'}
),
dmc.TextInput(
label="Präsentationslink",
placeholder="https://...",
leftSection=DashIconify(icon="mdi:link"),
id="presentation-link",
style={"minWidth": 0, "flex": 1, "marginBottom": 0}
)
], grow=True, align="flex-end", style={"marginBottom": 10}),
html.Div(id="presentation-upload-status")
], gap="sm")
elif event_type == "video":
return dmc.Stack([
dmc.Divider(label="Video-Details", labelPosition="center"),
dmc.Group([
dcc.Upload(
id='video-upload',
children=dmc.Button(
"Video hochladen",
leftSection=DashIconify(icon="mdi:video-plus"),
variant="outline"
),
style={'width': 'auto'}
),
dmc.TextInput(
label="Videolink",
placeholder="https://youtube.com/...",
leftSection=DashIconify(icon="mdi:youtube"),
id="video-link",
style={"minWidth": 0, "flex": 1, "marginBottom": 0}
)
], grow=True, align="flex-end", style={"marginBottom": 10}),
dmc.Group([
dmc.Checkbox(
label="Endlos wiederholen",
id="video-endless",
checked=True,
style={"marginRight": 20}
),
dmc.NumberInput(
label="Wiederholungen",
id="video-repeats",
value=1,
min=1,
max=99,
step=1,
disabled=True,
style={"width": 150}
),
dmc.Slider(
label="Lautstärke",
id="video-volume",
value=70,
min=0,
max=100,
step=5,
marks=[
{"value": 0, "label": "0%"},
{"value": 50, "label": "50%"},
{"value": 100, "label": "100%"}
],
style={"flex": 1, "marginLeft": 20}
)
], grow=True, align="flex-end"),
html.Div(id="video-upload-status")
], gap="sm")
elif event_type == "website":
return dmc.Stack([
dmc.Divider(label="Website-Details", labelPosition="center"),
dmc.TextInput(
label="Website-URL",
placeholder="https://example.com",
leftSection=DashIconify(icon="mdi:web"),
id="website-url",
required=True,
style={"flex": 1}
)
# Anzeigedauer entfernt!
], gap="sm")
elif event_type == "message":
return dmc.Stack([
dmc.Divider(label="Nachrichten-Details", labelPosition="center"),
dash_quill.Quill(
id="message-content",
value="",
# theme="snow",
# style={"height": "150px", "marginBottom": 10}
),
dmc.Group([
dmc.Select(
label="Schriftgröße",
data=[
{"value": "small", "label": "Klein"},
{"value": "medium", "label": "Normal"},
{"value": "large", "label": "Groß"},
{"value": "xlarge", "label": "Sehr groß"}
],
id="message-font-size",
value="medium",
style={"flex": 1}
),
dmc.ColorPicker(
id="message-color",
value="#000000",
format="hex",
swatches=[
"#000000", "#ffffff", "#ff0000", "#00ff00",
"#0000ff", "#ffff00", "#ff00ff", "#00ffff"
]
)
], grow=True, align="flex-end")
], gap="sm")
return html.Div()
# Callback zum Aktivieren/Deaktivieren des Wiederholungsfelds bei Video
@callback(
Output("video-repeats", "disabled"),
Input("video-endless", "checked"),
prevent_initial_call=True
)
def toggle_video_repeats(endless_checked):
return endless_checked
# Upload-Status für Präsentation
@callback(
Output('presentation-upload-status', 'children'),
Input('presentation-upload', 'contents'),
State('presentation-upload', 'filename'),
prevent_initial_call=True
)
def update_presentation_upload_status(contents, filename):
"""Zeigt Status des Präsentations-Uploads"""
if contents is not None and filename is not None:
return dmc.Alert(
f"✓ Datei '{filename}' erfolgreich hochgeladen",
color="green",
className="mt-2"
)
return html.Div()
# Upload-Status für Video
@callback(
Output('video-upload-status', 'children'),
Input('video-upload', 'contents'),
State('video-upload', 'filename'),
prevent_initial_call=True
)
def update_video_upload_status(contents, filename):
"""Zeigt Status des Video-Uploads"""
if contents is not None and filename is not None:
return dmc.Alert(
f"✓ Video '{filename}' erfolgreich hochgeladen",
color="green",
className="mt-2"
)
return html.Div()
# Wiederholungsoptionen aktivieren/deaktivieren
@callback(
[
Output('weekdays-select', 'disabled'),
Output('repeat-until-date', 'disabled'),
Output('skip-holidays-checkbox', 'disabled'),
Output('weekdays-select', 'value'),
Output('repeat-until-date', 'value'),
Output('skip-holidays-checkbox', 'checked')
],
Input('repeat-checkbox', 'checked'),
prevent_initial_call=True
)
def toggle_repeat_options(is_repeat):
"""Aktiviert/deaktiviert Wiederholungsoptionen"""
if is_repeat:
# Aktiviert und setzt Standardwerte
next_month = datetime.now().date() + timedelta(weeks=4) # 4 Wochen später
return (
False, # weekdays-select enabled
False, # repeat-until-date enabled
False, # skip-holidays-checkbox enabled
None, # weekdays value
next_month, # repeat-until-date value
False # skip-holidays-checkbox checked
)
else:
# Deaktiviert und löscht Werte
return (
True, # weekdays-select disabled
True, # repeat-until-date disabled
True, # skip-holidays-checkbox disabled
None, # weekdays value
None, # repeat-until-date value
False # skip-holidays-checkbox checked
)
# Dynamische Zeitoptionen für Startzeit
@callback(
[
Output('time-start', 'data'),
Output('start-time-feedback', 'children')
],
Input('time-start', 'searchValue'),
prevent_initial_call=True
)
def update_start_time_options(search_value):
"""Erweitert Zeitoptionen basierend auf Sucheingabe"""
base_options = time_options.copy()
feedback = None
if search_value:
validated_time, status = validate_and_format_time(search_value)
if validated_time:
if not any(opt["value"] == validated_time for opt in base_options):
base_options.insert(0, {
"value": validated_time,
"label": f"{validated_time} (Ihre Eingabe)"
})
feedback = dmc.Text(f"{status}: {validated_time}", size="xs", c="green")
else:
feedback = dmc.Text(f"{status}", size="xs", c="red")
return base_options, feedback
# Dynamische Zeitoptionen für Endzeit
@callback(
[
Output('time-end', 'data'),
Output('end-time-feedback', 'children')
],
Input('time-end', 'searchValue'),
prevent_initial_call=True
)
def update_end_time_options(search_value):
"""Erweitert Zeitoptionen basierend auf Sucheingabe"""
base_options = time_options.copy()
feedback = None
if search_value:
validated_time, status = validate_and_format_time(search_value)
if validated_time:
if not any(opt["value"] == validated_time for opt in base_options):
base_options.insert(0, {
"value": validated_time,
"label": f"{validated_time} (Ihre Eingabe)"
})
feedback = dmc.Text(f"{status}: {validated_time}", size="xs", c="green")
else:
feedback = dmc.Text(f"{status}", size="xs", c="red")
return base_options, feedback
# Automatische Endzeit-Berechnung mit Validation
@callback(
Output('time-end', 'value'),
[
Input('time-start', 'value'),
Input('btn-reset', 'n_clicks')
],
State('time-end', 'value'),
prevent_initial_call=True
)
def handle_end_time(start_time, reset_clicks, current_end_time):
"""Behandelt automatische Endzeit-Berechnung und Reset"""
ctx = dash.callback_context
if not ctx.triggered:
return dash.no_update
trigger_id = ctx.triggered[0]['prop_id'].split('.')[0]
if trigger_id == 'btn-reset' and reset_clicks:
return None
if trigger_id == 'time-start' and start_time:
if current_end_time:
return dash.no_update
try:
validated_start, _ = validate_and_format_time(start_time)
if validated_start:
start_dt = datetime.strptime(validated_start, "%H:%M")
# 1.5 Stunden später, aber maximal 23:59
end_dt = start_dt + timedelta(hours=1, minutes=30)
if end_dt.hour >= 24:
end_dt = end_dt.replace(hour=23, minute=59)
return end_dt.strftime("%H:%M")
except:
pass
return dash.no_update
# Hilfsfunktion für sichere Werte-Abfrage
def get_safe_value(ctx, prop_id):
"""Gibt den Wert einer Property zurück oder None, wenn sie nicht existiert"""
try:
return ctx.states.get(prop_id, {}).get('value')
except:
return None
# Vorschau-Bereich mit Ferientags-Berücksichtigung und typ-spezifischen Daten
@callback(
Output('preview-area', 'children'),
[
Input('title-input', 'value'),
Input('start-date-input', 'value'),
Input('time-start', 'value'),
Input('time-end', 'value'),
Input('type-input', 'value'),
Input('description-input', 'value'),
Input('repeat-checkbox', 'checked'),
Input('weekdays-select', 'value'),
Input('repeat-until-date', 'value'),
Input('skip-holidays-checkbox', 'checked')
],
prevent_initial_call=True
)
def update_preview(title, start_date, start_time, end_time, event_type, description,
is_repeat, weekdays, repeat_until, skip_holidays):
"""Zeigt Live-Vorschau der Termine mit typ-spezifischen Daten"""
validated_start, start_status = validate_and_format_time(start_time)
validated_end, end_status = validate_and_format_time(end_time)
# Zeitvalidierung
time_valid = True
time_error = ""
if validated_start and validated_end:
start_dt = datetime.strptime(validated_start, "%H:%M")
end_dt = datetime.strptime(validated_end, "%H:%M")
if end_dt <= start_dt:
time_valid = False
time_error = "Endzeit muss nach Startzeit liegen"
elif end_dt.hour < start_dt.hour: # Über Mitternacht
time_valid = False
time_error = "Termine dürfen nicht über Mitternacht hinausgehen"
# Typ-spezifische Details mit sicherer Abfrage
type_details = []
if event_type == "presentation":
# Hier würden wir normalerweise die Werte abfragen, aber da sie dynamisch sind,
# zeigen wir nur den Typ an
type_details.append(dmc.Text("🎯 Präsentationsdetails werden nach Auswahl angezeigt", size="sm"))
elif event_type == "video":
type_details.append(dmc.Text("📹 Videodetails werden nach Auswahl angezeigt", size="sm"))
elif event_type == "website":
type_details.append(dmc.Text("🌐 Website-Details werden nach Auswahl angezeigt", size="sm"))
elif event_type == "message":
type_details.append(dmc.Text("💬 Nachrichten-Details werden nach Auswahl angezeigt", size="sm"))
# Wiederholungslogik mit Ferientags-Berücksichtigung
if is_repeat and weekdays and start_date and repeat_until and time_valid:
weekday_names = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
selected_days = [weekday_names[int(day)] for day in weekdays]
# Termine berechnen
termine_count = 0
skipped_holidays = 0
# Sicherstellen, dass start_date ein date-Objekt ist
if isinstance(start_date, str):
try:
current_date = datetime.strptime(start_date, "%Y-%m-%d").date()
except:
current_date = datetime.now().date()
else:
current_date = start_date
# Sicherstellen, dass repeat_until ein date-Objekt ist
if isinstance(repeat_until, str):
try:
end_date = datetime.strptime(repeat_until, "%Y-%m-%d").date()
except:
end_date = current_date + timedelta(weeks=4)
else:
end_date = repeat_until
# Kopie für Iteration erstellen
iter_date = current_date
while iter_date <= end_date:
if str(iter_date.weekday()) in weekdays:
if skip_holidays and is_holiday_or_vacation(iter_date):
skipped_holidays += 1
else:
termine_count += 1
iter_date += timedelta(days=1)
holiday_info = []
if skip_holidays:
holiday_info = [
dmc.Text(f"🚫 Übersprungene Ferientage: {skipped_holidays}", size="sm", c="orange"),
dmc.Text(f"📅 Tatsächliche Termine: {termine_count}", size="sm", fw=500)
]
repeat_info = dmc.Stack([
dmc.Text(f"📅 Wiederholung: {', '.join(selected_days)}", size="sm"),
dmc.Text(f"📆 Zeitraum: {current_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}", size="sm"),
dmc.Text(f"🔢 Geplante Termine: {termine_count + skipped_holidays if skip_holidays else termine_count}", size="sm"),
*holiday_info
])
else:
repeat_info = dmc.Text("📅 Einzeltermin", size="sm")
# Datum formatieren
date_str = start_date.strftime('%d.%m.%Y') if isinstance(start_date, date) else (start_date or "Nicht gesetzt")
return dmc.Stack([
dmc.Title(title or "Unbenannter Termin", order=4),
dmc.Text(f"📅 Datum: {date_str}", size="sm"),
dmc.Text(f"🕐 Zeit: {validated_start or 'Nicht gesetzt'} - {validated_end or 'Nicht gesetzt'}", size="sm"),
dmc.Text(f"📋 Typ: {event_type or 'Nicht gesetzt'}", size="sm"),
# Typ-spezifische Details
*type_details,
dmc.Text(f"📝 Beschreibung: {description[:100] + '...' if description and len(description) > 100 else description or 'Keine'}", size="sm"),
dmc.Divider(className="my-2"),
repeat_info,
dmc.Divider(className="my-2"),
dmc.Stack([
dmc.Text("Validierung:", fw=500, size="xs"),
dmc.Text(f"Start: {start_status}", size="xs", c="green" if validated_start else "red"),
dmc.Text(f"Ende: {end_status}", size="xs", c="green" if validated_end else "red"),
dmc.Text(f"Zeitbereich: {'✓ Gültig' if time_valid else f'{time_error}'}",
size="xs", c="green" if time_valid else "red")
], gap="xs")
])
# Reset-Funktion erweitert
@callback(
[
Output('title-input', 'value'),
Output('start-date-input', 'value'),
Output('time-start', 'value'),
Output('type-input', 'value'),
Output('description-input', 'value'),
Output('repeat-checkbox', 'checked'),
Output('weekdays-select', 'value', allow_duplicate=True),
Output('repeat-until-date', 'value', allow_duplicate=True),
Output('skip-holidays-checkbox', 'checked', allow_duplicate=True)
],
Input('btn-reset', 'n_clicks'),
prevent_initial_call=True
)
def reset_form(n_clicks):
"""Setzt das komplette Formular zurück"""
if n_clicks:
return "", datetime.now().date(), "09:00", None, "", False, None, None, False
return dash.no_update
# Speichern-Funktion (vereinfacht für Demo)
@callback(
Output('save-feedback', 'children'),
Input('btn-save', 'n_clicks'),
prevent_initial_call=True
)
def save_appointments_demo(n_clicks):
"""Demo-Speicherfunktion"""
if not n_clicks:
return dash.no_update
return dmc.Alert(
"Demo: Termine würden hier gespeichert werden",
color="blue",
title="Speichern (Demo-Modus)"
)
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=8051)

View File

@@ -0,0 +1,5 @@
import dash
from dash import html
dash.register_page(__name__, path="/test", name="Testseite")
layout = html.Div("Testseite funktioniert!")

View File

@@ -0,0 +1,193 @@
"""
This app creates a collapsible, responsive sidebar layout with
dash-bootstrap-components and some custom css with media queries.
When the screen is small, the sidebar moved to the top of the page, and the
links get hidden in a collapse element. We use a callback to toggle the
collapse when on a small screen, and the custom CSS to hide the toggle, and
force the collapse to stay open when the screen is large.
dcc.Location is used to track the current location, a callback uses the current
location to render the appropriate page content. The active prop of each
NavLink is set automatically according to the current pathname. To use this
feature you must install dash-bootstrap-components >= 0.11.0.
For more details on building multi-page Dash applications, check out the Dash
documentation: https://dash.plotly.com/urls
"""
import sys
sys.path.append('/workspace')
import dash
import dash_bootstrap_components as dbc
from dash import Input, Output, State, dcc, html, page_container
from dash_iconify import DashIconify
# import callbacks.ui_callbacks
import dashboard.callbacks.appointments_callbacks
import dashboard.callbacks.appointment_modal_callbacks
import dash_mantine_components as dmc
app = dash.Dash(
external_stylesheets=[dbc.themes.BOOTSTRAP],
# these meta_tags ensure content is scaled correctly on different devices
# see: https://www.w3schools.com/css/css_rwd_viewport.asp for more
meta_tags=[{"name": "viewport", "content": "width=device-width, initial-scale=1"}],
use_pages=True,
suppress_callback_exceptions=True,
)
nav_items = [
{"label": "Übersicht", "href": "/overview", "icon": "mdi:view-dashboard"},
{"label": "Termine", "href": "/appointments","icon": "mdi:calendar"},
{"label": "Bildschirme", "href": "/clients", "icon": "mdi:monitor"},
{"label": "Einstellungen","href": "/settings", "icon": "mdi:cog"},
{"label": "Benutzer", "href": "/users", "icon": "mdi:account"},
]
nav_links = []
for item in nav_items:
# Create a NavLink for each item
link_id = {"type": "nav-item", "index": item["label"]}
nav_link = dbc.NavLink(
[
DashIconify(icon=item["icon"], width=24),
html.Span(item["label"], className="ms-2 sidebar-label"),
],
href=item["href"],
active="exact",
className="sidebar-item",
id=link_id,
)
nav_links.append(
html.Div(
children=nav_link,
className="nav-item-container"
)
)
# we use the Row and Col components to construct the sidebar header
# it consists of a title, and a toggle, the latter is hidden on large screens
sidebar_header = dbc.Row(
[
dbc.Col(html.H2("Sidebar", className="display-4")),
dbc.Col(
[
html.Button(
# use the Bootstrap navbar-toggler classes to style
html.Span(className="navbar-toggler-icon"),
className="navbar-toggler",
# the navbar-toggler classes don't set color
style={
"color": "rgba(0,0,0,.5)",
"border-color": "rgba(0,0,0,.1)",
},
id="navbar-toggle",
),
html.Button(
# use the Bootstrap navbar-toggler classes to style
html.Span(className="navbar-toggler-icon"),
className="navbar-toggler",
# the navbar-toggler classes don't set color
style={
"color": "rgba(0,0,0,.5)",
"border-color": "rgba(0,0,0,.1)",
},
id="sidebar-toggle",
),
],
# the column containing the toggle will be only as wide as the
# toggle, resulting in the toggle being right aligned
width="auto",
# vertically align the toggle in the center
align="center",
),
]
)
sidebar = html.Div(
[
sidebar_header,
# we wrap the horizontal rule and short blurb in a div that can be
# hidden on a small screen
html.Div(
[
html.Hr(),
html.P(
"A responsive sidebar layout with collapsible navigation " "links.",
className="lead",
),
],
id="blurb",
),
# use the Collapse component to animate hiding / revealing links
dbc.Collapse(
dbc.Nav(
nav_links, # <-- Korrigiert: keine zusätzliche Liste
vertical=True,
pills=True,
),
id="collapse",
),
],
id="sidebar",
)
content = dmc.MantineProvider([
html.Div(
html.Div(page_container, className="page-content"),style={"flex": "1", "padding": "20px"}
)
])
app.layout = html.Div([dcc.Location(id="url"), sidebar, content])
# @app.callback(Output("page-content", "children"), [Input("url", "pathname")])
# def render_page_content(pathname):
# if pathname == "/":
# return html.P("This is the content of the home page!")
# elif pathname == "/page-1":
# return html.P("This is the content of page 1. Yay!")
# elif pathname == "/page-2":
# return html.P("Oh cool, this is page 2!")
# # If the user tries to reach a different page, return a 404 message
# return html.Div(
# [
# html.H1("404: Not found", className="text-danger"),
# html.Hr(),
# html.P(f"The pathname {pathname} was not recognised..."),
# ],
# className="p-3 bg-light rounded-3",
# )
@app.callback(
[Output("sidebar", "className"), Output("collapse", "is_open")],
[
Input("sidebar-toggle", "n_clicks"),
Input("navbar-toggle", "n_clicks"),
],
[
State("sidebar", "className"),
State("collapse", "is_open"),
],
)
def toggle_sidebar_and_collapse(sidebar_n, navbar_n, classname, is_open):
ctx = dash.callback_context
if not ctx.triggered:
return classname, is_open
trigger_id = ctx.triggered[0]["prop_id"].split(".")[0]
if trigger_id == "sidebar-toggle":
# Toggle sidebar collapse
if sidebar_n and classname == "":
return "collapsed", is_open
return "", is_open
elif trigger_id == "navbar-toggle":
# Toggle collapse
if navbar_n:
return classname, not is_open
return classname, is_open
return classname, is_open
if __name__ == "__main__":
app.run(port=8888, debug=True)

1
dashboard/.dockerignore Normal file
View File

@@ -0,0 +1 @@
node_modules

34
dashboard/.eslintrc.cjs Normal file
View File

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

9
dashboard/.prettierrc Normal file
View File

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

View File

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

View File

@@ -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 WORKDIR /app
# --- Systemabhängigkeiten installieren (falls benötigt) --- # Copy package files
RUN apt-get update \ COPY package*.json ./
&& apt-get install -y --no-install-recommends \ COPY pnpm-lock.yaml* ./
build-essential git \
&& rm -rf /var/lib/apt/lists/*
# --- Python-Abhängigkeiten kopieren und installieren --- # Install pnpm and dependencies
COPY dash_using_fullcalendar-0.1.0.tar.gz ./ RUN npm install -g pnpm
COPY requirements.txt ./ RUN pnpm install --frozen-lockfile
RUN pip install --no-cache-dir -r requirements.txt
# --- Applikationscode kopieren --- # Copy source code
COPY dashboard/ /app COPY . .
# --- Non-Root-User anlegen und Rechte setzen --- # Build arguments
ARG USER_ID=1000 ARG NODE_ENV=production
ARG GROUP_ID=1000 ARG VITE_API_URL
RUN groupadd -g ${GROUP_ID} infoscreen_taa \
&& useradd -u ${USER_ID} -g ${GROUP_ID} \
--shell /bin/bash --create-home infoscreen_taa \
&& chown -R infoscreen_taa:infoscreen_taa /app
USER infoscreen_taa
# --- Port für Dash exposed --- # Build the application
EXPOSE 8050 RUN pnpm build
# --- Startbefehl: Gunicorn mit Dash-Server --- # Production stage with nginx
# "app.py" enthält: app = dash.Dash(...); server = app.server FROM nginx:alpine
CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:8050", "app:server"]
# Copy built files to nginx
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy custom nginx config (optional)
COPY nginx.conf /etc/nginx/nginx.conf
# Expose port
EXPOSE 3000
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,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 # Install dependencies manager (pnpm optional, npm reicht für Compose-Setup)
ARG USER_ID=1000 # RUN npm install -g pnpm
ARG GROUP_ID=1000
# Systemabhängigkeiten (falls nötig) # Copy package files
RUN apt-get update \ COPY package*.json ./
&& apt-get install -y --no-install-recommends locales curl \
&& sed -i 's/# de_DE.UTF-8 UTF-8/de_DE.UTF-8 UTF-8/' /etc/locale.gen \
&& locale-gen \
&& rm -rf /var/lib/apt/lists/*
# Locale setzen # Install dependencies (nutze npm, da Compose "npm run dev" nutzt)
ENV LANG=de_DE.UTF-8 \ RUN npm install
LANGUAGE=de_DE:de \
LC_ALL=de_DE.UTF-8
# Non-root User anlegen # Copy source code
RUN groupadd -g ${GROUP_ID} infoscreen_taa \ COPY . .
&& useradd -u ${USER_ID} -g ${GROUP_ID} --shell /bin/bash --create-home infoscreen_taa
# Arbeitsverzeichnis # Expose ports
WORKDIR /app EXPOSE 3000 9229
# Kopiere nur Requirements für schnellen Rebuild # Standard-Dev-Command (wird von Compose überschrieben)
COPY dash_using_fullcalendar-0.1.0.tar.gz ./ CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
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"]

54
dashboard/README.md Normal file
View File

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

View File

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

13
dashboard/index.html Normal file
View File

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

7968
dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
dashboard/package.json Normal file
View 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"
}
}

2310
dashboard/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

11
dashboard/src/App.css Normal file
View 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
View 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;

View File

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

After

Width:  |  Height:  |  Size: 4.0 KiB

75
dashboard/src/index.css Normal file
View 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
View 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
View File

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

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

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

View File

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

View File

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

10
dashboard/tsconfig.json Normal file
View File

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

View File

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

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

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

View File

@@ -17,6 +17,7 @@ services:
- dashboard - dashboard
networks: networks:
- infoscreen-net - infoscreen-net
db: db:
image: mariadb:11.4.7 image: mariadb:11.4.7
container_name: infoscreen-db container_name: infoscreen-db
@@ -32,7 +33,6 @@ services:
- "3306:3306" - "3306:3306"
networks: networks:
- infoscreen-net - infoscreen-net
# ✅ HINZUGEFÜGT: Healthcheck für MariaDB
healthcheck: healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 30s interval: 30s
@@ -48,13 +48,11 @@ services:
- ./mosquitto/config:/mosquitto/config - ./mosquitto/config:/mosquitto/config
- ./mosquitto/data:/mosquitto/data - ./mosquitto/data:/mosquitto/data
- ./mosquitto/log:/mosquitto/log - ./mosquitto/log:/mosquitto/log
# ✅ HINZUGEFÜGT: MQTT-Ports explizit exponiert
ports: ports:
- "1883:1883" # Standard MQTT - "1883:1883" # Standard MQTT
- "9001:9001" # WebSocket (falls benötigt) - "9001:9001" # WebSocket (falls benötigt)
networks: networks:
- infoscreen-net - infoscreen-net
# ✅ HINZUGEFÜGT: Healthcheck für MQTT
healthcheck: healthcheck:
test: ["CMD-SHELL", "mosquitto_pub -h localhost -t test -m 'health' || exit 1"] test: ["CMD-SHELL", "mosquitto_pub -h localhost -t test -m 'health' || exit 1"]
interval: 30s interval: 30s
@@ -70,7 +68,6 @@ services:
container_name: infoscreen-api container_name: infoscreen-api
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
# ✅ GEÄNDERT: Erweiterte depends_on mit Healthcheck-Conditions
db: db:
condition: service_healthy condition: service_healthy
mqtt: mqtt:
@@ -93,33 +90,36 @@ services:
retries: 3 retries: 3
start_period: 40s start_period: 40s
# ✅ GEÄNDERT: Dashboard jetzt mit Node.js/React statt Python/Dash
dashboard: dashboard:
build: build:
context: ./dashboard context: ./dashboard
dockerfile: Dockerfile dockerfile: Dockerfile
# ✅ HINZUGEFÜGT: Build-Args für React Production Build
args:
- NODE_ENV=production
- VITE_API_URL=${API_URL}
image: infoscreen-dashboard:latest image: infoscreen-dashboard:latest
container_name: infoscreen-dashboard container_name: infoscreen-dashboard
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
# ✅ GEÄNDERT: Healthcheck-Condition für Server
server: server:
condition: service_healthy condition: service_healthy
environment: environment:
API_URL: ${API_URL} # ✅ GEÄNDERT: React-spezifische Umgebungsvariablen
- VITE_API_URL=${API_URL}
- NODE_ENV=production
ports: ports:
- "8050:8050" - "3000:3000" # ✅ GEÄNDERT: Standard React/Vite Port
networks: networks:
- infoscreen-net - infoscreen-net
# ✅ GEÄNDERT: Healthcheck für React App
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8050/_alive"] test: ["CMD", "curl", "-f", "http://localhost:3000/"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 45s start_period: 30s
volumes: volumes:
db-data: db-data:
# mosquitto-conf:
# mosquitto-data:
# # ✅ HINZUGEFÜGT: Log-Volume für MQTT
# mosquitto-logs:

View File

@@ -1,14 +1,22 @@
events {} events {}
http { http {
upstream dashboard { upstream dashboard {
server infoscreen-dashboard:8050; server 127.0.0.1:3000;
} }
server { server {
listen 80; listen 80;
server_name _; server_name _;
# Optional: HTTP auf HTTPS weiterleiten # Optional: HTTP auf HTTPS weiterleiten
return 301 https://$host$request_uri; # return 301 https://$host$request_uri;
location / {
proxy_pass http://dashboard;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
} }
server { server {
listen 443 ssl; listen 443 ssl;