diff --git a/README.md b/README.md index f45f35e..814a915 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Git for Windows kann direkt über `winget` installiert werden: winget install --id Git.Git -e --source winget ``` -Alternativ kann **Git for Windows** über heruntergeladen und installiert werden. +Alternativ kann **Git for Windows** über https://git-scm.com/install/windows heruntergeladen und installiert werden. Nach der Installation PowerShell, Windows Terminal oder Git Bash neu öffnen und prüfen: @@ -24,7 +24,7 @@ git --version ### macOS -Falls Homebrew noch nicht installiert ist, die Installationsanleitung unter verwenden. +Falls Homebrew noch nicht installiert ist, die Installationsanleitung unter https://brew.sh/ verwenden. Git über Homebrew installieren: @@ -133,6 +133,34 @@ docker compose version docker run --rm hello-world ``` +Falls `docker run` mit + +```text +permission denied while trying to connect to the docker API at unix:///var/run/docker.sock +``` + +abbricht, zunächst prüfen: + +```bash +ls -l /var/run/docker.sock +id +getent group docker +``` + +Ist der Benutzer bereits Mitglied der Gruppe `docker`, die Gruppe aber bei `id` noch nicht aufgeführt, wurde die Gruppenmitgliedschaft von der aktuellen Shell noch nicht übernommen. Dann entweder: + +```bash +newgrp docker +``` + +ausführen oder WSL vollständig neu starten. Dazu in PowerShell: + +```powershell +wsl --shutdown +``` + +Danach Ubuntu erneut öffnen und `docker run --rm hello-world` wiederholen. + > Docker Engine nicht zusätzlich innerhalb von WSL installieren, wenn Docker Desktop verwendet wird. > > Für bessere Dateisystem-Performance das Repository möglichst im WSL-Dateisystem ablegen, z. B. `~/src/intranet`, nicht unter `/mnt/c/...`. @@ -141,6 +169,9 @@ docker run --rm hello-world ### Linux — Ubuntu 24.04 LTS +> **Wichtig für WSL-Nutzer:** +> Wenn Ubuntu unter Windows über WSL 2 läuft und Docker Desktop verwendet wird, **nicht** mit diesem Abschnitt weitermachen. Dieser Abschnitt ist nur für eigenständige Linux-Installationen vorgesehen. + **1. Konfliktierende Pakete entfernen** ```bash @@ -200,8 +231,8 @@ sudo usermod -aG docker $USER Danach ab- und wieder anmelden. -> **Debian 13:** Das Projekt funktioniert ebenfalls unter Debian. Dafür das Docker-Repository entsprechend auf `linux/debian` umstellen: -> +> **Debian 13:** Das Projekt funktioniert ebenfalls unter Debian. Dafür das Docker-Repository entsprechend auf `linux/debian` umstellen: +> https://docs.docker.com/engine/install/debian/ --- @@ -219,7 +250,7 @@ docker run --rm hello-world Dokumentation: - +https://docs.docker.com/desktop/setup/install/mac-install/ --- @@ -236,15 +267,15 @@ web/ Die produktive `docker-compose.yml` wird lokal **nicht** verwendet. Dadurch werden insbesondere folgende Produktionsbestandteile nicht gestartet: -- `scheduler` -- `dokploy-network` -- Strato-SSH -- produktive Scheduler-Secrets +* `scheduler` +* `dokploy-network` +* Strato-SSH +* produktive Scheduler-Secrets Die lokale Compose-Datei startet ausschließlich: -- `web` -- `db` (PostgreSQL 18 mit pgvector) +* `web` +* `db` (PostgreSQL 18 mit pgvector) Sie verwendet denselben `Dockerfile` wie Produktion. @@ -299,13 +330,15 @@ SMS_GATEWAY_URL=http://127.0.0.1:9 HA_BASE_URL=http://127.0.0.1:9 HA_TOKEN=local OPENWEATHER_API_KEY=local + +CKAL_USERNAME= +CKAL_PASSWORD= +CKAL_CALENDAR_URL= ``` Die `.env` darf keine produktiven Secrets enthalten und sollte nicht ins Repository committed werden. -### PostgreSQL-Version prüfen - -Lokal sollte möglichst dieselbe PostgreSQL-Major-Version wie in Produktion verwendet werden. Wir verwenden **PostgreSQL 18**. +> Lokal sollte möglichst dieselbe PostgreSQL-Major-Version wie in Produktion verwendet werden. Wir verwenden **PostgreSQL 18**. --- @@ -319,12 +352,12 @@ In pgAdmin: 4. Datei außerhalb des Repositorys speichern, z. B. `intranet.dump`. 5. **Format: Custom** wählen. 6. Unter **Data Options → Do not save**: - - **Owner: Yes** - - **Privileges: Yes** + + * **Owner: Yes** + * **Privileges: Yes** 7. **Include CREATE DATABASE statement: No** lassen. 8. **Backup** starten. - -Wenn pgAdmin im Browser auf einem entfernten Server läuft, wird die Backup-Datei serverseitig gespeichert. Sie kann anschließend über **Tools → Storage Manager** heruntergeladen werden. +9. Läuft pgAdmin im Browser auf einem entfernten Server, kann die Backup-Datei anschließend über **Tools → Storage Manager** heruntergeladen werden. > Der Dump enthält Produktivdaten. Nicht committen, weitergeben oder in ungeschützten Cloud-Ordnern speichern. @@ -356,6 +389,18 @@ docker exec -it intranet-local-db-1 psql -U intranet -d intranet -c "CREATE EXTE ## 7. Dump mit pgAdmin importieren +### pgAdmin 4 installieren + +Falls pgAdmin 4 noch nicht installiert ist, kann es lokal als Desktop-Anwendung installiert werden. + +* **Windows:** pgAdmin-Installer herunterladen und ausführen. +* **macOS:** pgAdmin-Disk-Image herunterladen, öffnen und die Anwendung installieren. +* **Ubuntu/Linux:** pgAdmin kann über das offizielle APT-Repository installiert werden; für die lokale Nutzung reicht `pgadmin4-desktop`. + +Die Installationspakete und Anleitungen sind unter https://www.pgadmin.org/download/ verfügbar. + +Unter Windows mit WSL 2 kann pgAdmin direkt unter Windows installiert werden. Die PostgreSQL-Datenbank aus Docker ist anschließend über `localhost:5432` erreichbar. + In pgAdmin einen neuen Server registrieren: ```text @@ -375,14 +420,13 @@ Danach: 3. **Format: Custom or tar** wählen. 4. `intranet.dump` auswählen. 5. Unter **Data Options → Do not save**: - - **Owner: Yes** - - **Privileges: Yes** + + * **Owner: Yes** + * **Privileges: Yes** 6. **Include CREATE DATABASE statement: No**. 7. **Clean before restore: No** beim ersten Import. 8. **Restore** starten. -Der Restore sollte ohne Fehler zur fehlenden Extension `vector` durchlaufen. Falls `pg_restore` mit `extension "vector" is not available` abbricht, prüfen, ob in `docker-compose.local.yml` tatsächlich `pgvector/pgvector:pg18` verwendet wird. - Soll die lokale Datenbank später vollständig durch einen neuen Dump ersetzt werden, kann das lokale Setup zurückgesetzt werden: ```bash @@ -410,7 +454,7 @@ docker compose -f docker-compose.local.yml ps Aufrufen: - +http://localhost:8080 Die Benutzer aus dem importierten Dump können anschließend für den Login verwendet werden. @@ -420,6 +464,26 @@ Die lokale Compose-Datei setzt `session.cookie_secure=0`, da lokal HTTP statt HT ## Entwicklung +### Nach einem Neustart starten + +Nach einem Neustart von Windows oder macOS zuerst **Docker Desktop** starten und warten, bis Docker bereit ist. + +Unter einer eigenständigen Linux-Installation sicherstellen, dass der Docker-Dienst läuft. + +Danach im Projektroot die lokalen Container starten: + +```bash +docker compose -f docker-compose.local.yml up -d +``` + +Falls sich das `Dockerfile` seit dem letzten Start geändert hat oder die Images neu gebaut werden sollen: + +```bash +docker compose -f docker-compose.local.yml up -d --build +``` + +Anschließend kann das Intranet wieder unter http://localhost:8080 aufgerufen werden. + ### Status prüfen ```bash diff --git a/docker-compose.local.yml b/docker-compose.local.yml index a532162..b8959a1 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -51,6 +51,45 @@ services: interval: 5s timeout: 5s retries: 12 + + scheduler: + build: + context: . + dockerfile: Dockerfile + env_file: + - .env + environment: + TZ: Europe/Berlin + HOME: /tmp/intranet-worker + XDG_CACHE_HOME: /tmp/intranet-worker-cache + PLAYWRIGHT_BROWSERS_PATH: /ms-playwright + + DB_HOST: db + DB_PORT: "5432" + + CKAL_XLSX_DIR: /data/ckal-xlsx + PDF_OUTPUT: /data/pdf/terminuebersicht.pdf + DASHBOARD_MONTH_DIR: /var/www/html/intranet_repo/projects/dashboard/month + + CALENDAR_INTERVAL_SECONDS: "60" + + STRATO_SYNC_ENABLED: "false" + BRANDSCHUTZ_MAIL_ENABLED: "false" + + command: ["/opt/intranet/docker/scheduler.py"] + init: true + user: "33:33" + shm_size: "512m" + + depends_on: + db: + condition: service_healthy + + volumes: + - dashboard_month:/var/www/html/intranet_repo/projects/dashboard/month + - ckal_xlsx:/data/ckal-xlsx + - calendar_pdf:/data/pdf + - scheduler_state:/data/state volumes: postgres_data: @@ -61,3 +100,4 @@ volumes: dashboard_month: ckal_xlsx: calendar_pdf: + scheduler_state: diff --git a/docker/intranet.ini b/docker/intranet.ini index 66453cf..b08c8e6 100644 --- a/docker/intranet.ini +++ b/docker/intranet.ini @@ -8,4 +8,7 @@ log_errors = On session.cookie_httponly = 1 session.cookie_samesite = Lax session.cookie_secure = 1 +session.use_strict_mode = 1 +session.use_only_cookies = 1 +session.use_trans_sid = 0 expose_php = Off diff --git a/web/intranet_repo/general_files/initialise.php b/web/intranet_repo/general_files/initialise.php index 6b62867..9d995bf 100644 --- a/web/intranet_repo/general_files/initialise.php +++ b/web/intranet_repo/general_files/initialise.php @@ -2,41 +2,49 @@ Beschreibung: Initialisierung der grundlegenden Verarbeitungen für die Seiten des Intranets (1. Aufruf) (Ausgenommen davon sind die An-/Abmeldung und Registrierung) - + Ladung: Session, Parameter, Funktionen, Datenbankverbindung und Authentifizierung des Nutzers */ - - // Session if (session_status() == PHP_SESSION_NONE) { session_start(); } - - // Parameter und Funktionen require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/ini/parameters.php'; require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/ini/functions.php'; - - // Datenbankverbindung - if (isset($_SESSION["user_id"])) { - - // Include database connection file - $pdo = require_once($_SERVER['DOCUMENT_ROOT']."/intranet_repo/general_files/ini/database.php"); - - $stmt = $pdo->prepare("SELECT * FROM user_general_information WHERE user_id = ?"); - $stmt->execute([$_SESSION["user_id"]]); - $user = $stmt->fetch(); - - // Authentication - if (isset($user["mail"])) { - $stmt_groups = $pdo->prepare("SELECT * FROM user_permissions WHERE user_id = ?"); - $stmt_groups->execute([$_SESSION["user_id"]]); - $groups = $stmt_groups->fetch(); - } - - } else { + if (!isset($_SESSION['user_id']) || (int) $_SESSION['user_id'] <= 0) { header("Location: {$g_httpLoginPath}"); exit(); } -?> \ No newline at end of file + + $pdo = require_once($_SERVER['DOCUMENT_ROOT']."/intranet_repo/general_files/ini/database.php"); + $userId = (int) $_SESSION['user_id']; + + // Eine Session ist nur gültig, solange Nutzer UND Login-Datensatz weiterhin existieren. + $stmt = $pdo->prepare( + "SELECT u.* FROM user_general_information u " + . "INNER JOIN user_login l ON l.user_id = u.user_id " + . "WHERE u.user_id = ?" + ); + $stmt->execute([$userId]); + $user = $stmt->fetch(); + + if (!$user || empty($user['mail'])) { + $_SESSION = []; + session_destroy(); + header("Location: {$g_httpLoginPath}"); + exit(); + } + + $stmt_groups = $pdo->prepare("SELECT * FROM user_permissions WHERE user_id = ?"); + $stmt_groups->execute([$userId]); + $groups = $stmt_groups->fetch(); + + if (!$groups) { + $_SESSION = []; + session_destroy(); + header("Location: {$g_httpLoginPath}"); + exit(); + } +?> diff --git a/web/intranet_repo/projects/_central/layouts/admin_brandschutz.css b/web/intranet_repo/projects/_central/layouts/admin_brandschutz.css new file mode 100644 index 0000000..9547e29 --- /dev/null +++ b/web/intranet_repo/projects/_central/layouts/admin_brandschutz.css @@ -0,0 +1,357 @@ +.admin-users-shell { + padding: 18px; + background: linear-gradient(135deg, var(--gray-50) 0%, var(--theme-light, var(--nak-blue-light)) 100%); + min-height: 100%; + } + .admin-table-card { + overflow: hidden; + border: 1px solid rgba(120, 140, 160, 0.22); + background: rgba(255,255,255,0.86); + box-shadow: 0 20px 45px rgba(31, 38, 46, 0.10), 0 1px 0 rgba(255,255,255,0.9) inset; + } + .admin-list-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 18px 12px 18px; + border-bottom: 1px solid rgba(120, 140, 160, 0.16); + background: linear-gradient(180deg, rgba(255,255,255,0.92), rgba(255,255,255,0.72)); + } + .admin-list-title { + font-size: 1.05em; + font-weight: 800; + color: hsl(210, 20%, 15%); + } + .admin-list-hint { + font-size: 0.86em; + color: hsl(210, 15%, 46%); + } + table.admin-user-table { + display: table !important; + width: calc(100% - 24px); + margin: 8px 12px 14px 12px; + border-collapse: separate; + border-spacing: 0 8px; + padding: 0; + background: transparent; + overflow: visible; + } + .admin-user-table thead th { + position: sticky; + top: 0; + padding: 10px 14px; + color: hsl(210, 16%, 46%); + font-size: 0.78em; + font-weight: 800; + text-align: left; + letter-spacing: 0.04em; + text-transform: uppercase; + background: transparent !important; + border: none !important; + } + .admin-user-table tbody tr.admin-user-row { + position: relative; + cursor: pointer; + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 6px 18px rgba(31, 38, 46, 0.06); + transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; + } + .admin-user-table tbody tr.admin-user-row:hover { + transform: translateY(-1px); + background: #FFFFFF; + box-shadow: 0 12px 26px rgba(31, 38, 46, 0.13); + } + .admin-user-table td { + padding: 12px 14px !important; + vertical-align: middle; + border: none !important; + background: transparent !important; + } + .admin-user-table tbody tr.admin-user-row td:first-child { + width: 82px; + min-width: 82px; + max-width: 82px; + } + .admin-profile-button { + display: block; + width: 58px; + height: 58px; + border-radius: 12px; + overflow: hidden; + border: 0; + padding: 0; + background: transparent; + cursor: pointer; + box-shadow: 0 8px 20px rgba(31, 38, 46, 0.18), 0 0 0 3px rgba(255,255,255,0.95); + } + .admin-profile-button img { + display: block; + width: 58px !important; + height: 58px !important; + object-fit: cover; + border-radius: 12px !important; + transition: transform 0.2s ease; + } + .admin-user-table tbody tr.admin-user-row:hover .admin-profile-button img { + transform: scale(1.08); + } + .admin-user-name { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 1em; + font-weight: 800; + color: hsl(210, 20%, 15%); + } + .bs-status-badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + font-size: 0.78em; + font-weight: 800; + } + .bs-status-ok { + color: hsl(145, 50%, 28%); + background: hsl(145, 50%, 93%); + border: 1px solid hsl(145, 50%, 82%); + } + .bs-status-warning { + color: hsl(35, 78%, 30%); + background: hsl(42, 90%, 93%); + border: 1px solid hsl(42, 90%, 78%); + } + .bs-status-overdue { + color: hsl(0, 65%, 38%); + background: hsl(0, 65%, 95%); + border: 1px solid hsl(0, 65%, 82%); + } + .bs-status-critical { + color: #FFFFFF; + background: hsl(0, 65%, 45%); + border: 1px solid hsl(0, 65%, 38%); + } + .bs-due-date { + font-size: 0.9em; + font-weight: 650; + color: hsl(210, 14%, 35%); + } + + /* Modal */ + .admin-modal-card { + position: relative; + width: min(720px, calc(100vw - 24px)); + max-height: min(760px, calc(100vh - 24px)); + overflow: auto; + padding: 0; + background: #FFFFFF; + box-shadow: 0 24px 70px rgba(0,0,0,0.36); + } + .admin-modal-hero { + position: relative; + display: grid; + grid-template-columns: 112px 1fr; + gap: 18px; + align-items: center; + padding: 24px; + color: #FFFFFF; + background: + radial-gradient(circle at 10% 0%, rgba(255,255,255,0.24), transparent 32%), + linear-gradient(135deg, hsl(25, 90%, 45%), hsl(25, 95%, 35%)); + } + .admin-modal-profile-img { + width: 104px; + height: 104px; + object-fit: cover; + border-radius: 16px; + box-shadow: 0 12px 28px rgba(0,0,0,0.32), 0 0 0 4px rgba(255,255,255,0.18); + } + .admin-modal-title { + margin: 0 42px 6px 0; + font-size: 1.45em; + font-weight: 850; + color: #FFFFFF; + } + .admin-modal-subtitle { + display: flex; + flex-flow: row wrap; + gap: 8px; + align-items: center; + margin-bottom: 10px; + color: rgba(255,255,255,0.86); + font-weight: 600; + } + .admin-modal-chip { + display: inline-flex; + align-items: center; + padding: 4px 9px; + border-radius: 9999px; + background: rgba(255,255,255,0.14); + border: 1px solid rgba(255,255,255,0.18); + font-size: 0.82em; + } + .admin-modal-body { + padding: 22px 24px 24px 24px; + } + .admin-section-title { + margin: 0 0 10px 0; + font-size: 0.9em; + font-weight: 850; + letter-spacing: 0.04em; + text-transform: uppercase; + color: hsl(210, 18%, 36%); + } + .admin-modal-card .exit { + position: absolute; + top: 14px; + right: 14px; + width: 34px; + height: 34px; + border: 1px solid rgba(255,255,255,0.25); + background: rgba(255,255,255,0.16); + color: #FFFFFF; + font-size: 1.35em; + line-height: 1; + cursor: pointer; + } + .bs-detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 20px; + } + .bs-detail-card { + padding: 14px 16px; + border: 1px solid hsl(210, 18%, 88%); + background: linear-gradient(180deg, #FFFFFF, hsl(210, 12%, 98%)); + } + .bs-detail-label { + margin-bottom: 3px; + color: hsl(210, 14%, 46%); + font-size: 0.74em; + font-weight: 850; + letter-spacing: 0.04em; + text-transform: uppercase; + } + .bs-detail-value { + color: hsl(210, 20%, 18%); + font-size: 1.02em; + font-weight: 850; + } + .bs-detail-value.overdue { + color: hsl(0, 65%, 42%); + } + .bs-detail-value.ok { + color: hsl(145, 50%, 32%); + } + .admin-modal-actions { + display: flex; + justify-content: space-between; + gap: 10px; + border-top: 1px solid hsl(210, 18%, 90%); + padding-top: 16px; + } + .admin-modal-actions button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 36px; + padding: 8px 13px; + border: 1px solid hsl(210, 18%, 84%); + background: #FFFFFF; + color: hsl(210, 20%, 22%); + font-size: 0.86em; + font-weight: 850; + cursor: pointer; + box-shadow: 0 4px 12px rgba(31,38,46,0.06); + } + .admin-modal-actions button:hover { + background: hsl(210, 57%, 96%); + } + .admin-save-button { + color: #FFFFFF !important; + background: linear-gradient(135deg, var(--nak-orange), hsl(25, 95%, 40%)) !important; + border-color: hsl(25, 95%, 40%) !important; + } + .bs-date-input { + padding: 10px 12px; + font-size: var(--font-size-sm); + font-family: var(--font-primary); + border: 1px solid var(--gray-300); + background: var(--gray-50); + width: 100%; + box-sizing: border-box; + font-weight: 650; + } + .bs-date-input:focus { + border-color: hsl(25, 80%, 55%); + background: #FFFFFF; + outline: none; + box-shadow: 0 0 0 4px hsla(25, 80%, 55%, 0.14); + } + + @media (max-width: 780px) { + .admin-users-shell { padding: 10px; } + .admin-list-header { + flex-direction: column; + align-items: flex-start; + padding: 14px; + gap: 8px; + } + table.admin-user-table { + display: block !important; + width: auto; + margin: 8px; + } + .admin-user-table thead { display: none; } + .admin-user-table tbody { + display: grid; + gap: 10px; + } + .admin-user-table tbody tr.admin-user-row { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 0 12px; + padding: 12px; + border: 1px solid rgba(120, 140, 160, 0.16); + box-shadow: 0 8px 22px rgba(31, 38, 46, 0.08); + } + .admin-user-table tbody tr.admin-user-row td { + display: block; + padding: 0 !important; + } + .admin-user-table tbody tr.admin-user-row td:first-child { + grid-row: 1 / span 3; + width: 72px; + min-width: 72px; + max-width: 72px; + } + .admin-profile-button, + .admin-profile-button img { + width: 64px !important; + height: 64px !important; + border-radius: 12px !important; + } + .admin-modal-card { + width: calc(100vw - 12px); + max-height: calc(100dvh - 12px); + } + .admin-modal-hero { + grid-template-columns: 78px minmax(0, 1fr); + gap: 14px; + padding: 18px; + } + .admin-modal-profile-img { + width: 72px; + height: 72px; + border-radius: 14px; + } + .admin-modal-title { + font-size: 1.15em; + margin-right: 40px; + } + .admin-modal-body { padding: 18px; } + .bs-detail-grid { grid-template-columns: 1fr; } + .admin-modal-actions { flex-direction: column-reverse; } + .admin-modal-actions button { width: 100%; } + } diff --git a/web/intranet_repo/projects/_central/layouts/authorization.css b/web/intranet_repo/projects/_central/layouts/authorization.css new file mode 100644 index 0000000..de1c7e7 --- /dev/null +++ b/web/intranet_repo/projects/_central/layouts/authorization.css @@ -0,0 +1,26 @@ +.login_button { + position: relative; +} + +.login_button.is-loading { + color: transparent !important; + pointer-events: none; +} + +.login_button.is-loading::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 22px; + height: 22px; + margin: -11px 0 0 -11px; + border: 3px solid rgba(255, 255, 255, 0.35); + border-top-color: #fff; + border-radius: 50%; + animation: authorization-spin 0.7s linear infinite; +} + +@keyframes authorization-spin { + to { transform: rotate(360deg); } +} diff --git a/web/intranet_repo/projects/_central/layouts/brandschutz.css b/web/intranet_repo/projects/_central/layouts/brandschutz.css new file mode 100644 index 0000000..ae04a9b --- /dev/null +++ b/web/intranet_repo/projects/_central/layouts/brandschutz.css @@ -0,0 +1,694 @@ +/* ══════════════════════════════════════ + BRANDSCHUTZ, Variables & Base + ══════════════════════════════════════ */ +:root { + --bs-primary: #f08200; + --bs-primary-hover: #d97400; + --bs-primary-light: #fff4e6; + --bs-primary-shadow: rgba(240,130,0,0.35); + --bs-red: #d21400; + --bs-red-light: #ffeaea; + --bs-green: #2e7d32; + --bs-green-light: #e8f5e9; +} + +/* ══════════════════════════════════════ + WIZARD CONTAINER + ══════════════════════════════════════ */ +.bs-page-wrapper { + margin-top: calc(var(--header-height) + var(--header-title-height)); + min-height: calc(100vh - var(--header-height) - var(--header-title-height) - var(--footer-height)); + background: linear-gradient(135deg, var(--gray-50) 0%, hsl(30, 80%, 95%) 100%); + padding: var(--space-lg) 0 var(--space-3xl); +} +.bs-wizard { + max-width: 1400px; + width: 90%; + margin: 0 auto; + background: #ffffff; + box-shadow: var(--shadow-md); + overflow: hidden; +} + +/* ══════════════════════════════════════ + MAIN PROGRESS BAR (Sections) + ══════════════════════════════════════ */ +.bs-progress { + display: flex; + align-items: center; + justify-content: center; + gap: 0; + padding: 20px 28px 16px; + background: var(--gray-50); + border-bottom: 1px solid var(--gray-200); + flex-wrap: wrap; +} +.bs-progress-step { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + flex-shrink: 0; + cursor: default; + transition: opacity 150ms; +} +.bs-progress-step:hover { opacity: 1; } +.bs-progress-circle { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--gray-500); + background: var(--gray-200); + border: 2px solid var(--gray-300); + transition: all 250ms ease; +} +.bs-progress-step.active .bs-progress-circle { + background: var(--bs-primary); + border-color: var(--bs-primary); + color: #ffffff; + box-shadow: 0 2px 8px var(--bs-primary-shadow); +} +.bs-progress-step.done .bs-progress-circle { + background: #ffffff; + border-color: var(--bs-primary); + color: var(--bs-primary); +} +.bs-progress-label { + font-size: var(--font-size-xs); + font-weight: 500; + color: var(--gray-500); + white-space: nowrap; + transition: color 250ms ease; + max-width: 80px; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; +} +.bs-progress-step.active .bs-progress-label, +.bs-progress-step.done .bs-progress-label { + color: var(--bs-primary); +} +.bs-progress-line { + flex: 1; + height: 2px; + min-width: 16px; + max-width: 50px; + background: var(--gray-300); + margin: 0 6px; + margin-bottom: 20px; + transition: background 250ms ease; +} +.bs-progress-line.done { + background: var(--bs-primary); +} + +/* ══════════════════════════════════════ + BODY & SECTION CONTENT + ══════════════════════════════════════ */ +.bs-body { + padding: 28px 32px 20px; +} +.bs-section-header { + text-align: center; + padding: 12px 20px; + background: var(--bs-primary-light); + border-bottom: 1px solid #fde0b0; + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--bs-primary-hover); + letter-spacing: 0.3px; +} +.bs-chapter-title { + font-size: 1.6rem; + font-weight: 600; + color: var(--gray-900); + margin: 0 0 8px 0; +} +.bs-text { + font-size: var(--font-size-base); + color: var(--gray-800); + line-height: 1.7; + margin-bottom: 16px; +} +.bs-text strong { color: var(--gray-900); } +.bs-text a, +.bs-wizard label a { + text-decoration: none; + pointer-events: auto; + position: relative; + z-index: 1; +} + +/* ══════════════════════════════════════ + ACTIONS BAR + ══════════════════════════════════════ */ +.bs-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + padding: 16px 28px 20px; + border-top: 1px solid var(--gray-200); +} +.bs-actions-left, +.bs-actions-right { + display: flex; + gap: 8px; +} +.bs-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 38px; + padding: 8px 18px; + font-size: var(--font-size-sm); + font-weight: 600; + font-family: var(--font-primary); + border: 1px solid var(--gray-300); + background: #ffffff; + color: var(--gray-800); + cursor: pointer; + transition: background 150ms ease, transform 150ms ease; + white-space: nowrap; +} +.bs-btn:hover { + background: var(--gray-100); + transform: translateY(-1px); +} +.bs-btn-primary { + background: var(--bs-primary); + border-color: var(--bs-primary); + color: #ffffff; +} +.bs-btn-primary:hover { + background: var(--bs-primary-hover); + border-color: var(--bs-primary-hover); +} +.bs-btn-locked { + opacity: 0.4; + pointer-events: none; + cursor: not-allowed; +} +.bs-btn-check-disabled { + opacity: 0.4; + pointer-events: none; +} +.bs-btn-success { + background: var(--bs-green); + border-color: var(--bs-green); + color: #ffffff; +} +.bs-btn-success:hover { + background: var(--bs-green); + border-color: var(--bs-green); + transform: none; +} +.bs-btn-success.bs-btn-check-disabled { + opacity: 1; +} +.bs-btn-failed { + background: var(--bs-red); + border-color: var(--bs-red); + color: #ffffff; +} +.bs-btn-failed.bs-btn-check-disabled { + opacity: 1; +} +.bs-quiz-btn-group { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-top: 20px; +} + +/* ══════════════════════════════════════ + INFO BOX / TIP BOX / WARNING BOX + ══════════════════════════════════════ */ +.bs-infobox { + padding: 16px 20px; + margin: 16px 0; + border-left: 4px solid var(--bs-primary); + background: var(--bs-primary-light); + font-size: var(--font-size-sm); + color: var(--gray-800); + line-height: 1.6; +} +.bs-infobox-title { + font-weight: 600; + margin-bottom: 6px; + color: var(--bs-primary); +} +.bs-warnbox { + padding: 16px 20px; + margin: 16px 0; + border-left: 4px solid var(--bs-red); + background: var(--bs-red-light); + font-size: var(--font-size-sm); + color: var(--gray-800); + line-height: 1.6; +} +.bs-warnbox-title { + font-weight: 600; + margin-bottom: 6px; + color: var(--bs-red); +} +.bs-successbox { + padding: 16px 20px; + margin: 16px 0; + border-left: 4px solid var(--bs-green); + background: var(--bs-green-light); + font-size: var(--font-size-sm); + color: var(--gray-800); + line-height: 1.6; +} + +/* ══════════════════════════════════════ + LIST STYLES + ══════════════════════════════════════ */ +.bs-list { + list-style: none; + padding: 0; + margin: 12px 0; +} +.bs-list li { + padding: 8px 0 8px 28px; + position: relative; + font-size: var(--font-size-sm); + color: var(--gray-800); + line-height: 1.6; + border-bottom: 1px solid var(--gray-100); +} +.bs-list li:last-child { border-bottom: none; } +.bs-list li::before { + content: ''; + position: absolute; + left: 6px; + top: 14px; + width: 8px; + height: 8px; + background: var(--bs-primary); +} +.bs-list-numbered { + list-style: none; + padding: 0; + margin: 12px 0; + counter-reset: bs-counter; +} +.bs-list-numbered li { + padding: 10px 0 10px 40px; + position: relative; + font-size: var(--font-size-sm); + color: var(--gray-800); + line-height: 1.6; + border-bottom: 1px solid var(--gray-100); + counter-increment: bs-counter; +} +.bs-list-numbered li:last-child { border-bottom: none; } +.bs-list-numbered li::before { + content: counter(bs-counter); + position: absolute; + left: 0; + top: 8px; + width: 26px; + height: 26px; + background: var(--bs-primary); + color: #ffffff; + font-size: 0.75rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; +} + +/* ══════════════════════════════════════ + QUIZ / INTERACTIVE ELEMENTS + ══════════════════════════════════════ */ +.bs-quiz { + margin: 20px 0; + padding: 20px; + background: var(--gray-50); + border: 1px solid var(--gray-200); +} +.bs-quiz-question { + font-size: var(--font-size-base); + font-weight: 600; + color: var(--gray-900); + margin-bottom: 14px; + line-height: 1.5; +} +.bs-quiz-options { display: flex; flex-direction: column; gap: 8px; } +.bs-quiz-option { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: #ffffff; + border: 2px solid var(--gray-200); + cursor: pointer; + transition: all 150ms; + font-size: var(--font-size-sm); + color: var(--gray-800); +} +.bs-quiz-option:hover { border-color: var(--bs-primary); background: var(--bs-primary-light); } +.bs-quiz-locked .bs-quiz-option { pointer-events: none; } +.bs-quiz-option.selected { border-color: var(--bs-primary); background: var(--bs-primary-light); } +.bs-quiz-option.correct { border-color: var(--bs-green); background: var(--bs-green-light); } +.bs-quiz-option.wrong { border-color: var(--bs-red); background: var(--bs-red-light); } +.bs-quiz-option input[type="checkbox"] { display: none; } +.bs-quiz-indicator { + width: 20px; + height: 20px; + border: 2px solid var(--gray-400); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + color: transparent; +} +.bs-quiz-option.selected .bs-quiz-indicator { + border-color: var(--bs-primary); + background: var(--bs-primary); + color: #ffffff; +} +.bs-quiz-option.correct .bs-quiz-indicator { + border-color: var(--bs-green); + background: var(--bs-green); + color: #ffffff; +} +.bs-quiz-option.wrong .bs-quiz-indicator { + border-color: var(--bs-red); + background: var(--bs-red); + color: #ffffff; +} +.bs-quiz-feedback, +.bs-feedback { + margin-top: 12px; + padding: 10px 14px; + font-size: var(--font-size-sm); + font-weight: 600; + display: none; +} +.bs-quiz-feedback.show, +.bs-feedback.show { display: block; } +.bs-quiz-feedback.correct, +.bs-feedback.correct { + background: var(--bs-green-light); + color: var(--bs-green); + border-left: 4px solid var(--bs-green); +} +.bs-quiz-feedback.wrong, +.bs-feedback.wrong { + background: var(--bs-red-light); + color: var(--bs-red); + border-left: 4px solid var(--bs-red); +} +/* ══════════════════════════════════════ + DROPDOWN MATCHING + ══════════════════════════════════════ */ +.bs-dropdown-match { margin: 16px 0; } +.bs-dropdown-match-row { + display: flex; + align-items: center; + gap: 16px; + padding: 12px 16px; + background: #ffffff; + border: 2px solid var(--gray-200); + margin-bottom: 8px; + transition: border-color 150ms; +} +.bs-dropdown-match-row.correct { border-color: var(--bs-green); background: var(--bs-green-light); } +.bs-dropdown-match-row.wrong { border-color: var(--bs-red); background: var(--bs-red-light); } +.bs-dropdown-match-label { flex: 1; font-weight: 600; color: var(--gray-800); } +.bs-dropdown-match-select { + padding: 8px 12px; + border: 2px solid var(--gray-200); + font-size: var(--font-size-sm); + color: var(--gray-800); + background: #fff; + min-width: 200px; +} +.bs-dropdown-match-select:focus { border-color: var(--bs-primary); outline: none; } + +/* ══════════════════════════════════════ + SCENARIO CARD + ══════════════════════════════════════ */ +.bs-scenario { + margin: 20px 0; + border: 1px solid var(--gray-200); + overflow: hidden; +} +.bs-scenario-header { + padding: 14px 20px; + background: linear-gradient(135deg, var(--bs-primary), var(--bs-primary-hover)); + color: #ffffff; + font-weight: 600; + font-size: var(--font-size-sm); +} +.bs-scenario-body { + padding: 20px; +} +.bs-scenario-text { + font-size: var(--font-size-sm); + color: var(--gray-800); + line-height: 1.6; + margin-bottom: 16px; + font-style: italic; +} + +/* ══════════════════════════════════════ + CHECKLIST + ══════════════════════════════════════ */ +.bs-checklist { list-style: none; padding: 0; margin: 16px 0; } +.bs-checklist li { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 0; + border-bottom: 1px solid var(--gray-100); + font-size: var(--font-size-sm); + color: var(--gray-800); + cursor: pointer; +} +.bs-checklist li:last-child { border-bottom: none; } +.bs-check-box { + width: 22px; + height: 22px; + border: 2px solid var(--gray-300); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8rem; + color: transparent; + transition: all 150ms; + margin-top: 1px; +} +.bs-checklist li.checked .bs-check-box { + border-color: var(--bs-green); + background: var(--bs-green); + color: #ffffff; +} +.bs-checklist li.checked { color: var(--gray-500); } + +/* ══════════════════════════════════════ + FILL-IN-THE-BLANK + ══════════════════════════════════════ */ +.bs-fill-input { + display: inline-block; + border: none; + border-bottom: 2px solid var(--bs-primary); + background: transparent; + font-family: var(--font-primary); + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--gray-900); + padding: 2px 6px; + width: 120px; + outline: none; + transition: border-color 200ms; +} +.bs-fill-input:focus { border-color: var(--bs-primary-hover); } +.bs-fill-input.correct { border-color: var(--bs-green); color: var(--bs-green); } +.bs-fill-input.wrong { border-color: var(--bs-red); color: var(--bs-red); } + +/* ══════════════════════════════════════ + ACCORDION / EXPANDABLE + ══════════════════════════════════════ */ +.bs-accordion { + margin: 16px 0; + border: 1px solid var(--gray-200); +} +.bs-accordion-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + background: var(--gray-50); + cursor: pointer; + font-weight: 600; + font-size: var(--font-size-sm); + color: var(--gray-800); + transition: background 150ms; +} +.bs-accordion-header:hover { background: var(--gray-100); } +.bs-accordion-arrow { + transition: transform 250ms; + font-size: 0.8rem; + color: var(--gray-500); +} +.bs-accordion.open .bs-accordion-arrow { transform: rotate(180deg); } +.bs-accordion-content { + display: none; + padding: 16px 18px; + font-size: var(--font-size-sm); + color: var(--gray-700); + line-height: 1.6; + border-top: 1px solid var(--gray-200); +} +.bs-accordion.open .bs-accordion-content { display: block; } + +/* ══════════════════════════════════════ + INTRO HERO / 5 SÄULEN ERKLÄRUNG + ══════════════════════════════════════ */ + .bs-intro-hero { + margin: 16px 0 24px; + background: linear-gradient(180deg, #fff9f0 0%, #ffffff 100%); + border: 1px solid #fde0b0; + box-shadow: 0 8px 24px rgba(0,0,0,0.06); + overflow: hidden; +} + + +.bs-intro-card { + margin: 18px 18px 18px; + padding: 18px 20px 20px; + background: #ffffff; + border-top: 4px solid var(--bs-primary); + box-shadow: 0 -6px 18px rgba(0,0,0,0.05); + text-align: left; +} +.bs-intro-lead { + margin: 0 0 16px 0; + font-size: var(--font-size-base); + line-height: 1.7; + color: var(--gray-800); +} +.bs-intro-lead strong { color: var(--bs-primary-hover); } +.bs-intro-pillars { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 8px; +} +.bs-intro-pillar { + padding: 12px 10px; + background: var(--bs-primary-light); + border: 1px solid #fde0b0; + text-align: center; + transition: transform 150ms ease, box-shadow 150ms ease; +} +.bs-intro-pillar:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(240,130,0,0.16); +} +.bs-intro-pillar-number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + margin-bottom: 8px; + background: var(--bs-primary); + color: #ffffff; + font-size: 0.75rem; + font-weight: 700; +} +.bs-intro-pillar-title { + display: block; + font-size: var(--font-size-xs); + font-weight: 700; + color: var(--gray-900); + line-height: 1.35; +} + +/* ══════════════════════════════════════ + SCORE / PROGRESS PAGE + ══════════════════════════════════════ */ +.bs-score-bar-wrap { + margin: 24px 0; +} +.bs-score-bar { + width: 100%; + height: 24px; + background: var(--gray-200); + overflow: hidden; +} +.bs-score-bar-fill { + height: 100%; + transition: width 600ms ease; +} +.bs-score-bar-label { + margin-top: 8px; + font-size: var(--font-size-sm); + color: var(--gray-600); + font-weight: 500; + text-align: center; +} +.bs-score-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 12px; + margin: 20px 0; +} +.bs-score-card { + padding: 20px 16px; + text-align: center; + background: var(--gray-50); + border: 1px solid var(--gray-200); +} +.bs-score-card-num { + font-size: 1.8rem; + font-weight: 700; + color: var(--gray-800); + margin-bottom: 4px; +} +.bs-score-card-label { + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--gray-500); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.bs-score-correct .bs-score-card-num { color: var(--bs-green); } +.bs-score-wrong .bs-score-card-num { color: var(--bs-red); } +.bs-score-open .bs-score-card-num { color: var(--bs-primary); } + +/* ══════════════════════════════════════ + RESPONSIVE + ══════════════════════════════════════ */ +@media (max-width: 600px) { + .bs-wizard { margin: 0; width: 100%; } + .bs-progress { padding: 14px 8px 10px; } + .bs-progress-step[data-section="0"], + .bs-progress-step[data-section="0"] + .bs-progress-line { + display: none; + } + .bs-progress-label { font-size: 0.6rem; max-width: 48px; } + .bs-progress-circle { width: 26px; height: 26px; font-size: 0.7rem; } + .bs-progress-line { min-width: 8px; max-width: 20px; margin-bottom: 16px; } + .bs-body { padding: 16px; } + .bs-actions { padding: 12px 16px 16px; flex-wrap: wrap; } + .bs-dropdown-match-row { flex-direction: column; align-items: flex-start; gap: 8px; } + .bs-dropdown-match-select { min-width: unset; width: 100%; } + .bs-chapter-title { font-size: 1.3rem; } + .bs-intro-card { margin: 10px 10px 10px; padding: 14px; } + .bs-intro-pillars { grid-template-columns: 1fr; } + .bs-intro-pillar { display: flex; align-items: center; gap: 10px; text-align: left; } + .bs-intro-pillar-number { margin-bottom: 0; flex-shrink: 0; } + .bs-score-grid { grid-template-columns: repeat(2, 1fr); } +} diff --git a/web/intranet_repo/projects/_central/layouts/contentpanel.css b/web/intranet_repo/projects/_central/layouts/contentpanel.css new file mode 100644 index 0000000..b1b2090 --- /dev/null +++ b/web/intranet_repo/projects/_central/layouts/contentpanel.css @@ -0,0 +1,321 @@ +/* Schwarzes Brett / Contentpanel */ +.sb-page { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + margin-top: calc(var(--header-height) + var(--header-title-height)); + width: 100%; + min-height: calc(100vh - var(--header-height) - var(--header-title-height) - var(--footer-height)); + padding: var(--space-lg) var(--space-md) var(--space-3xl); + background: linear-gradient(135deg, var(--gray-50) 0%, hsl(330, 60%, 95%) 100%); +} + +.sb-page > .content-tab-bar { + margin: calc(-1 * var(--space-lg)) calc(-1 * var(--space-md)) 0; + width: calc(100% + 2 * var(--space-md)); +} + +.sb-tab-content { + display: none; + width: 100%; + max-width: 960px; + margin: var(--space-lg) auto 0; + animation: sbFadeIn 300ms ease; +} + +.sb-tab-content.active { + display: block; +} + +@keyframes sbFadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.sb-card { + margin-bottom: var(--space-lg); + padding: 24px 28px; + background: #ffffff; + box-shadow: var(--shadow-md); +} + +.sb-card-title { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 var(--space-md); + color: var(--gray-800); + font-size: 1rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.sb-card-title svg { + width: 20px; + height: 20px; + flex-shrink: 0; + fill: var(--nak-raspberry); +} + +.sb-entry-list { + display: flex; + flex-direction: column; + max-height: 360px; + margin-bottom: var(--space-lg); + overflow-y: auto; + border: 1px solid var(--gray-200); +} + +.sb-entry { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--gray-100); +} + +.sb-entry:last-child { + border-bottom: none; +} + +.sb-entry-info { + flex: 1; + min-width: 0; +} + +.sb-entry-date { + color: var(--nak-raspberry); + font-size: var(--font-size-xs); + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.sb-entry-title { + margin-top: 2px; + color: var(--gray-800); + font-size: var(--font-size-sm); + font-weight: 600; +} + +.sb-entry-subtitle, +.sb-entry-meta { + margin-top: 2px; + color: var(--gray-500); + font-size: var(--font-size-xs); +} + +.sb-entry-subtitle { + font-style: italic; +} + +.sb-empty { + padding: 16px; + color: var(--gray-500); + font-size: var(--font-size-sm); + text-align: center; +} + +.sb-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-md); +} + +.sb-form-grid-single { + grid-template-columns: 1fr; +} + +.sb-form-full { + grid-column: 1 / -1; +} + +.sb-field-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +.sb-field-group-action { + margin-top: var(--space-sm); +} + +.sb-label { + color: var(--gray-700); + font-size: var(--font-size-sm); + font-weight: 500; +} + +.sb-required { + color: var(--nak-red); +} + +.sb-optional { + color: var(--gray-500); + font-weight: 300; +} + +.sb-field { + box-sizing: border-box; + width: 100%; + padding: 10px 14px; + border: 1px solid var(--gray-300); + outline: none; + background: var(--gray-50); + font-family: var(--font-primary); + font-size: var(--font-size-base); + font-weight: 300; + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +.sb-field:focus { + border-color: var(--nak-raspberry); + background: #ffffff; + box-shadow: 0 0 0 3px rgba(215, 50, 130, 0.15); +} + +.sb-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 40px; + padding: 10px 20px; + border: 1px solid var(--gray-300); + background: #ffffff; + color: var(--gray-700); + cursor: pointer; + font-family: var(--font-primary); + font-size: var(--font-size-sm); + font-weight: 600; + white-space: nowrap; + transition: background 150ms ease, transform 150ms ease, box-shadow 150ms ease; +} + +.sb-btn:hover { + background: var(--gray-100); + box-shadow: var(--shadow-sm); + transform: translateY(-1px); +} + +.sb-btn-primary { + border-color: var(--nak-raspberry); + background: linear-gradient(135deg, var(--nak-raspberry), #b8296e); + color: #ffffff; +} + +.sb-btn-primary:hover { + border-color: #b8296e; + background: linear-gradient(135deg, #b8296e, #9c2260); +} + +.sb-btn-danger { + min-height: 30px; + padding: 6px 12px; + border: 1px solid var(--gray-300); + background: none; + color: var(--nak-red); + font-size: var(--font-size-xs); +} + +.sb-btn-danger:hover { + border-color: var(--nak-red); + background: hsl(0, 78%, 96%); +} + +.sb-alert { + margin: var(--space-md) 0; + padding: 10px 16px; + border: 1px solid hsl(145, 50%, 85%); + background: hsl(145, 50%, 93%); + color: hsl(145, 50%, 28%); + font-size: var(--font-size-sm); + font-weight: 500; +} + +.sb-alert-error { + border-color: hsl(0, 70%, 86%); + background: hsl(0, 78%, 96%); + color: hsl(0, 65%, 38%); +} + +.sb-aushang-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: var(--space-md); + margin-bottom: var(--space-lg); +} + +.sb-aushang-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 12px; + border: 1px solid var(--gray-200); + background: var(--gray-50); + transition: box-shadow 200ms ease, transform 200ms ease; +} + +.sb-aushang-card:hover { + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.sb-aushang-card img { + width: 100%; + height: 180px; + object-fit: cover; + border: 1px solid var(--gray-200); +} + +.sb-aushang-title { + color: var(--gray-800); + font-size: var(--font-size-sm); + font-weight: 600; + text-align: center; +} + +.sb-aushang-meta { + color: var(--gray-500); + font-size: var(--font-size-xs); + text-align: center; +} + +.sb-aushang-delete-form { + width: 100%; + text-align: center; +} + +.sb-spinner { + display: none; + width: 24px; + height: 24px; + margin: 0 auto; + border: 3px solid var(--gray-200); + border-top-color: var(--nak-raspberry); + border-radius: 50%; + animation: sbSpin 0.8s linear infinite; +} + +.sb-uploading .sb-upload-button { + display: none; +} + +.sb-uploading .sb-spinner { + display: block; +} + +@keyframes sbSpin { + to { transform: rotate(360deg); } +} + +@media (max-width: 768px) { + .sb-page { padding: var(--space-md) var(--space-sm); } + .sb-card { padding: 16px; } + .sb-form-grid { grid-template-columns: 1fr; } + .sb-aushang-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } +} diff --git a/web/intranet_repo/projects/_central/layouts/kalli.css b/web/intranet_repo/projects/_central/layouts/kalli.css new file mode 100644 index 0000000..2d58c92 --- /dev/null +++ b/web/intranet_repo/projects/_central/layouts/kalli.css @@ -0,0 +1,337 @@ +/* Kalli – Eintragungsassistent */ +.kalli-wizard { + width: 90%; + max-width: 1400px; + margin: var(--space-xl) auto; + overflow: hidden; + background: #fff; + box-shadow: var(--shadow-md); +} + +.kalli-progress { + display: flex; + align-items: center; + justify-content: center; + gap: 0; + padding: 20px 28px 16px; + background: var(--gray-50); + border-bottom: 1px solid var(--gray-200); +} + +.kalli-progress-step { + display: flex; + flex-shrink: 0; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.kalli-progress-circle { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + color: var(--gray-500); + font-size: var(--font-size-sm); + font-weight: 600; + background: var(--gray-200); + border: 2px solid var(--gray-300); + transition: all 250ms ease; +} + +.kalli-progress-step.active .kalli-progress-circle { + color: #fff; + background: var(--nak-blue); + border-color: var(--nak-blue); + box-shadow: 0 2px 8px rgba(39, 134, 207, 0.35); +} + +.kalli-progress-step.done .kalli-progress-circle { + color: var(--nak-blue); + background: #fff; + border-color: var(--nak-blue); +} + +.kalli-progress-label { + color: var(--gray-500); + font-size: var(--font-size-xs); + font-weight: 500; + white-space: nowrap; + transition: color 250ms ease; +} + +.kalli-progress-step.active .kalli-progress-label, +.kalli-progress-step.done .kalli-progress-label { + color: var(--nak-blue); +} + +.kalli-progress-line { + flex: 1; + min-width: 20px; + max-width: 60px; + height: 2px; + margin: 0 6px 20px; + background: var(--gray-300); + transition: background 250ms ease; +} + +.kalli-progress-line.done { background: var(--nak-blue); } +.kalli-step[hidden] { display: none !important; } + +.kalli-modal-body { padding: 22px 28px 16px; } + +.kalli-section-title { + margin: 0 0 16px; + color: var(--gray-600); + font-size: var(--font-size-sm); + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.kalli-category-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: var(--space-lg); +} + +.kalli-category { + display: flex; + flex-direction: column; + gap: 2px; +} + +.kalli-category-label { + margin-bottom: 4px; + color: var(--gray-700); + font-size: var(--font-size-sm); + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.kalli-category-label--custom { color: var(--nak-red); } + +label.kalli-radio-item { + display: flex; + align-items: center; + gap: 8px; + padding: 0 5px; + color: var(--gray-600); + font-size: var(--font-size-sm); + font-weight: 300; + letter-spacing: normal; + text-transform: none; + cursor: pointer; + border: 1px solid transparent; + transition: background 150ms ease, border-color 150ms ease; +} + +label.kalli-radio-item:hover { + background: var(--gray-50); + border-color: var(--gray-200); +} + +label.kalli-radio-item input[type="radio"] { + flex-shrink: 0; + align-self: center; + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--nak-blue); +} + +label.kalli-radio-item span { + overflow-wrap: break-word; + line-height: 1.4; + word-break: break-word; + hyphens: auto; +} + +.kalli-custom-section { + margin-top: var(--space-lg); + padding: var(--space-md); + background: hsl(0, 60%, 98%); + border: 1px solid hsl(0, 60%, 90%); +} + +.kalli-custom-hint { + margin: 0 0 var(--space-sm); + color: var(--gray-600); + font-size: var(--font-size-sm); +} + +.kalli-custom-options { + display: flex; + flex-wrap: wrap; + gap: var(--space-lg); + margin-bottom: var(--space-sm); +} + +.kalli-field-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-md); +} + +.kalli-field-grid-single { grid-template-columns: 1fr; } + +.kalli-field-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +.kalli-label { + color: var(--gray-700); + font-size: var(--font-size-sm); + font-weight: 500; +} + +.kalli-label-note { + color: var(--gray-500); + font-weight: 300; +} + +.kalli-required { color: var(--nak-red); } + +.kalli-field { + box-sizing: border-box; + width: 100%; + padding: 12px 14px; + color: inherit; + font-family: var(--font-primary); + font-size: var(--font-size-base); + font-weight: 300; + background: var(--gray-50); + border: 1px solid var(--gray-300); + outline: none; + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +.kalli-field:focus { + background: #fff; + border-color: var(--nak-blue); + box-shadow: 0 0 0 3px rgba(39, 134, 207, 0.2); +} + +.kalli-summary { + display: flex; + flex-direction: column; + border: 1px solid var(--gray-200); +} + +.kalli-summary-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--gray-100); +} + +.kalli-summary-row:last-child { border-bottom: 0; } + +.kalli-summary-label { + color: var(--gray-600); + font-size: var(--font-size-sm); + font-weight: 500; +} + +.kalli-summary-value { + color: var(--gray-900); + font-size: var(--font-size-sm); + font-weight: 500; + text-align: right; +} + +.kalli-validation-error { + margin: var(--space-md) 28px 0; + padding: 10px 14px; + color: hsl(0, 78%, 40%); + font-size: var(--font-size-sm); + font-weight: 600; + background: hsl(0, 78%, 95%); +} + +.kalli-validation-error--step { margin: var(--space-md) 0 0; } + +.kalli-modal-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 16px 28px 20px; + border-top: 1px solid var(--gray-200); +} + +.kalli-modal-actions-left, +.kalli-modal-actions-right { + display: flex; + gap: 8px; +} + +.kalli-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 38px; + padding: 8px 18px; + color: var(--gray-800); + font-family: var(--font-primary); + font-size: var(--font-size-sm); + font-weight: 600; + white-space: nowrap; + cursor: pointer; + background: #fff; + border: 1px solid var(--gray-300); + transition: background 150ms ease, transform 150ms ease; +} + +.kalli-btn:hover { + background: var(--gray-100); + transform: translateY(-1px); +} + +.kalli-btn:disabled { + opacity: 0.65; + cursor: wait; + transform: none; +} + +.kalli-btn-primary { + color: #fff; + background: var(--nak-blue); + border-color: var(--nak-blue); +} + +.kalli-btn-primary:hover { + background: var(--nak-blue-hover); + border-color: var(--nak-blue-hover); +} + +.kalli-btn-submit { + padding: 10px 24px; + color: #fff; + font-weight: 700; + background: linear-gradient(135deg, var(--nak-blue), var(--nak-blue-hover)); + border-color: var(--nak-blue-hover); +} + +.kalli-btn-submit:hover { + background: linear-gradient(135deg, var(--nak-blue-hover), #185d8f); +} + +@media (max-width: 600px) { + .kalli-wizard { width: 100%; margin: 0; } + .kalli-progress { padding: 14px 12px 10px; } + .kalli-progress-label { font-size: 0.65rem; } + .kalli-progress-circle { width: 26px; height: 26px; font-size: 0.7rem; } + .kalli-progress-line { min-width: 12px; margin-bottom: 16px; } + .kalli-modal-body { padding: 16px; } + .kalli-category-grid, + .kalli-field-grid { grid-template-columns: 1fr; } + .kalli-validation-error { margin-right: 16px; margin-left: 16px; } + .kalli-validation-error--step { margin-right: 0; margin-left: 0; } + .kalli-modal-actions { flex-wrap: wrap; padding: 12px 16px 16px; } +} diff --git a/web/intranet_repo/projects/_central/layouts/seelsorge.css b/web/intranet_repo/projects/_central/layouts/seelsorge.css index 6907749..9be647f 100644 --- a/web/intranet_repo/projects/_central/layouts/seelsorge.css +++ b/web/intranet_repo/projects/_central/layouts/seelsorge.css @@ -1,6 +1,6 @@ /* Name: seelsorge.css Beschreibung: Spezielles CSS für Seelsorge Planer */ - + #seelsorge.menue { position: relative; top: 0px; @@ -24,7 +24,7 @@ width: 100%; } -#seelsorge.menue .menu-item:link { color: hsl(210, 60%, 50%);} +#seelsorge.menue .menu-item:link { color: hsl(210, 60%, 50%);} #seelsorge.menue .menu-item:hover { color: #FFFFFF; opacity: 1.0; transition: top 0.6s ease-in-out; @@ -52,7 +52,7 @@ font-weight: 100; cursor: pointer; opacity: 0.9; - + transition: background-color 0.5s ease; -webkit-transition: background-color 0.5s ease; /*box-shadow: 0 1px 1px 0 rgba(255, 255, 255, 0.3), @@ -81,7 +81,7 @@ border-radius: 0; } .content .active { /* bezieht sich damit auf .content_seelsorge*/ - display: flex; + display: flex; } /* Besuchsstellen */ @@ -102,7 +102,7 @@ color: white; } .reservation-button { - + background-color: hsl(210, 57%, 50%)!important; color: white; } @@ -138,7 +138,7 @@ div.content_output { overflow: visible; width: calc(100% - 31px); background-color: hsl( 60, 33%, 95%); - border: 1px solid #DDDDDD; + border: 1px solid #DDDDDD; border-radius: 0; } div.content_output ul { @@ -176,7 +176,7 @@ ul.galery { padding: 0px; margin: 0px; width:auto; - list-style: none; + list-style: none; } li.galery { position: relative; @@ -204,7 +204,7 @@ ul.galery .available { } ul.galery .notavailable { box-shadow: 0 0 2px 1px hsl(0,0%,50%); -} +} img.galery_image { padding: 0px; margin: 0px; @@ -221,11 +221,11 @@ img.galery_image { justify-content: center; align-items: center; align-self: center; - + padding: 0px 5px 0px 5px; margin: 0px; height: 17px; - text-align: center; + text-align: center; font-size: 0.78em; font-weight: 500; color: hsl(297, 40%, 45%); @@ -256,7 +256,7 @@ img.galery_image { margin: 5px; width: 250px; text-align: center; - + background-color: #FFFFFF; border: 5px solid hsl(60, 33%, 80%); border-radius: 10px; @@ -273,7 +273,7 @@ img.galery_image { flex: 50%; padding: 30px; margin: 5px; - + border: 0px solid #DDDDDD; background-color: hsl(60, 33%, 80%); border-radius: 10px; @@ -286,10 +286,10 @@ img.galery_image { padding-bottom: 2px; margin: 0px; width: auto; - + border-spacing: 0px; border-bottom: 1px solid #AAAAAA; - + } */ /* .profile_calendar { -ms-flex: 50%; @@ -424,16 +424,16 @@ img.galery_image { width: 100%; overflow: hidden; font-size: 1.0em; - font-weight: 600; + font-weight: 600; border-spacing: 0; border: none; - border-radius: 0; + border-radius: 0; } .calendar_content table { padding: 0px; margin: 0px 0px 0px 0px; width: 100%; - height: auto; + height: auto; table-layout: fixed; border-spacing: 5px; border: 0px solid #DDDDDD; @@ -482,7 +482,7 @@ td.calendar_day_area:hover { td.calendar_day_area a { text-decoration: none; } -#calendar_day_item:hover, #calendar_day_item *:hover { +.calendar_day_item:hover, .calendar_day_item *:hover { filter: brightness(0.82); box-shadow: 0 4px 12px rgba(0,0,0,0.2); } @@ -491,27 +491,27 @@ td.calendar_day_area a { display: flex; justify-content: center; align-items: center; - align-content: center; + align-content: center; width: auto; - height: 100%; + height: 100%; aspect-ratio: 1/1; vertical-align: middle; border-radius: 0; box-shadow: 0 2px 8px rgba(0,0,0,0.12); transition: background 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; } - .available_single { + .calendar_day_item.available_single { color: hsl(210, 20%, 15%); background-color: #ffdc0f; } - .available_multiple { + .calendar_day_item.available_multiple { color: #FFFFFF; background-color: #006e14; } - .normalday { + .calendar_day_item.normalday { background-color: hsl(297, 10%, 98%); } - .today { + .calendar_day_item.today { color: #FFFFFF; background-color: #1243a0; box-shadow: 0 4px 16px rgba(18,67,160,0.35); @@ -521,32 +521,32 @@ td.calendar_day_area a { background-color: hsl(195, 80%, 60%); box-shadow: 1px 1px 2px 1px rgba(0, 0, 0, 0.3); } */ - .saturday { + .calendar_day_item.saturday { color: #FFFFFF; background-color: hsl(297, 25%, 45%); } - .sunday, .localholiday { + .calendar_day_item.sunday, .calendar_day_item.localholiday { color: #FFFFFF; background-color: hsl(297, 80%, 40%); } - .past { + .calendar_day_item.past { color: hsl(0, 0%, 55%); background-color: hsl(0, 0%, 90%); } - + .editmode { border-radius: 0 !important; box-shadow: 0 4px 16px rgba(0,0,0,0.2) !important; - } + } .calendar_day { display: flex; flex-wrap: wrap; justify-content: center; - align-content: center; + align-content: center; width: auto; height: 43px; - border: 0px solid #000; + border: 0px solid #000; } .feiertagsname { position: absolute; @@ -557,7 +557,7 @@ td.calendar_day_area a { padding: 0px 1px; /*yphens: auto; */ /* Textumbruch erstmal nicht*/ font-size: 0.55em; - text-align: center; + text-align: center; width: auto; color: hsl(297, 100%, 20%); border: 1px solid hsl(297, 30%, 90%); @@ -593,13 +593,13 @@ td.greenbox { justify-content: space-evenly; padding: 0; margin: 0; - + } #seelsorge.menue .menu-item { padding: 5px 2vw 5px 2vw; margin: 0.125vh 1vw 0.0125vh 1vw; max-width: 24vw; - font-size: 80%; + font-size: 80%; } .content_seelsorge { padding: 0px; @@ -636,7 +636,7 @@ td.greenbox { padding: 0px; margin: 5px; width:auto; - list-style: none; + list-style: none; } } @media (max-width: 400px) { @@ -647,11 +647,11 @@ td.greenbox { padding: 5px 5px 5px 5px; margin: 0px 5px 0px 5px; max-width: 24vw; - font-size: 77%; + font-size: 77%; } .page_calendar { width: calc(100vw - 10px - 2px); - + max-width: 400px; transform: scale(0.98); overflow-x: hidden; @@ -661,7 +661,7 @@ td.greenbox { } .calendar_day_item { width: auto; - height: 90%; + height: 90%; aspect-ratio: 1/1; vertical-align: middle; border-radius: 0; @@ -685,10 +685,10 @@ td.greenbox { } @media (max-width: 475px) { .page_calendar { - + max-height: 475px; max-width: 475px; - + transform: scale(0.6); } } */ @@ -1002,3 +1002,571 @@ td.greenbox { font-size: 0.85em; } } + +/* Besuchsstellen: Karten, Detail- und Bearbeitungsmodals */ + /* --- Shell --- */ + .visit-shell { + padding: 18px; + width: 100%; + background: linear-gradient(135deg, var(--gray-50) 0%, var(--theme-light, var(--nak-blue-light)) 100%); + min-height: 100%; + } + .visit-card { + overflow: hidden; + border: 1px solid rgba(120, 140, 160, 0.22); + border-radius: 0; + background: rgba(255,255,255,0.86); + box-shadow: 0 20px 45px rgba(31, 38, 46, 0.10), 0 1px 0 rgba(255,255,255,0.9) inset; + } + .visit-list-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 18px 12px 18px; + border-bottom: 1px solid rgba(120, 140, 160, 0.16); + background: linear-gradient(180deg, rgba(255,255,255,0.92), rgba(255,255,255,0.72)); + } + .visit-list-title { + font-size: 1.05em; + font-weight: 800; + color: hsl(210, 20%, 15%); + } + .visit-list-actions { + display: flex; + gap: 8px; + align-items: center; + } + .visit-action-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border: 1px solid hsl(210, 18%, 84%); + border-radius: 0; + background: #FFFFFF; + color: hsl(210, 20%, 22%); + font-size: 0.86em; + font-weight: 850; + cursor: pointer; + box-shadow: 0 4px 12px rgba(31,38,46,0.06); + text-decoration: none; + transition: background 0.18s ease; + } + .visit-action-btn:hover { background: hsl(210, 57%, 96%); } + .visit-action-btn-primary { + color: #FFFFFF; + background: linear-gradient(135deg, var(--nak-purple), #5c1865); + border-color: #5c1865; + } + .visit-action-btn-primary:hover { background: linear-gradient(135deg, #5c1865, #4a1352); } + + /* --- Table (admin-style) --- */ + table.visit-table { + display: table !important; + width: calc(100% - 24px); + margin: 8px 12px 14px 12px; + border-collapse: separate; + border-spacing: 0 8px; + padding: 0; + background: transparent; + overflow: visible; + } + .visit-table:after { display: none; } + .visit-table thead th { + position: sticky; + top: 0; + padding: 10px 14px; + color: hsl(210, 16%, 46%); + font-size: 0.78em; + font-weight: 800; + text-align: left; + letter-spacing: 0.04em; + text-transform: uppercase; + background: transparent !important; + border: none !important; + box-shadow: none; + } + .visit-table thead th::after { display: none; } + .visit-table tbody tr.visit-row { + position: relative; + cursor: pointer; + background: rgba(255, 255, 255, 0.92); + border-radius: 0; + box-shadow: 0 6px 18px rgba(31, 38, 46, 0.06); + transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; + } + .visit-table tbody tr.visit-row:hover, + .visit-table tbody tr.visit-row:focus { + transform: translateY(-1px); + background: #FFFFFF; + box-shadow: 0 12px 26px rgba(31, 38, 46, 0.13); + outline: none !important; + filter: none !important; + } + .visit-table td { + padding: 12px 14px !important; + vertical-align: middle; + border: none !important; + background: transparent !important; + } + .visit-table tbody tr.visit-row td:first-child { + position: static !important; + width: 18px; + min-width: 18px; + max-width: 18px; + padding-right: 0 !important; + border-radius: 0; + } + .visit-table tbody tr.visit-row td:last-child { + border-radius: 0; + } + + /* Status dot (first column) */ + .visit-status-dot { + display: block; + width: 14px; + height: 14px; + border-radius: 50%; + box-shadow: 0 2px 6px rgba(0,0,0,0.15); + } + .visit-status-dot.is-blue { background: var(--nak-blue); } + .visit-status-dot.is-red { background: var(--nak-red); } + .visit-status-dot.is-orange { background: var(--nak-orange); } + .visit-status-dot.is-green { background: var(--nak-darkgreen); } + + /* Status badge (pill below name) */ + .visit-status-badge { + display: inline-flex; + align-items: center; + padding: 2px 7px; + border-radius: 0; + font-size: 0.72em; + font-weight: 800; + } + .visit-status-badge.is-blue { + color: hsl(210, 57%, 30%); + background: hsl(210, 57%, 92%); + } + .visit-status-badge.is-red { + color: hsl(0, 60%, 36%); + background: hsl(0, 65%, 93%); + } + .visit-status-badge.is-orange { + color: hsl(30, 70%, 30%); + background: hsl(35, 80%, 92%); + } + .visit-status-badge.is-green { + color: hsl(145, 50%, 28%); + background: hsl(145, 50%, 92%); + } + + /* Name column */ + .visit-name-text { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 1em; + font-weight: 800; + color: hsl(210, 20%, 15%); + } + + /* Info column */ + .visit-info-cell { + font-size: 0.9em; + color: hsl(210, 14%, 40%); + font-weight: 600; + } + .visit-info-cell .visit-planned-by { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 4px; + font-size: 0.88em; + color: hsl(210, 57%, 40%); + font-weight: 700; + } + .visit-info-cell .visit-planned-by img { + width: 24px; + height: 24px; + border-radius: 50%; + object-fit: cover; + } + + .visit-empty-row td { + padding: 24px !important; + text-align: center !important; + color: hsl(210, 20%, 45%); + background: #FFFFFF !important; + border-radius: 0; + } + + /* --- Detail Modal --- */ + .visit-detail-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.45); + z-index: 9999; + display: none; + align-items: center; + justify-content: center; + padding: 18px; + } + .visit-detail-card { + position: relative; + width: min(640px, calc(100vw - 24px)); + max-height: min(780px, calc(100vh - 24px)); + overflow: auto; + background: #FFFFFF; + border-radius: 0; + box-shadow: 0 24px 70px rgba(0,0,0,0.36); + } + .visit-detail-hero { + padding: 24px; + color: #FFFFFF; + background: radial-gradient(circle at 10% 0%, rgba(255,255,255,0.24), transparent 32%), + linear-gradient(135deg, var(--nak-purple), #5c1865); + border-radius: 0; + } + .visit-detail-hero-title { + font-size: 1.3em; + font-weight: 850; + margin-bottom: 6px; + } + .visit-detail-hero-sub { + color: rgba(255,255,255,0.8); + font-weight: 600; + } + .visit-detail-close { + position: absolute; + top: 14px; + right: 14px; + width: 34px; + height: 34px; + border-radius: 0; + border: 1px solid rgba(255,255,255,0.25); + background: rgba(255,255,255,0.16); + color: #FFFFFF; + font-size: 1.35em; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + } + .visit-detail-body { + padding: 22px 24px 24px 24px; + } + .visit-detail-section-title { + margin: 16px 0 8px 0; + font-size: 0.88em; + font-weight: 850; + text-transform: uppercase; + letter-spacing: 0.04em; + color: hsl(210, 18%, 36%); + } + .visit-detail-section-title:first-child { margin-top: 0; } + .visit-detail-member { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border: 1px solid hsl(210, 18%, 88%); + border-radius: 0; + background: hsl(210, 12%, 98%); + margin-bottom: 8px; + } + .visit-detail-member-info { + font-weight: 700; + color: hsl(210, 20%, 18%); + } + .visit-detail-member-info .age { + font-size: 0.88em; + color: hsl(210, 14%, 46%); + font-weight: 600; + margin-left: 8px; + } + .visit-detail-member-edit { + width: 32px; + height: 32px; + border-radius: 0; + border: 1px solid hsl(210, 18%, 84%); + background: #FFF; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.9em; + transition: background 0.18s ease; + } + .visit-detail-member-edit:hover { background: hsl(210, 57%, 96%); } + .visit-detail-info-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-bottom: 12px; + } + .visit-detail-info-item { + padding: 10px 12px; + border: 1px solid hsl(210, 18%, 90%); + border-radius: 0; + background: hsl(210, 12%, 98.5%); + } + .visit-detail-info-label { + font-size: 0.72em; + font-weight: 850; + text-transform: uppercase; + letter-spacing: 0.04em; + color: hsl(210, 14%, 46%); + margin-bottom: 2px; + } + .visit-detail-info-value { + font-weight: 750; + color: hsl(210, 20%, 18%); + font-size: 0.95em; + } + .visit-detail-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid hsl(210, 18%, 90%); + } + .visit-detail-actions .visit-action-btn { font-size: 0.88em; padding: 9px 16px; } + + /* --- Edit/Picker Modals (kept from original) --- */ + .visit-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.45); + z-index: 10000; + display: none; + align-items: center; + justify-content: center; + padding: 18px; + } + .visit-modal { + width: min(820px, 96vw); + max-height: 92vh; + overflow: auto; + background: #FFFFFF; + border-radius: 0; + box-shadow: 0 24px 70px rgba(0,0,0,0.36); + } + .visit-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid hsl(210, 18%, 90%); + background: linear-gradient(180deg, rgba(255,255,255,0.95), rgba(255,255,255,0.8)); + position: sticky; + top: 0; + z-index: 1; + border-radius: 0; + } + .visit-modal-header h2 { margin: 0; font-size: 1.15em; font-weight: 850; color: hsl(210, 20%, 15%); } + .visit-modal-close { + cursor: pointer; + border: 1px solid hsl(210, 18%, 84%); + background: #FFF; + border-radius: 0; + width: 34px; + height: 34px; + font-size: 1.3em; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + } + .visit-modal-close:hover { background: hsl(210, 12%, 95%); } + .visit-modal-body { padding: 20px 24px 24px 24px; } + .visit-picker-search { + width: 100%; + box-sizing: border-box; + padding: 12px 14px; + border-radius: 0; + border: 1px solid hsl(210, 18%, 84%); + background: hsl(210, 12%, 98%); + font-size: 1em; + font-weight: 650; + outline: none; + margin-bottom: 12px; + } + .visit-picker-search:focus { border-color: var(--nak-purple); background: #FFF; } + .visit-picker-list { display: flex; flex-direction: column; gap: 8px; } + .visit-picker-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 14px; + border-radius: 0; + border: 1px solid hsl(210, 18%, 88%); + background: #FFF; + cursor: pointer; + transition: background 0.18s ease, transform 0.18s ease; + } + .visit-picker-item:hover { background: hsl(210, 57%, 97%); transform: translateY(-1px); } + .visit-picker-name { font-weight: 800; color: hsl(210, 20%, 15%); } + .visit-picker-sub { font-size: 0.82em; color: hsl(210, 14%, 46%); margin-top: 2px; } + .visit-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + } + .visit-form-grid .full { grid-column: 1 / -1; } + .visit-form-field label { + display: block; + font-weight: 750; + margin-bottom: 6px; + font-size: 0.88em; + color: hsl(210, 18%, 36%); + text-transform: uppercase; + letter-spacing: 0.04em; + } + .visit-form-field input, + .visit-form-field select { + width: 100%; + box-sizing: border-box; + padding: 12px 14px; + border-radius: 0; + border: 1px solid hsl(210, 18%, 84%); + background: hsl(210, 12%, 98%); + font-size: 1em; + font-weight: 650; + outline: none; + } + .visit-form-field input:focus, + .visit-form-field select:focus { border-color: var(--nak-purple); background: #FFF; } + .visit-modal-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid hsl(210, 18%, 90%); + } + .visit-form-error { + margin-bottom: 14px; + padding: 10px 12px; + border: 1px solid hsl(0, 72%, 88%); + background: hsl(0, 72%, 97%); + color: hsl(0, 72%, 38%); + font-size: 0.9em; + font-weight: 700; + } + .visit-modal-actions button { cursor: pointer; border: none; border-radius: 0; padding: 10px 18px; font-weight: 850; font-size: 0.88em; } + .visit-btn-save { background: linear-gradient(135deg, hsl(145, 55%, 42%), hsl(145, 55%, 34%)); color: #fff; } + .visit-btn-cancel { background: hsl(210, 12%, 92%); color: hsl(210, 20%, 30%); } + .visit-btn-delete { background: hsl(0, 72%, 95%); color: hsl(0, 72%, 42%); border: 1px solid hsl(0, 72%, 88%); margin-right: auto; } + .visit-phone-row { display: flex; align-items: stretch; width: 100%; } + .visit-input-prefix { + display: flex; + align-items: center; + padding: 0 12px; + border: 1px solid hsl(210, 18%, 84%); + border-right: none; + border-radius: 0; + background: hsl(210, 12%, 95%); + font-weight: 800; + font-size: 0.9em; + white-space: nowrap; + color: hsl(210, 14%, 40%); + } + .visit-phone-row input.visit-phone-input { + width: 100%; + box-sizing: border-box; + padding: 12px 14px; + border: 1px solid hsl(210, 18%, 84%); + border-left: none; + border-radius: 0; + background: hsl(210, 12%, 98%); + font-size: 1em; + font-weight: 650; + outline: none; + } + + @media (max-width: 780px) { + .visit-shell { padding: 10px; } + .visit-list-header { + align-items: flex-start; + flex-direction: column; + padding: 14px; + gap: 8px; + } + .visit-list-actions { + width: 100%; + justify-content: space-between; + } + table.visit-table { + display: block !important; + width: auto; + margin: 8px; + padding: 0; + border-spacing: 0; + } + .visit-table thead { display: none; } + .visit-table tbody { + display: grid; + gap: 10px; + } + .visit-table tbody tr.visit-row { + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + gap: 0 12px; + padding: 14px; + border: 1px solid rgba(120, 140, 160, 0.16); + border-radius: 0; + box-shadow: 0 8px 22px rgba(31, 38, 46, 0.08); + } + .visit-table tbody tr.visit-row td { + display: block; + padding: 0 !important; + border-radius: 0 !important; + } + .visit-table tbody tr.visit-row td:first-child { + grid-row: 1 / span 2; + width: 18px; + min-width: 18px; + max-width: 18px; + display: flex; + align-items: center; + } + .visit-table tbody tr.visit-row td:nth-child(2) { + align-self: end; + min-width: 0; + } + .visit-table tbody tr.visit-row td:nth-child(3) { + align-self: start; + min-width: 0; + padding-top: 5px !important; + } + .visit-name-text { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .visit-empty-row { + display: block !important; + } + .visit-empty-row td { + display: block; + } + .visit-detail-info-grid { grid-template-columns: 1fr; } + .visit-detail-card { width: calc(100vw - 12px); max-height: calc(100dvh - 12px); } + } + + @media (max-width: 480px) { + .visit-shell { padding: 8px; } + .visit-card { border-radius: 0; } + .visit-list-title { font-size: 1em; } + .visit-table tbody tr.visit-row { + gap: 0 10px; + padding: 10px; + } + .visit-name-text { font-size: 0.96em; } + .visit-info-cell { font-size: 0.85em; } + .visit-action-btn { font-size: 0.78em; padding: 6px 10px; } + } diff --git a/web/intranet_repo/projects/_central/scripts/admin_brandschutz.js b/web/intranet_repo/projects/_central/scripts/admin_brandschutz.js new file mode 100644 index 0000000..a14d40c --- /dev/null +++ b/web/intranet_repo/projects/_central/scripts/admin_brandschutz.js @@ -0,0 +1,15 @@ +function openBsModal(userId) { + var modal = document.getElementById('bsModal-' + userId); + if (modal) modal.style.display = 'flex'; +} +function closeBsModal(userId) { + var modal = document.getElementById('bsModal-' + userId); + if (modal) modal.style.display = 'none'; +} +document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { + document.querySelectorAll('.standard_overlay[id^="bsModal-"]').forEach(function(m) { + m.style.display = 'none'; + }); + } +}); diff --git a/web/intranet_repo/projects/_central/scripts/authorization.js b/web/intranet_repo/projects/_central/scripts/authorization.js new file mode 100644 index 0000000..759231f --- /dev/null +++ b/web/intranet_repo/projects/_central/scripts/authorization.js @@ -0,0 +1,72 @@ +'use strict'; + +document.addEventListener('DOMContentLoaded', function () { + document.querySelectorAll('[data-auth-toast]').forEach(function (toast) { + requestAnimationFrame(function () { + toast.classList.add('nak-toast--visible'); + }); + }); + + var otpRequestForm = document.getElementById('otpRequestForm'); + if (otpRequestForm) { + otpRequestForm.addEventListener('submit', function () { + var button = document.getElementById('requestBtn'); + if (button) { + button.classList.add('is-loading'); + button.disabled = true; + } + }); + } + + var signupForm = document.getElementById('signup'); + if (!signupForm || typeof window.JustValidate !== 'function') { + return; + } + + var validation = new window.JustValidate('#signup'); + validation + .addField('#first_name', [ + { rule: 'required', errorMessage: 'Pflichtfeld' }, + { rule: 'maxLength', value: 100, errorMessage: 'Zu lang' } + ]) + .addField('#last_name', [ + { rule: 'required', errorMessage: 'Pflichtfeld' }, + { rule: 'maxLength', value: 100, errorMessage: 'Zu lang' } + ]) + .addField('#email', [ + { rule: 'required', errorMessage: 'Pflichtfeld' }, + { rule: 'email', errorMessage: 'Ungültig' }, + { rule: 'maxLength', value: 254, errorMessage: 'Zu lang' } + ]) + .addField('#phone', [ + { rule: 'required', errorMessage: 'Pflichtfeld' }, + { + validator: function (value) { + var digits = String(value).replace(/\D+/g, ''); + return /^[1-9][0-9]{5,14}$/.test(digits); + }, + errorMessage: 'Ohne führende 0, 6–15 Ziffern' + } + ]) + .addField('#password', [ + { rule: 'required', errorMessage: 'Pflichtfeld' }, + { + validator: function (value) { + return String(value).length >= 8 && /[a-z]/i.test(value) && /[0-9]/.test(value); + }, + errorMessage: 'Mind. 8 Zeichen, Buchstabe und Zahl' + } + ]) + .addField('#password_confirmation', [ + { rule: 'required', errorMessage: 'Pflichtfeld' }, + { + validator: function (value, fields) { + return value === fields['#password'].elem.value; + }, + errorMessage: 'Passwörter müssen übereinstimmen' + } + ]) + .onSuccess(function () { + signupForm.submit(); + }); +}); diff --git a/web/intranet_repo/projects/_central/scripts/brandschutz.js b/web/intranet_repo/projects/_central/scripts/brandschutz.js new file mode 100644 index 0000000..d26343d --- /dev/null +++ b/web/intranet_repo/projects/_central/scripts/brandschutz.js @@ -0,0 +1,601 @@ +/* ══════════════════════════════════════ + NAVIGATION + ══════════════════════════════════════ */ +var bsCurrentSection = 0; +var bsTotalSections = 6; // 0=intro, 1-5=pillars +var bsCurrentSub = {}; // track sub-steps per section + +// Init sub-step tracking +for (var i = 1; i <= 5; i++) bsCurrentSub[i] = 1; + +function bsGoToSection(section) { + var scoreEl = document.getElementById('bsSectionScore'); + if (scoreEl) scoreEl.style.display = 'none'; + + // Hide all sections + for (var i = 0; i < bsTotalSections; i++) { + var el = document.getElementById('bsSection' + i); + if (el) el.style.display = 'none'; + } + // Show target section + var target = document.getElementById('bsSection' + section); + if (target) target.style.display = ''; + + bsCurrentSection = section; + bsUpdateMainProgress(section); + + // Reset to first sub-step when entering a section + if (section >= 1 && section <= 5) { + bsCurrentSub[section] = 1; + bsShowSubStep(section, 1); + } + + bsScrollToTop(); +} + +function bsUpdateMainProgress(section) { + var steps = document.querySelectorAll('#bsMainProgress .bs-progress-step'); + var lines = document.querySelectorAll('#bsMainProgress .bs-progress-line'); + steps.forEach(function(el, idx) { + el.classList.remove('active', 'done'); + if (idx === section) el.classList.add('active'); + else if (idx < section) el.classList.add('done'); + }); + lines.forEach(function(el, idx) { + el.classList.toggle('done', idx < section); + }); +} + +/* ── Sub-step navigation ── */ +function bsSubStep(section, step) { + bsCurrentSub[section] = step; + bsShowSubStep(section, step); + bsScrollToTop(); +} + +function bsShowSubStep(section, step) { + // Count sub-steps + var maxSub = document.querySelectorAll('#bsSection' + section + ' .bs-substep').length; + for (var i = 1; i <= maxSub; i++) { + var el = document.getElementById('bsSub' + section + '_' + i); + if (el) el.style.display = (i === step) ? '' : 'none'; + } +} + +function bsScrollToTop() { + window.scrollTo({top: 0, behavior: 'smooth'}); + var headerHeight = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 64; + var elemHeaderMenue = document.getElementById('header_menue'); + var elemHeaderTitle = document.getElementById('header_title'); + if (elemHeaderMenue) elemHeaderMenue.style.top = '0'; + if (elemHeaderTitle) elemHeaderTitle.style.top = headerHeight + 'px'; +} + +/* ── Dropdown Matching ── */ +function bsCheckDropdownMatch() { + var container = document.getElementById('match3'); + var rows = container.querySelectorAll('.bs-dropdown-match-row'); + var correct = 0; + var total = rows.length; + rows.forEach(function(row) { + var select = row.querySelector('select'); + row.classList.remove('correct', 'wrong'); + if (select.value === select.dataset.correct) { + row.classList.add('correct'); + correct++; + } else { + row.classList.add('wrong'); + } + }); + var fb = document.getElementById('match3_fb'); + if (correct === total) { + fb.className = 'bs-quiz-feedback show correct'; + fb.textContent = 'Richtig: Alle Zuordnungen korrekt! Gut gemacht!'; + } else { + fb.className = 'bs-quiz-feedback show wrong'; + fb.textContent = correct + ' von ' + total + ' richtig. Bitte überprüfen Sie Ihre Auswahl.'; + } + return correct === total; +} + +/* ══════════════════════════════════════ + FILL-IN-THE-BLANK + ══════════════════════════════════════ */ +function bsCheckFill(ids) { + var correct = 0; + var total = ids.length; + + ids.forEach(function(id) { + var input = document.getElementById(id); + var answer = input.dataset.answer.toLowerCase().trim(); + var value = input.value.toLowerCase().trim(); + + input.classList.remove('correct', 'wrong'); + if (value === answer) { + input.classList.add('correct'); + correct++; + } else { + input.classList.add('wrong'); + } + }); + + // Find feedback element near the button + var fb = document.getElementById('fill2_fb'); + if (fb) { + fb.classList.remove('correct', 'wrong'); + fb.classList.add('show'); + if (correct === total) { + fb.classList.add('correct'); + fb.textContent = 'Richtig: Alle Lücken richtig ausgefüllt!'; + } else { + fb.classList.add('wrong'); + fb.textContent = correct + ' von ' + total + ' richtig. Korrekte Antworten: 112, Klixstraße 17, 13403'; + } + } + return correct === total; +} + +/* ══════════════════════════════════════ + MULTI-SELECT CHECKBOX QUIZ + ══════════════════════════════════════ */ +function bsToggleCheckbox(optionEl) { + var cb = optionEl.querySelector('input[type="checkbox"]'); + cb.checked = !cb.checked; + optionEl.classList.toggle('selected', cb.checked); +} + +function bsCheckMultiGeneric(quizId, correctMsg, wrongMsg) { + var quiz = document.getElementById(quizId); + var options = quiz.querySelectorAll('.bs-quiz-option'); + var allCorrect = true; + + options.forEach(function(opt) { + var cb = opt.querySelector('input[type="checkbox"]'); + var isCorrect = cb.dataset.correct === 'true'; + opt.classList.remove('correct', 'wrong'); + + if (isCorrect && cb.checked) { + opt.classList.add('correct'); + } else if (isCorrect && !cb.checked) { + opt.classList.add('correct'); + allCorrect = false; + } else if (!isCorrect && cb.checked) { + opt.classList.add('wrong'); + allCorrect = false; + } + }); + + var fb = document.getElementById(quizId + '_fb'); + fb.classList.remove('correct', 'wrong'); + fb.classList.add('show'); + if (allCorrect) { + fb.classList.add('correct'); + fb.textContent = correctMsg; + } else { + fb.classList.add('wrong'); + fb.textContent = wrongMsg; + } + return allCorrect; +} + +/* ══════════════════════════════════════ + INDIVIDUAL QUIZ GATES + ══════════════════════════════════════ */ +var bsQuizPassed = {}; +var bsQuizAttempts = {}; +var bsQuizCorrect = {}; // true = correct, false = nach drei Versuchen beendet + +var bsQuizGates = [ + { section: 1, nextBtnId: 'bsNextS1Q', quizIds: ['quiz1_1', 'quiz1_2', 'quiz1_3'] }, + { section: 2, nextBtnId: 'bsNextS2Q', quizIds: ['scenario2_1', 'fill2'] }, + { section: 3, nextBtnId: 'bsNextS3Q', quizIds: ['match3', 'quiz3_1', 'quiz3_2'] }, + { section: 4, nextBtnId: 'bsNextS4Q', quizIds: ['tf4', 'scenario4_1'] }, + { section: 5, nextBtnId: 'bsNextS5P1', quizIds: ['bs5q1', 'bs5q2', 'bs5q3', 'bs5q4', 'bs5q5'] }, + { section: 5, nextBtnId: 'bsNextS5P2', quizIds: ['bs5q6', 'bs5q7', 'bs5q8', 'bs5q9', 'bs5q10'] } +]; + +var bsQuizMessages = { + scenario2_1: [ + 'Richtig! Nicht durch das verrauchte Treppenhaus gehen und keinen Löschversuch im Rauch unternehmen. Richtig ist: 112 anrufen, warnen, in einem rauchfreien Raum bleiben und sich am Fenster bemerkbar machen.', + 'Falsch. Gesucht sind die Verhaltensweisen, die man nicht tun sollte: durch das verrauchte Treppenhaus laufen oder im verrauchten Bereich selbst löschen.' + ], + tf4: [ + 'Richtig: Alle Antworten richtig! Ausgezeichnet!', + 'Nicht ganz richtig. Die korrekten Antworten sind markiert.' + ], + scenario4_1: [ + 'Richtig! Bei Elektrobränden niemals Wasser verwenden, Stromschlaggefahr! Der CO₂-Löscher ist die richtige Wahl.', + 'Falsch. Bei Elektrobränden darf kein Wasser verwendet werden (Stromschlaggefahr). Ein CO₂-Löscher ist hier das richtige Löschmittel.' + ], + bs5q1: [ + 'Richtig! Die Feuerlöscher befinden sich im Vorraum am seitlichen Ausgang, im Treppenhaus 1. OG und im hinteren Kirchenanbau neben der Toilette.', + 'Falsch: Nicht ganz. Die korrekten Standorte sind: Vorraum am seitlichen Ausgang, Treppenhaus 1. OG und hinterer Kirchenanbau neben der Toilette.' + ], + bs5q2: [ + 'Richtig! Alle drei Ausgänge stehen zur Verfügung: Haupteingang, hinterer Kirchenanbau und seitlicher Eingang. Achte auf die Stufen und den Gegenverkehr von der Empore!', + 'Falsch: Nicht ganz. Die korrekten Rettungswege sind: Haupteingang (Stufen!), hinterer Kirchenanbau (Stufen!) und seitlicher Eingang (Emporenverkehr!). Fenster sind keine Rettungswege.' + ], + bs5q3: [ + 'Richtig! Wir haben keine Brandmeldeanlage. Deshalb ist es besonders wichtig, dass alle Anwesenden wachsam sind und die Abläufe kennen.', + 'Falsch. Wir haben keine Brandmeldeanlage, daher müssen wir uns umso mehr auf geschultes Verhalten und aufmerksame Personen verlassen.' + ], + bs5q4: [ + 'Richtig! Die Adresse für den Notruf findest du innen an der rechten Tür des Haupteingangs. Merke dir diesen Ort!', + 'Falsch. Die Adresse befindet sich innen an der rechten Tür des Haupteingangs (Fluchttür), nicht auf dem Altar oder im Treppenhaus.' + ], + bs5q5: [ + 'Richtig! Unsere Kirchengemeinde fasst 180 bis 200 Personen.', + 'Falsch. Unsere Kirchengemeinde fasst 180 bis 200 Personen, nicht mehr.' + ], + bs5q6: [ + 'Richtig! Die Gefahrenstellen sind: Stufen zum Anbau, Stufen am Haupteingang und das Treppenhaus an der Empore (Gegenverkehr).', + 'Falsch: Nicht ganz. Die Gefahrenstellen sind: Stufen zwischen Kirchenschiff und Anbau, Stufen vor dem Haupteingang und das Treppenhaus an der Empore. Die Rampe ist keine Gefahrenstelle.' + ], + bs5q7: [ + 'Richtig! Die zuständige vBsU koordiniert, eine klare Zuständigkeit verhindert Chaos und widersprüchliche Anweisungen.', + 'Falsch. Nur die zuständige vBsU koordiniert die Aktionen. Das verhindert widersprüchliche Anweisungen und sorgt für eine geordnete Evakuierung.' + ], + bs5q8: [ + 'Richtig! Notleuchten befinden sich an sechs Standorten: Sakristei, unterm Altar, seitliche Klappen, Empore, Eltern-Kind-Raum und Vorraum.', + 'Falsch: Nicht ganz. Die Notleuchten befinden sich an: Sakristei, unterm Altar, letzte seitliche Klappen im Kirchenschiff, Empore (Tisch), Eltern-Kind-Raum (Schrank unter TV) und „Diakonie-Schrank" im Vorraum.' + ], + bs5q9: [ + 'Richtig! Der vorbereitete Text wird vom Pult aus verlesen, so erreicht die Information alle Anwesenden gleichzeitig und klar verständlich.', + 'Falsch. Die Räumung wird eingeleitet, indem der vorbereitete Text „zur Räumung des Gebäudes" vom Pult aus verlesen wird (rotes, laminiertes DIN-A4-Blatt). So wird sichergestellt, dass alle die gleiche, klare Ansage hören.' + ], + bs5q10: [ + 'Richtig! Anwesende Ersthelfer*innen übernehmen die weitere Erste Hilfe. Die vBsU koordiniert die Evakuierung, nicht die medizinische Versorgung.', + 'Falsch. Für die weitere Erste Hilfe sind die anwesenden Ersthelfer*innen zuständig, nicht die vBsU. Für weitergehende Erste Hilfe ist eine gesonderte Schulung notwendig. Die erste Hilfeleistung ist natürlich von jedem erwünscht und gesetzlich vorgeschrieben.' + ] +}; + +function bsGetQuizGate(quizId) { + return bsQuizGates.find(function(gate) { + return gate.quizIds.includes(quizId); + }) || null; +} + + +function bsGetAllQuizIds() { + return bsQuizGates.reduce(function(ids, gate) { return ids.concat(gate.quizIds); }, []); +} + +function bsRenderQuizResult(quizId) { + if (quizId === 'fill2') { + return bsCheckFill(['fill2_1', 'fill2_2', 'fill2_3']); + } + if (quizId === 'match3') { + return bsCheckDropdownMatch(); + } + + var messages = bsQuizMessages[quizId] || ['Richtig!', 'Falsch: Leider falsch. Die richtige Antwort ist markiert.']; + return bsCheckMultiGeneric(quizId, messages[0], messages[1]); +} + +function bsGetScoreStats() { + var allIds = bsGetAllQuizIds(); + var total = allIds.length; + var correct = 0; + var wrong = 0; + var open = 0; + allIds.forEach(function(id) { + if (bsQuizCorrect[id] === true) correct++; + else if (bsQuizCorrect[id] === false) wrong++; + else open++; + }); + var percent = total > 0 ? Math.round((correct / total) * 100) : 0; + return { correct: correct, wrong: wrong, open: open, total: total, percent: percent }; +} + +function bsShowSectionScore(completedSection, nextAction) { + var stats = bsGetScoreStats(); + var answered = stats.correct + stats.wrong; + var maxReachable = stats.total > 0 ? 100 - Math.round((stats.wrong / stats.total) * 100) : 100; + + var scoreEl = document.getElementById('bsSectionScore'); + var body = scoreEl.querySelector('.bs-body'); + + var barColor = maxReachable >= 70 ? 'var(--bs-green)' : 'var(--bs-red)'; + + body.innerHTML = + '
Zwischenstand nach Säule ' + completedSection + '
' + + '
' + + '
' + + '
Max. erreichbar: ' + maxReachable + '% (mindestens 70% erforderlich)
' + + '
' + + '
' + + '
' + stats.correct + '
Richtig
' + + '
' + stats.wrong + '
Falsch
' + + '
' + stats.open + '
Offen
' + + '
' + answered + '/' + stats.total + '
Gesamt
' + + '
'; + + var actionsRight = scoreEl.querySelector('.bs-actions-right'); + actionsRight.innerHTML = ''; + + if (typeof nextAction === 'function') { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'bs-btn bs-btn-primary'; + btn.textContent = completedSection < 5 ? 'Weiter zu Säule ' + (completedSection + 1) + ' >' : 'Zum Ergebnis >'; + btn.onclick = nextAction; + actionsRight.appendChild(btn); + } + + var actionsLeft = scoreEl.querySelector('.bs-actions-left'); + actionsLeft.innerHTML = ''; + + for (var i = 0; i < 6; i++) { + var el = document.getElementById('bsSection' + i); + if (el) el.style.display = 'none'; + } + scoreEl.style.display = ''; + bsScrollToTop(); +} + +function bsFinalResultAction() { + document.getElementById('bsSectionScore').style.display = 'none'; + bsShowFinalResult(); + document.getElementById('bsSection5').style.display = ''; + bsCurrentSub[5] = 5; + bsShowSubStep(5, 5); + bsScrollToTop(); +} + +function bsShowFinalResult() { + var stats = bsGetScoreStats(); + var passed = stats.percent >= 70; + + var section = document.getElementById('bsSub5_5'); + var body = section.querySelector('.bs-body'); + + var barColor = passed ? 'var(--bs-green)' : 'var(--bs-red)'; + + var html = '
' + (passed ? 'Schulung bestanden!' : 'Schulung nicht bestanden') + '
'; + + html += '
' + + '
' + + '
' + stats.percent + '% richtig (' + stats.correct + ' von ' + stats.total + ' Fragen)
' + + '
'; + + html += '
' + + '
' + stats.correct + '
Richtig
' + + '
' + stats.wrong + '
Falsch
' + + '
'; + + if (passed) { + html += '
' + + 'Herzlichen Glückwunsch! Du hast die Brandschutzunterweisung erfolgreich absolviert. ' + + 'Du kennst jetzt die Grundlagen der Brandverhütung, weißt, wie du dich im Brandfall verhältst, ' + + 'kennst die Aufgaben der vBsU, kannst einen Feuerlöscher richtig einsetzen und weißt, wo sich die wichtigsten ' + + 'Einrichtungen in unserer Kirchengemeinde befinden.' + + '
'; + html += '
' + + '' + + '
'; + html += ''; + } else { + html += '
' + + '
Leider nicht bestanden
' + + 'Du hast ' + stats.percent + '% der Fragen richtig beantwortet. Zum Bestehen sind mindestens 70% erforderlich. ' + + 'Bitte wiederhole die Schulung, um alle Inhalte zu verinnerlichen.' + + '
'; + html += ''; + } + + body.innerHTML = html; +} + +function bsSetQuizButtonState(quizId, state) { + var btn = document.getElementById('bsCheck_' + quizId); + if (!btn) return; + btn.classList.remove('bs-btn-success', 'bs-btn-failed', 'bs-btn-check-disabled'); + if (state === 'passed') { + btn.textContent = 'Richtig'; + btn.classList.add('bs-btn-success', 'bs-btn-check-disabled'); + btn.onclick = null; + return; + } + if (state === 'failed') { + btn.textContent = 'Falsch'; + btn.classList.add('bs-btn-failed', 'bs-btn-check-disabled'); + btn.onclick = null; + return; + } + btn.textContent = 'Antwort prüfen'; + btn.onclick = function() { bsCheckIndividualQuiz(quizId); }; +} + +function bsUpdatePageGate(nextBtnId, quizIds) { + var nextBtn = document.getElementById(nextBtnId); + if (!nextBtn) return; + var allPassed = quizIds.every(function(id) { return !!bsQuizPassed[id]; }); + nextBtn.classList.toggle('bs-btn-locked', !allPassed); +} + +/* Validate quiz without marking options */ +function bsValidateQuiz(quizId) { + var quiz = document.getElementById(quizId); + if (!quiz) return false; + + // Checkbox-based quizzes + var cbOptions = quiz.querySelectorAll('.bs-quiz-option input[type="checkbox"]'); + if (cbOptions.length > 0) { + var ok = true; + cbOptions.forEach(function(cb) { + var isCorrect = cb.dataset.correct === 'true'; + if ((isCorrect && !cb.checked) || (!isCorrect && cb.checked)) ok = false; + }); + return ok; + } + + // Fill-in-the-blank + var fills = quiz.querySelectorAll('.bs-fill-input'); + if (fills.length > 0) { + var ok = true; + fills.forEach(function(input) { + if (input.value.toLowerCase().trim() !== input.dataset.answer.toLowerCase().trim()) ok = false; + }); + return ok; + } + + // Dropdown matching + var selects = quiz.querySelectorAll('.bs-dropdown-match-select'); + if (selects.length > 0) { + var ok = true; + selects.forEach(function(sel) { + if (sel.value !== sel.dataset.correct) ok = false; + }); + return ok; + } + + return false; +} + +function bsCheckIndividualQuiz(quizId) { + var gate = bsGetQuizGate(quizId); + if (!gate) return; + + if (!bsQuizAttempts[quizId]) bsQuizAttempts[quizId] = 0; + bsQuizAttempts[quizId]++; + var attempt = bsQuizAttempts[quizId]; + var passed = bsValidateQuiz(quizId); + + if (passed) { + // Correct — show markings and lock + bsRenderQuizResult(quizId); + bsQuizPassed[quizId] = true; + bsQuizCorrect[quizId] = true; + bsSetQuizButtonState(quizId, 'passed'); + document.getElementById(quizId).classList.add('bs-quiz-locked'); + } else if (attempt >= 3) { + // Third wrong attempt — show solution, lock as failed + bsRenderQuizResult(quizId); + bsQuizPassed[quizId] = true; // done (failed but completed) + bsQuizCorrect[quizId] = false; + bsSetQuizButtonState(quizId, 'failed'); + document.getElementById(quizId).classList.add('bs-quiz-locked'); + } else { + // Wrong but retries left — show hint, keep selections + var remaining = 3 - attempt; + var fb = document.getElementById(quizId + '_fb'); + if (fb) { + fb.classList.remove('correct', 'wrong'); + fb.classList.add('show', 'wrong'); + fb.textContent = 'Leider falsch. Du hast noch ' + remaining + (remaining === 1 ? ' Versuch.' : ' Versuche.'); + } + } + + bsUpdatePageGate(gate.nextBtnId, gate.quizIds); +} + + +function bsResetQuizContainer(containerId) { + var container = document.getElementById(containerId); + if (!container) return; + container.querySelectorAll('input[type="checkbox"]').forEach(function(input) { + input.checked = false; + }); + container.querySelectorAll('.bs-quiz-option').forEach(function(opt) { + opt.classList.remove('correct', 'wrong', 'selected'); + }); + container.querySelectorAll('select').forEach(function(select) { + select.selectedIndex = 0; + }); + container.querySelectorAll('.bs-dropdown-match-row').forEach(function(row) { + row.classList.remove('correct', 'wrong'); + }); + container.querySelectorAll('input[type="text"]').forEach(function(input) { + input.value = ''; + input.classList.remove('correct', 'wrong'); + }); + container.querySelectorAll('.bs-quiz-feedback, .bs-feedback').forEach(function(fb) { + fb.classList.remove('show', 'correct', 'wrong'); + fb.textContent = ''; + }); +} + +function bsUpdateChecklistNext(checklistId, buttonId) { + const checklist = document.getElementById(checklistId); + const button = document.getElementById(buttonId); + + if (!checklist || !button) return; + + const items = checklist.querySelectorAll('li'); + const checkedItems = checklist.querySelectorAll('li.checked'); + const allChecked = items.length > 0 && items.length === checkedItems.length; + + button.classList.toggle('bs-btn-locked', !allChecked); +} + +function bsRestartTraining() { + bsQuizPassed = {}; + bsQuizAttempts = {}; + bsQuizCorrect = {}; + + bsGetAllQuizIds().forEach(function(id) { + bsResetQuizContainer(id); + bsSetQuizButtonState(id, 'default'); + var quiz = document.getElementById(id); + if (quiz) quiz.classList.remove('bs-quiz-locked'); + }); + + ['bsNextS1Q', 'bsNextS2Q', 'bsNextS3Q', 'bsNextS4Q', 'bsNextS5P1', 'bsNextS5P2'].forEach(function(id) { + var btn = document.getElementById(id); + if (btn) btn.classList.add('bs-btn-locked'); + }); + + var checklist = document.getElementById('bsChecklist3_2'); + if (checklist) { + checklist.querySelectorAll('li.checked').forEach(function(li) { li.classList.remove('checked'); }); + var clBtn = document.getElementById('bsNextSub3_2'); + if (clBtn) clBtn.classList.add('bs-btn-locked'); + } + + bsGoToSection(0); +} + +/* ══════════════════════════════════════ + ABSCHLUSS, BESTÄTIGUNG + ══════════════════════════════════════ */ +function bsToggleConfirm() { + var checkbox = document.getElementById('bsConfirmCheckbox'); + var finishBtn = document.getElementById('bsFinishBtn'); + if (checkbox.checked) { + finishBtn.classList.remove('bs-btn-locked'); + } else { + finishBtn.classList.add('bs-btn-locked'); + } +} + +function bsFinishTraining() { + var checkbox = document.getElementById('bsConfirmCheckbox'); + var form = document.getElementById('bsCompleteForm'); + if (!checkbox || !checkbox.checked || !form) return; + form.submit(); +} + +/* ══════════════════════════════════════ + SHUFFLE QUIZ OPTIONS ON LOAD + ══════════════════════════════════════ */ +(function() { + var quizBlocks = document.querySelectorAll('.bs-quiz-options'); + quizBlocks.forEach(function(container) { + var options = Array.from(container.querySelectorAll(':scope > .bs-quiz-option')); + if (options.length < 2) return; + // Fisher-Yates shuffle + for (var i = options.length - 1; i > 0; i--) { + var j = Math.floor(Math.random() * (i + 1)); + var temp = options[i]; + options[i] = options[j]; + options[j] = temp; + } + options.forEach(function(opt) { container.appendChild(opt); }); + }); +})(); diff --git a/web/intranet_repo/projects/_central/scripts/contentpanel.js b/web/intranet_repo/projects/_central/scripts/contentpanel.js new file mode 100644 index 0000000..b4e15cd --- /dev/null +++ b/web/intranet_repo/projects/_central/scripts/contentpanel.js @@ -0,0 +1,47 @@ +(function () { + function switchTab(tabName, updateHash) { + const tab = document.querySelector('.content-tab-bar__item[data-tab="' + tabName + '"]'); + const content = document.getElementById('tab-' + tabName); + if (!tab || !content) return; + + document.querySelectorAll('.content-tab-bar__item').forEach(function (item) { + item.classList.toggle('active', item === tab); + }); + document.querySelectorAll('.sb-tab-content').forEach(function (item) { + item.classList.toggle('active', item === content); + }); + + if (updateHash) { + history.replaceState(null, '', '#' + tabName); + } + } + + document.addEventListener('DOMContentLoaded', function () { + document.querySelectorAll('.content-tab-bar__item[data-tab]').forEach(function (tab) { + tab.addEventListener('click', function () { + switchTab(tab.dataset.tab, true); + }); + }); + + const hash = window.location.hash.slice(1); + if (hash) { + switchTab(hash, false); + } + + document.querySelectorAll('[data-confirm]').forEach(function (button) { + button.addEventListener('click', function (event) { + if (!window.confirm(button.dataset.confirm || 'Wirklich fortfahren?')) { + event.preventDefault(); + } + }); + }); + + document.querySelectorAll('[data-upload-form]').forEach(function (form) { + form.addEventListener('submit', function () { + form.classList.add('sb-uploading'); + const button = form.querySelector('.sb-upload-button'); + if (button) button.disabled = true; + }); + }); + }); +})(); diff --git a/web/intranet_repo/projects/_central/scripts/intranet.js b/web/intranet_repo/projects/_central/scripts/intranet.js index 530a4a1..082daae 100644 --- a/web/intranet_repo/projects/_central/scripts/intranet.js +++ b/web/intranet_repo/projects/_central/scripts/intranet.js @@ -169,17 +169,13 @@ function showHideText(element) { // Per Taste Overlay verlassen document.addEventListener('keydown', function(event) { - const profileVisitOverlay = document.getElementById('profile_visit_overlay'); - const profileCalendarOverlay = document.getElementById('profile_calendar_overlay'); + const seelsorgeProfileOverlay = document.getElementById('profile_seelsorge_overlay'); const addNewUser = document.getElementById('addNewUser'); const showMessage = document.getElementById('showMessage'); if (event.key === 'Escape') { - if (profileVisitOverlay && profileVisitOverlay.style.display === 'flex') { - openCloseProfile(0, 'profile_visit', 'profile_visit_overlay'); - } - if (profileCalendarOverlay && profileCalendarOverlay.style.display === 'flex') { - openCloseProfile(0, 'profile_calendar', 'profile_calendar_overlay'); + if (seelsorgeProfileOverlay && seelsorgeProfileOverlay.style.display === 'flex') { + openCloseProfile(0, 'profile_seelsorge', 'profile_seelsorge_overlay'); } if (addNewUser && addNewUser.style.display === 'flex') { openCloseOverlay('addNewUser'); diff --git a/web/intranet_repo/projects/_central/scripts/kalli.js b/web/intranet_repo/projects/_central/scripts/kalli.js new file mode 100644 index 0000000..4c7a5f2 --- /dev/null +++ b/web/intranet_repo/projects/_central/scripts/kalli.js @@ -0,0 +1,173 @@ +(function () { + 'use strict'; + + var currentStep = 1; + + function byId(id) { + return document.getElementById(id); + } + + function selectedGroup(form) { + return form.querySelector('input[name="group"]:checked'); + } + + function showAlert(message) { + window.alert(message); + return false; + } + + function validateStepOne(form) { + var selected = selectedGroup(form); + if (!selected) { + return showAlert('Bitte wählen Sie eine Terminart aus.'); + } + + if (selected.dataset.kalliCustom === '1' && !byId('kalliUserInput').value.trim()) { + return showAlert('Bitte geben Sie einen Titel für den benutzerdefinierten Termin ein.'); + } + + return true; + } + + function validateStepTwo() { + var dateStart = byId('kalliDateS').value; + var dateEnd = byId('kalliDateE').value; + var timeStart = byId('kalliTimeS').value; + var timeEnd = byId('kalliTimeE').value; + + if (!dateStart) { + return showAlert('Bitte geben Sie ein Datum ein.'); + } + if (dateEnd && dateEnd < dateStart) { + return showAlert('Das Enddatum darf nicht vor dem Startdatum liegen.'); + } + if (timeStart && timeEnd && (!dateEnd || dateEnd === dateStart) && timeEnd < timeStart) { + return showAlert('Die Endzeit darf am selben Tag nicht vor der Startzeit liegen.'); + } + + return true; + } + + function updateProgress(step) { + document.querySelectorAll('.kalli-progress-step').forEach(function (element, index) { + var elementStep = index + 1; + element.classList.toggle('active', elementStep === step); + element.classList.toggle('done', elementStep < step); + }); + + document.querySelectorAll('.kalli-progress-line').forEach(function (element, index) { + element.classList.toggle('done', index < step - 1); + }); + } + + function showHeader() { + var rootStyle = getComputedStyle(document.documentElement); + var headerHeight = parseFloat(rootStyle.getPropertyValue('--header-height')) || 64; + var menu = byId('header_menue'); + var title = byId('header_title'); + + if (menu) menu.style.top = '0'; + if (title) title.style.top = headerHeight + 'px'; + } + + function goToStep(step) { + for (var index = 1; index <= 4; index++) { + byId('kalliStep' + index).hidden = index !== step; + } + currentStep = step; + updateProgress(step); + window.scrollTo({ top: 0, behavior: 'smooth' }); + showHeader(); + } + + function formatDate(isoDate) { + if (!isoDate) return '–'; + var parts = isoDate.split('-'); + return parts.length === 3 ? parts[2] + '.' + parts[1] + '.' + parts[0] : isoDate; + } + + function showSummaryRow(rowId, valueId, value, displayValue) { + var row = byId(rowId); + row.hidden = !value; + if (value) byId(valueId).textContent = displayValue; + } + + function buildSummary(form) { + var selected = selectedGroup(form); + var groupLabel = selected ? selected.value : '–'; + + if (selected && selected.dataset.kalliCustom === '1') { + var customTitle = byId('kalliUserInput').value.trim(); + var visibility = selected.value === 'oeffentlich' ? 'Öffentlich' : 'Intern'; + groupLabel = customTitle ? customTitle + ' (' + visibility + ')' : groupLabel; + } + + byId('sumGroup').textContent = groupLabel; + byId('sumDateS').textContent = formatDate(byId('kalliDateS').value); + showSummaryRow('sumDateERow', 'sumDateE', byId('kalliDateE').value, formatDate(byId('kalliDateE').value)); + showSummaryRow('sumTimeSRow', 'sumTimeS', byId('kalliTimeS').value, byId('kalliTimeS').value); + showSummaryRow('sumTimeERow', 'sumTimeE', byId('kalliTimeE').value, byId('kalliTimeE').value); + showSummaryRow('sumTextRow', 'sumText', byId('kalliFurtherText').value.trim(), byId('kalliFurtherText').value.trim()); + showSummaryRow('sumLocRow', 'sumLoc', byId('kalliLocation').value.trim(), byId('kalliLocation').value.trim()); + showSummaryRow('sumRespRow', 'sumResp', byId('kalliResponsibility').value.trim(), byId('kalliResponsibility').value.trim()); + } + + function validateForStep(form, nextStep) { + if (nextStep === 2) return validateStepOne(form); + if (nextStep === 3) return validateStepTwo(); + if (nextStep === 4) { + if (!validateStepOne(form) || !validateStepTwo()) return false; + buildSummary(form); + } + return true; + } + + function initialise() { + var form = byId('kalliForm'); + if (!form) return; + + form.querySelectorAll('[data-kalli-next]').forEach(function (button) { + button.addEventListener('click', function () { + var nextStep = Number(button.dataset.kalliNext); + if (validateForStep(form, nextStep)) goToStep(nextStep); + }); + }); + + form.querySelectorAll('[data-kalli-prev]').forEach(function (button) { + button.addEventListener('click', function () { + goToStep(Number(button.dataset.kalliPrev)); + }); + }); + + form.addEventListener('submit', function (event) { + if (currentStep !== 4) { + event.preventDefault(); + var nextStep = Math.min(currentStep + 1, 4); + if (validateForStep(form, nextStep)) goToStep(nextStep); + return; + } + + if (!validateStepOne(form)) { + event.preventDefault(); + goToStep(1); + return; + } + if (!validateStepTwo()) { + event.preventDefault(); + goToStep(2); + return; + } + + var submitButton = form.querySelector('button[type="submit"]'); + if (submitButton) submitButton.disabled = true; + }); + + updateProgress(1); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initialise); + } else { + initialise(); + } +}()); diff --git a/web/intranet_repo/projects/_central/scripts/seelsorge.js b/web/intranet_repo/projects/_central/scripts/seelsorge.js index 5544a1f..1e3b889 100644 --- a/web/intranet_repo/projects/_central/scripts/seelsorge.js +++ b/web/intranet_repo/projects/_central/scripts/seelsorge.js @@ -38,6 +38,11 @@ document.addEventListener('DOMContentLoaded', function() { // Zeige den entsprechenden Inhalt an var contentId = item.getAttribute('data-target'); document.getElementById(contentId).classList.add('active'); + + const url = new URL(window.location.href); + if (contentId === 'content_visit') url.searchParams.set('tab', 'visit'); + else url.searchParams.delete('tab'); + window.history.replaceState(null, '', url); }); }); @@ -68,130 +73,108 @@ function switchCalendar(type) { } -// Tages-Modal oeffnen -async function openDayModal(calendarDate) { - if (!calendarDate) return; +let _seelsorgeConfig = null; + +function getSeelsorgeConfig() { + if (_seelsorgeConfig) return _seelsorgeConfig; + const element = document.getElementById('seelsorge-config'); + _seelsorgeConfig = element ? JSON.parse(element.textContent) : { csrfToken: '' }; + return _seelsorgeConfig; +} + +async function loadCalendarDay(calendarDate, action = 'view') { + if (!calendarDate) return null; const modal = document.getElementById('seelsorge-day-modal'); const body = document.getElementById('modal-day-body'); const title = document.getElementById('modal-day-title'); const subtitle = document.getElementById('modal-day-subtitle'); + if (!modal || !body || !title || !subtitle) return null; - if (!modal || !body || !title || !subtitle) { - console.error('openDayModal: Modal-Elemente nicht gefunden.'); - return; + if (action === 'view') { + title.textContent = 'Laden...'; + subtitle.innerHTML = ''; } - - // Modal sofort anzeigen mit Ladezustand - title.textContent = 'Laden...'; - subtitle.innerHTML = ''; body.innerHTML = '
'; + body.dataset.calendarDate = calendarDate; modal.style.display = 'flex'; - try { - const response = await fetch('content/calendar_day.php', { + const url = 'content/calendar_day.php?date=' + encodeURIComponent(calendarDate); + const options = action === 'toggle' + ? { method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: 'calendarDate=' + encodeURIComponent(calendarDate) - }); - - const html = await response.text(); - - if (response.ok) { - body.innerHTML = html; - - // Metadaten aus dem eingebetteten JSON lesen - const metaEl = body.querySelector('#modal-meta'); - if (metaEl) { - const meta = JSON.parse(metaEl.textContent); - title.textContent = meta.title; - - let chips = 'KW ' + meta.kw + ''; - if (meta.feiertag) { - chips += '' + meta.feiertag + ''; - } - subtitle.innerHTML = chips; - metaEl.remove(); - } - } else { - // PHP-Fehler direkt im Modal anzeigen - title.textContent = 'Fehler ' + response.status; - body.innerHTML = '
' - + (html || 'Keine Antwort vom Server.') + '
'; + body: new URLSearchParams({ + date: calendarDate, + action: 'toggle', + csrf_token: getSeelsorgeConfig().csrfToken + }) } + : { method: 'GET' }; + + try { + const response = await fetch(url, options); + const html = await response.text(); + if (!response.ok) { + throw new Error(html || ('HTTP Fehler: ' + response.status)); + } + + body.innerHTML = html; + const metaEl = body.querySelector('#modal-meta'); + if (!metaEl) throw new Error('Tagesdaten sind unvollständig.'); + + const meta = JSON.parse(metaEl.textContent); + title.textContent = meta.title; + let chips = 'KW ' + meta.kw + ''; + if (meta.feiertag) { + chips += '' + escHtml(meta.feiertag) + ''; + } + subtitle.innerHTML = chips; + metaEl.remove(); + return meta; } catch (error) { - body.innerHTML = '
Fehler beim Laden der Daten: ' + error.message + '
'; + title.textContent = 'Fehler'; + body.innerHTML = '
' + escHtml(error.message || 'Fehler beim Laden der Daten.') + '
'; console.error('Fehler beim Laden der Tagesdetails:', error); + return null; } } +function openDayModal(calendarDate) { + return loadCalendarDay(calendarDate, 'view'); +} -// Modal schliessen function closeDayModal() { const modal = document.getElementById('seelsorge-day-modal'); if (modal) modal.style.display = 'none'; } -// Backdrop-Klick schliesst Modal function closeDayModalOnBackdrop(event) { - if (event.target === event.currentTarget) { - closeDayModal(); - } + if (event.target === event.currentTarget) closeDayModal(); } - -// Verfuegbarkeit im Modal togglen (Modal bleibt offen) async function toggleAvailabilityModal(date) { - const btn = document.getElementById('modal-toggle-btn'); - if (!btn) return; + const button = document.getElementById('modal-toggle-btn'); + if (button) button.disabled = true; - const originalText = btn.textContent; - btn.disabled = true; - btn.textContent = 'Wird gespeichert\u2026'; - - try { - const response = await fetch("toggle_availability.php", { - method: "POST", - headers: { "Content-type": "application/x-www-form-urlencoded" }, - body: "date=" + encodeURIComponent(date) - }); - - const data = await response.json(); - - if (data.ok) { - // Modal-Inhalt neu laden um aktuellen Stand zu zeigen - await openDayModal(date); - - // Kalender-Tageszelle visuell aktualisieren - updateCalendarCell(date, data.nowAvailable, data.availCount); - } else { - btn.textContent = originalText; - btn.disabled = false; - console.error("Fehler:", data.error); - } - } catch (error) { - btn.textContent = originalText; - btn.disabled = false; - console.error("Fehler beim Aktualisieren der Verfuegbarkeit:", error); - } + const meta = await loadCalendarDay(date, 'toggle'); + if (meta) updateCalendarCell(date, Number(meta.availCount) || 0); } // Kalender-Tageszelle visuell aktualisieren ohne Page-Reload -function updateCalendarCell(date, nowAvailable, availCount) { - var cells = document.querySelectorAll('#calendar_day_item'); +function updateCalendarCell(date, availCount) { + const cells = document.querySelectorAll(`[data-calendar-date="${date}"]`); cells.forEach(function(cell) { - var onclick = cell.getAttribute('onclick') || ''; - if (onclick.indexOf(date) === -1) return; - - // Alle Verfuegbarkeits-Klassen entfernen - cell.classList.remove('available_single', 'available_multiple', 'normalday'); + cell.classList.remove( + 'available_single', 'available_multiple', 'normalday', + 'today', 'sunday', 'localholiday', 'saturday' + ); if (availCount >= 2) { cell.classList.add('available_multiple'); } else if (availCount === 1) { cell.classList.add('available_single'); } else { - cell.classList.add('normalday'); + cell.classList.add(cell.dataset.emptyClass || 'normalday'); } }); } @@ -201,26 +184,16 @@ function updateCalendarCell(date, nowAvailable, availCount) { var _lastCalendarDate = null; function openCalendarProfile(userId) { - // Datum merken, damit wir beim Schliessen zurueckkehren koennen - var metaEl = document.getElementById('modal-meta'); - var bodyEl = document.getElementById('modal-day-body'); - if (!_lastCalendarDate && bodyEl) { - // Datum aus dem Toggle-Button extrahieren - var toggleBtn = bodyEl.querySelector('#modal-toggle-btn'); - if (toggleBtn) { - var onclickAttr = toggleBtn.getAttribute('onclick') || ''; - var match = onclickAttr.match(/'(\d{4}-\d{2}-\d{2})'/); - if (match) _lastCalendarDate = match[1]; - } - } + const body = document.getElementById('modal-day-body'); + _lastCalendarDate = body?.dataset.calendarDate || null; closeDayModal(); - openCloseProfile(userId, 'profile_calendar', 'profile_calendar_overlay'); + openCloseProfile(userId, 'profile_seelsorge', 'profile_seelsorge_overlay'); } // Globalen Listener fuer Kalender-Profil-Overlay: bei Schliessen Tagesmodal wieder oeffnen document.addEventListener('DOMContentLoaded', function() { // MutationObserver auf das Kalender-Profil-Overlay - var calOverlay = document.getElementById('profile_calendar_overlay'); + var calOverlay = document.getElementById('profile_seelsorge_overlay'); if (calOverlay) { var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { @@ -244,4 +217,297 @@ function openCalendarHelp() { function closeCalendarHelp() { var modal = document.getElementById('calendar-help-modal'); if (modal) modal.style.display = 'none'; -} \ No newline at end of file +} + +// Besuchsstellen ------------------------------------------------------------ +let _visitConfig = null; + +function getVisitConfig() { + if (_visitConfig) return _visitConfig; + const element = document.getElementById('visit-config'); + _visitConfig = element ? JSON.parse(element.textContent) : { entries: [], currentUserId: 0 }; + return _visitConfig; +} + +function openVisitProfile(userId) { + closeHouseholdDetail(); + openCloseProfile(userId, 'profile_seelsorge', 'profile_seelsorge_overlay'); +} + +// --- Detail Modal --- +function openHouseholdDetail(data) { + const overlay = document.getElementById('visitDetailOverlay'); + const title = document.getElementById('detailHeroTitle'); + const sub = document.getElementById('detailHeroSub'); + const body = document.getElementById('detailBody'); + + const members = data.members || []; + const lastName = members[0]?.last_name || ''; + const firstNames = members.map(m => m.first_name).join(' & '); + title.textContent = lastName + ', ' + firstNames; + sub.textContent = data.address_street + ' · ' + data.address_plz; + + let html = ''; + + // Members section + html += '
Haushaltsmitglieder
'; + members.forEach(m => { + html += `
+
+ ${escHtml(m.first_name)} ${escHtml(m.last_name)} + geb. ${escHtml(m.birth_formatted)} (${m.age} Jahre) +
+ +
`; + }); + + // Info grid + html += '
Details
'; + html += '
'; + html += ``; + + const phone = members[0]?.phone || data.phone || ''; + const phoneFormatted = phone ? ('0' + phone) : '—'; + html += `
Telefon
${phone ? ''+escHtml(phoneFormatted)+'' : '—'}
`; + + const priestName = members[0]?.priest_name || '—'; + const priestId = members[0]?.priest_id || 0; + const priestImg = members[0]?.priest_img || '/intranet_repo/general_files/profile_pictures/default.jpg'; + if (priestId > 0) { + html += `
Priester
`; + html += ``; + html += ``; + html += `${escHtml(priestName)}`; + html += `
`; + } else { + html += `
Priester
${escHtml(priestName)}
`; + } + + const comment = members.map(m => m.comment).filter(c => c).join('; ') || '—'; + html += `
Hinweis
${escHtml(comment)}
`; + html += '
'; + + // Reservation info + if (data.reservation) { + html += '
Geplanter Besuch
'; + const resName = data.reservation_name || ('Nutzer #' + data.reservation); + const resImg = data.reservation_img || '/intranet_repo/general_files/profile_pictures/default.jpg'; + const resId = parseInt(data.reservation); + html += '
Geplant von
'; + html += ``; + html += ``; + html += `${escHtml(resName)}`; + html += ``; + html += '
'; + } + + // Actions + const firstBesuchId = members[0]?.besuch_id || 0; + html += '
'; + if (!data.reservation) { + html += ``; + } else if (parseInt(data.reservation) === getVisitConfig().currentUserId) { + html += ``; + } + html += ``; + html += '
'; + + body.innerHTML = html; + overlay.style.display = 'flex'; +} + +function closeHouseholdDetail() { + document.getElementById('visitDetailOverlay').style.display = 'none'; +} + +function escHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +function editMemberFromDetail(besuchId) { + closeHouseholdDetail(); + const entry = getVisitConfig().entries.find(e => e.besuch_id === besuchId); + if (entry) openVisitEditModalFromEntry(entry); +} + +function submitVisitAction(action, besuchId) { + const form = document.getElementById('visitActionForm'); + const command = document.getElementById('visit_action_command'); + const id = document.getElementById('visit_action_id'); + if (!form || !command || !id) return; + + command.value = action; + id.value = besuchId; + form.submit(); +} + +function doReservation(besuchId) { submitVisitAction('reserve', besuchId); } +function doComplete(besuchId) { submitVisitAction('complete', besuchId); } +function doPrioritize(besuchId) { submitVisitAction('prioritize', besuchId); } + +// --- Picker Modal --- +function openVisitPickerModal() { + renderVisitPickerList(''); + document.getElementById('visitPickerModalOverlay').style.display = 'flex'; + const inp = document.getElementById('visitPickerSearch'); + inp.value = ''; + setTimeout(() => inp.focus(), 30); +} + +function closeVisitPickerModal() { + document.getElementById('visitPickerModalOverlay').style.display = 'none'; +} + +function onVisitPickerSearchInput() { + renderVisitPickerList(document.getElementById('visitPickerSearch').value || ''); +} + +function normalizeStr(s) { return (s || '').toString().toLowerCase(); } + +function matchesQuery(entry, query) { + const q = normalizeStr(query).trim(); + if (!q) return true; + const ln = normalizeStr(entry.last_name); + const fn = normalizeStr(entry.first_name); + const a = (ln + ' ' + fn).trim(); + const b = (fn + ' ' + ln).trim(); + const tokens = q.split(/\s+/).filter(Boolean); + return tokens.every(t => a.includes(t) || b.includes(t)); +} + +function renderVisitPickerList(query) { + const list = document.getElementById('visitPickerList'); + list.innerHTML = ''; + if (!Array.isArray(getVisitConfig().entries) || getVisitConfig().entries.length === 0) { + list.innerHTML = '
Keine Einträge vorhanden.
'; + return; + } + const filtered = getVisitConfig().entries.filter(e => matchesQuery(e, query)); + if (filtered.length === 0) { + list.innerHTML = '
Keine Treffer.
'; + return; + } + filtered.forEach(entry => { + const item = document.createElement('div'); + item.className = 'visit-picker-item'; + item.onclick = () => { closeVisitPickerModal(); openVisitEditModalFromEntry(entry); }; + const left = document.createElement('div'); + const name = document.createElement('div'); + name.className = 'visit-picker-name'; + name.textContent = (entry.last_name || '') + ', ' + (entry.first_name || ''); + left.appendChild(name); + const sub = document.createElement('div'); + sub.className = 'visit-picker-sub'; + sub.textContent = (entry.address_street || '') + (entry.address_plz ? (' · ' + entry.address_plz) : ''); + left.appendChild(sub); + const right = document.createElement('div'); + right.style.cssText = 'opacity:0.5;font-size:0.8em;font-weight:700;'; + right.textContent = '#' + entry.besuch_id; + item.appendChild(left); + item.appendChild(right); + list.appendChild(item); + }); +} + +// --- Edit Modal --- +const EMPTY_VISIT_ENTRY = { + besuch_id: '', + last_name: '', + first_name: '', + birth_date: '', + address_street: '', + address_plz: '', + address_link: '', + phone: '', + comment: '', + letzter_besuch: '2024-07-11', + priority: 0, + pr_bezirk: '' +}; + +function openVisitEntryModal(entry = null, errorMessage = '') { + const values = Object.assign({}, EMPTY_VISIT_ENTRY, entry || {}); + const isEdit = Number(values.besuch_id) > 0; + + document.getElementById('visitModalTitle').innerText = isEdit ? 'Eintrag bearbeiten' : 'Neue Besuchsstelle anlegen'; + document.getElementById('modal_besuch_id').value = isEdit ? values.besuch_id : ''; + document.getElementById('modal_last_name').value = values.last_name || ''; + document.getElementById('modal_first_name').value = values.first_name || ''; + document.getElementById('modal_birth_date').value = values.birth_date || ''; + document.getElementById('modal_address_street').value = values.address_street || ''; + document.getElementById('modal_address_plz').value = values.address_plz || ''; + document.getElementById('modal_address_link').value = values.address_link || ''; + document.getElementById('modal_phone').value = values.phone || ''; + document.getElementById('modal_comment').value = values.comment || ''; + document.getElementById('modal_letzter_besuch').value = values.letzter_besuch || EMPTY_VISIT_ENTRY.letzter_besuch; + document.getElementById('modal_priority').value = values.priority ?? 0; + document.getElementById('modal_pr_bezirk').value = values.pr_bezirk ?? ''; + document.getElementById('modal_delete_btn').style.display = isEdit ? 'inline-block' : 'none'; + + const error = document.getElementById('visitFormError'); + if (error) { + error.textContent = errorMessage; + error.style.display = errorMessage ? 'block' : 'none'; + } + showVisitModal(); +} + +function openVisitEditModalFromEntry(entry) { + openVisitEntryModal(entry); +} + +function openVisitAddModal() { + openVisitEntryModal(); +} + +function showVisitModal() { + const overlay = document.getElementById('visitEntryModalOverlay'); + overlay.style.display = 'flex'; + overlay.setAttribute('aria-hidden', 'false'); + setTimeout(() => document.getElementById('modal_last_name')?.focus(), 30); +} + +function closeVisitModal() { + const overlay = document.getElementById('visitEntryModalOverlay'); + if (overlay && overlay.contains(document.activeElement)) document.activeElement.blur(); + overlay.setAttribute('aria-hidden', 'true'); + overlay.style.display = 'none'; +} + +// Close on backdrop/escape +document.addEventListener('click', function(e) { + const picker = document.getElementById('visitPickerModalOverlay'); + const edit = document.getElementById('visitEntryModalOverlay'); + if (picker && picker.style.display === 'flex' && e.target === picker) closeVisitPickerModal(); + if (edit && edit.style.display === 'flex' && e.target === edit) closeVisitModal(); +}); + +document.addEventListener('keydown', function(e) { + if (e.key !== 'Escape') return; + const detail = document.getElementById('visitDetailOverlay'); + const edit = document.getElementById('visitEntryModalOverlay'); + const picker = document.getElementById('visitPickerModalOverlay'); + if (edit && edit.style.display === 'flex') return closeVisitModal(); + if (picker && picker.style.display === 'flex') return closeVisitPickerModal(); + if (detail && detail.style.display === 'flex') return closeHouseholdDetail(); +}); + +function deleteVisitEntryFromModal() { + const besuchId = document.getElementById('modal_besuch_id').value; + if (besuchId && confirm('Eintrag wirklich löschen?')) { + submitVisitAction('delete', besuchId); + } +} + +// Server-seitige Validierungsfehler nach Post/Redirect/Get im Formular wieder anzeigen. +document.addEventListener('DOMContentLoaded', function() { + const config = getVisitConfig(); + if (config.formError && config.formData) { + openVisitEntryModal(config.formData, config.formError); + } else if (config.formError) { + alert(config.formError); + } +}); diff --git a/web/intranet_repo/projects/adminpanel/admin_brandschutz.php b/web/intranet_repo/projects/adminpanel/admin_brandschutz.php index 9c19c2e..478bc90 100644 --- a/web/intranet_repo/projects/adminpanel/admin_brandschutz.php +++ b/web/intranet_repo/projects/adminpanel/admin_brandschutz.php @@ -1,457 +1,117 @@ -prepare("SELECT \"Admin\" FROM user_permissions WHERE user_id = ?"); - $stmt->execute([$_SESSION["user_id"]]); - $isAdmin = $stmt->fetchColumn(); +if ((int) ($groups['Admin'] ?? 0) !== 1) { + header("Location: {$g_httpHomePath}"); + exit(); +} - if ((int)$isAdmin !== 1) { - header("Location: {$g_httpHomePath}"); - exit(); - } +$csrfToken = brandschutz_csrf_token(); - // Hilfsfunktionen - function getProfileImageUrl_bs(array $row, string $g_profilePicturePath): string { - $profileImg = glob($g_profilePicturePath."{$row['user_id']}-*.jpg"); - if (!empty($profileImg)) { - usort($profileImg, function($a, $b) { - return filemtime($b) - filemtime($a); - }); - return str_replace($_SERVER['DOCUMENT_ROOT'], '', $profileImg[0]); - } - return '/intranet_repo/general_files/profile_pictures/default.jpg'; - } +function getProfileImageUrl_bs(array $row, string $g_profilePicturePath): string +{ + $profileImages = glob($g_profilePicturePath . $row['user_id'] . '-*.jpg') ?: []; + if ($profileImages !== []) { + usort($profileImages, static function (string $a, string $b): int { + return filemtime($b) <=> filemtime($a); + }); + return str_replace($_SERVER['DOCUMENT_ROOT'], '', $profileImages[0]); + } - function getDisplayName_bs(array $row): string { - $fullName = trim(($row['first_name'] ?? '') . ' ' . ($row['last_name'] ?? '')); - return $fullName !== '' ? $fullName : ($row['mail'] ?? 'Unbekannt'); - } + return '/intranet_repo/general_files/profile_pictures/default.jpg'; +} - // Schulungsdatum manuell anpassen (POST) - if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['bs_update_date'])) { - $editUserId = (int)($_POST['bs_user_id'] ?? 0); - $newDate = $_POST['bs_new_date'] ?? ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + try { + brandschutz_verify_csrf($_POST['csrf_token'] ?? null); + } catch (InvalidArgumentException $exception) { + http_response_code(400); + exit($exception->getMessage()); + } - if ($editUserId > 0 && preg_match('/^\d{4}-\d{2}-\d{2}$/', $newDate)) { - // Prüfen ob das neue Datum innerhalb der Frist liegt (365 Tage ab Datum > heute) - $newDueDate = date('Y-m-d', strtotime($newDate . ' +365 days')); - $isWithinDeadline = (strtotime($newDueDate) >= strtotime(date('Y-m-d'))); + if (($_POST['action'] ?? '') !== 'update_training_date') { + http_response_code(400); + exit('Ungültige Aktion.'); + } - if ($isWithinDeadline) { - // Datum in der Frist: Zähler zurücksetzen - $stmtUpsert = $pdo->prepare("INSERT INTO brandschutz_training (user_id, last_training_date, reminder_count, admin_reminder_count) VALUES (?, ?, 0, 0) ON CONFLICT (user_id) DO UPDATE SET last_training_date = EXCLUDED.last_training_date, reminder_count = 0, admin_reminder_count = 0"); - } else { - // Datum außerhalb der Frist: Nur Datum aktualisieren, Zähler beibehalten - $stmtUpsert = $pdo->prepare("INSERT INTO brandschutz_training (user_id, last_training_date, reminder_count, admin_reminder_count) VALUES (?, ?, 0, 0) ON CONFLICT (user_id) DO UPDATE SET last_training_date = EXCLUDED.last_training_date"); - } - $stmtUpsert->execute([$editUserId, $newDate]); + $editUserId = filter_var($_POST['bs_user_id'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + $newDate = trim((string) ($_POST['bs_new_date'] ?? '')); + if ($editUserId === false || !brandschutz_is_valid_date($newDate)) { + http_response_code(400); + exit('Ungültige Eingaben.'); + } - // Systemnachricht in erledigte Admin-Nachrichten - $adminName = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? '')); - if (empty($adminName)) $adminName = 'Ein Administrator'; + $stmtUser = $pdo->prepare( + 'SELECT ugi.first_name, ugi.last_name, ugi.mail ' + . 'FROM user_general_information ugi ' + . 'INNER JOIN user_permissions up ON up.user_id = ugi.user_id ' + . 'WHERE ugi.user_id = ? AND up."Brandschutz" = 1' + ); + $stmtUser->execute([(int) $editUserId]); + $editedUser = $stmtUser->fetch(PDO::FETCH_ASSOC); + if (!$editedUser) { + http_response_code(404); + exit('Brandschutz-Nutzer nicht gefunden.'); + } - $stmtUser = $pdo->prepare("SELECT first_name, last_name, mail FROM user_general_information WHERE user_id = ?"); - $stmtUser->execute([$editUserId]); - $editedUser = $stmtUser->fetch(PDO::FETCH_ASSOC); + $newStatus = brandschutz_training_status($newDate); + $resetCounters = (int) $newStatus['days_until_due'] >= 0; + $adminName = brandschutz_display_name(is_array($user) ? $user : [], 'Ein Administrator'); + $editedName = brandschutz_display_name($editedUser); - $editedName = trim(($editedUser['first_name'] ?? '') . ' ' . ($editedUser['last_name'] ?? '')); - if (empty($editedName)) $editedName = $editedUser['mail'] ?? 'Unbekannt'; + $pdo->beginTransaction(); + try { + brandschutz_store_training_date($pdo, (int) $editUserId, $newDate, $resetCounters); + brandschutz_log_admin_message( + $pdo, + $adminName, + (string) ($editedUser['mail'] ?? ''), + 'Brandschutz-Schulungsdatum angepasst: ' . $editedName, + $adminName . ' hat das Schulungsdatum von ' . $editedName . ' manuell auf den ' + . (new DateTimeImmutable($newDate))->format('d.m.Y') . ' gesetzt.' + ); + $pdo->commit(); + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $exception; + } - $formattedDate = date('d.m.Y', strtotime($newDate)); - $sysSubject = "Brandschutz-Schulungsdatum angepasst: {$editedName}"; - $sysMessage = "{$adminName} hat das Schulungsdatum von {$editedName} manuell auf den {$formattedDate} gesetzt."; + header('Location: admin_brandschutz.php?message=5', true, 303); + exit(); +} - $stmtMsg = $pdo->prepare("INSERT INTO admin_messages (type, sender_name, sender_email, subject, message, status, processed_at, created_at) VALUES ('system', ?, ?, ?, ?, 'done', NOW(), NOW())"); - $senderMail = $editedUser['mail'] ?? ''; - $stmtMsg->execute([$adminName, $senderMail, $sysSubject, $sysMessage]); +$sql = 'SELECT ugi.user_id, ugi.first_name, ugi.last_name, ugi.mail, ' + . 'bt.last_training_date, bt.reminder_count, bt.admin_reminder_count, ' + . 'CASE WHEN ul.user_id IS NOT NULL THEN 1 ELSE 0 END AS IstRegistriert ' + . 'FROM user_permissions up ' + . 'INNER JOIN user_general_information ugi ON up.user_id = ugi.user_id ' + . 'LEFT JOIN brandschutz_training bt ON ugi.user_id = bt.user_id ' + . 'LEFT JOIN user_login ul ON ugi.user_id = ul.user_id ' + . 'WHERE up."Brandschutz" = 1 ' + . 'ORDER BY bt.last_training_date ASC, ugi.last_name ASC, ugi.first_name ASC'; +$bsUsers = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC); - header("Location: admin_brandschutz.php?message=5"); - exit(); - } - } - - // Brandschutz-Nutzer laden - $sql = "SELECT ugi.user_id, ugi.first_name, ugi.last_name, ugi.mail, - bt.last_training_date, bt.reminder_count, bt.admin_reminder_count, - CASE WHEN ul.user_id IS NOT NULL THEN 1 ELSE 0 END AS IstRegistriert - FROM user_permissions up - INNER JOIN user_general_information ugi ON up.user_id = ugi.user_id - LEFT JOIN brandschutz_training bt ON ugi.user_id = bt.user_id - LEFT JOIN user_login ul ON ugi.user_id = ul.user_id - WHERE up.\"Brandschutz\" = 1 - ORDER BY bt.last_training_date ASC, ugi.last_name ASC, ugi.first_name ASC"; - $bsUsers = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC); +foreach ($bsUsers as &$bsUser) { + $bsUser['_display_name'] = brandschutz_display_name($bsUser); + $bsUser['_profile_url'] = getProfileImageUrl_bs($bsUser, $g_profilePicturePath); + $bsUser['_training_status'] = brandschutz_training_status($bsUser['last_training_date'] ?? null); +} +unset($bsUser); ?> - @@ -487,45 +147,9 @@ 30) { - $statusText = 'Aktuell'; - $statusClass = 'bs-status-ok'; - $dueDateDisplay = date('d.m.Y', strtotime($dueDate)); - } elseif ($diff > 0) { - $statusText = 'Bald fällig'; - $statusClass = 'bs-status-warning'; - $dueDateDisplay = date('d.m.Y', strtotime($dueDate)); - } elseif ($diff > -120) { - $daysOverdue = abs((int)$diff); - $statusText = "{$daysOverdue} Tage überfällig"; - $statusClass = 'bs-status-overdue'; - $dueDateDisplay = date('d.m.Y', strtotime($dueDate)); - } else { - $daysOverdue = abs((int)$diff); - $statusText = "{$daysOverdue} Tage überfällig"; - $statusClass = 'bs-status-critical'; - $dueDateDisplay = date('d.m.Y', strtotime($dueDate)); - } - } + $nameOutput = (string) $bsUser['_display_name']; + $profileUrl = (string) $bsUser['_profile_url']; + $trainingStatus = $bsUser['_training_status']; ?> @@ -537,10 +161,10 @@ - + - + @@ -555,34 +179,12 @@ 0) { - $overdueText = 'Nicht überfällig (' . (int)$diff . ' Tage verbleibend)'; - $overdueClass = 'ok'; - } else { - $daysOverdue = abs((int)$diff); - $overdueText = "{$daysOverdue} Tage überfällig"; - $overdueClass = 'overdue'; - } - } + $reminderCount = (int) ($bsUser['reminder_count'] ?? 0); + $adminReminderCount = (int) ($bsUser['admin_reminder_count'] ?? 0); + $trainingStatus = $bsUser['_training_status']; ?>
@@ -605,15 +207,15 @@
Letzte Schulung
-
+
Nächste Fälligkeit
-
+
Überfälligkeit
-
+
Erinnerungsmails gesendet
@@ -629,6 +231,8 @@
Schulungsdatum anpassen
+ +
@@ -643,7 +247,7 @@
- +
@@ -653,23 +257,7 @@
- + diff --git a/web/intranet_repo/projects/adminpanel/admin_inbox.php b/web/intranet_repo/projects/adminpanel/admin_inbox.php index e5e23b5..3dd8d94 100644 --- a/web/intranet_repo/projects/adminpanel/admin_inbox.php +++ b/web/intranet_repo/projects/adminpanel/admin_inbox.php @@ -7,6 +7,8 @@ - Info-Nachrichten */ require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/initialise.php'; + require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/authorization/util/authorization_helpers.php'; + $authorizationCsrfToken = authorization_csrf_token(); // Admin-Check $stmt = $pdo->prepare("SELECT \"Admin\" FROM user_permissions WHERE user_id = ?"); @@ -18,6 +20,11 @@ exit(); } + if ($_SERVER['REQUEST_METHOD'] === 'POST' && !authorization_csrf_is_valid($_POST['csrf_token'] ?? null)) { + header('Location: admin_inbox.php?inbox_status=error'); + exit(); + } + // Typ-Konfiguration für Inbox $typeConfig = [ 'registration' => ['label' => 'Registrierung', 'class' => 'inbox-type-registration'], @@ -398,12 +405,14 @@
+
+
@@ -453,6 +462,7 @@
+ @@ -542,6 +552,7 @@ document.addEventListener('keydown', function(e) {
+ diff --git a/web/intranet_repo/projects/adminpanel/approve-registration.php b/web/intranet_repo/projects/adminpanel/approve-registration.php index 0921342..af6a55b 100644 --- a/web/intranet_repo/projects/adminpanel/approve-registration.php +++ b/web/intranet_repo/projects/adminpanel/approve-registration.php @@ -6,6 +6,7 @@ require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/initialise.php'; require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/ini/send_mail.php'; + require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/authorization/util/authorization_helpers.php'; // Admin-Check $stmt = $pdo->prepare("SELECT \"Admin\" FROM user_permissions WHERE user_id = ?"); @@ -22,6 +23,11 @@ exit(); } + if (!authorization_csrf_is_valid($_POST['csrf_token'] ?? null)) { + header("Location: admin_inbox.php?inbox_status=error"); + exit(); + } + // Admin-Name ermitteln $adminName = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? '')); if (empty($adminName)) $adminName = 'Ein Administrator'; diff --git a/web/intranet_repo/projects/authorization/login.php b/web/intranet_repo/projects/authorization/login.php index 7e9bbd1..6df9cd7 100644 --- a/web/intranet_repo/projects/authorization/login.php +++ b/web/intranet_repo/projects/authorization/login.php @@ -1,106 +1,96 @@ -prepare($sql); - $stmt->execute([$_POST["email"]]); - $user = $stmt->fetch(); - - if ($user) { - - if (isset($user['password_hash']) && password_verify($_POST["password"], $user["password_hash"])) { - - session_start(); - session_regenerate_id(); - $_SESSION["user_id"] = $user["user_id"]; - - header("Location: {$g_httpHomePath}"); - exit; - } - } - - $is_invalid = true; -} -?> - - - - - - - - - - - - - - - - + $userId]; + authorization_redirect($g_httpHomePath); + } + $isInvalid = true; + } +} + +$csrfToken = authorization_csrf_token(); +?> + + + + + + + + + + + + + diff --git a/web/intranet_repo/projects/authorization/logout.php b/web/intranet_repo/projects/authorization/logout.php index 6cc5f09..26dc947 100644 --- a/web/intranet_repo/projects/authorization/logout.php +++ b/web/intranet_repo/projects/authorization/logout.php @@ -1,13 +1,22 @@ - time() - 42000, + 'path' => $params['path'], + 'domain' => $params['domain'], + 'secure' => $params['secure'], + 'httponly' => $params['httponly'], + 'samesite' => $params['samesite'] ?? 'Lax', + ]); +} session_destroy(); -header("Location: {$g_httpLoginPath}"); -exit(); \ No newline at end of file +authorization_redirect($g_httpLoginPath, 302); diff --git a/web/intranet_repo/projects/authorization/reset-password-process.php b/web/intranet_repo/projects/authorization/reset-password-process.php deleted file mode 100644 index 02a164f..0000000 --- a/web/intranet_repo/projects/authorization/reset-password-process.php +++ /dev/null @@ -1,223 +0,0 @@ -exec("SET timezone = 'Europe/Berlin'"); - -if ($_SERVER["REQUEST_METHOD"] !== "POST") { - header("Location: reset-password.php"); - exit; -} - -$action = $_POST["action"] ?? ''; - -// ============================================= -// SCHRITT 1: OTP anfordern -// ============================================= -if ($action === "request_otp") { - $email = trim($_POST["email"] ?? ''); - - if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { - header("Location: reset-password.php?error=1"); - exit; - } - - // Nutzer suchen (muss registriert sein = user_login existiert) - $stmt = $pdo->prepare("SELECT ugi.user_id, ugi.phone, ugi.first_name FROM user_general_information ugi INNER JOIN user_login ul ON ugi.user_id = ul.user_id WHERE ugi.mail = ?"); - $stmt->execute([$email]); - $user = $stmt->fetch(); - - if (!$user) { - header("Location: reset-password.php?error=1"); - exit; - } - - if (empty($user['phone'])) { - header("Location: reset-password.php?error=2"); - exit; - } - - // OTP generieren (6-stellig) - $otp = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT); - - // Ablaufzeit als UNIX-Timestamp speichern (vermeidet Timezone-Probleme) - $expires_timestamp = time() + 600; // 10 Minuten - $expires_at = date('Y-m-d H:i:s', $expires_timestamp); - - // Alte OTPs für diesen Nutzer invalidieren - $stmt = $pdo->prepare("UPDATE password_reset_otp SET used = 1 WHERE user_id = ? AND used = 0"); - $stmt->execute([$user['user_id']]); - - // Neues OTP speichern - $stmt = $pdo->prepare("INSERT INTO password_reset_otp (user_id, otp_code, expires_at) VALUES (?, ?, ?)"); - $stmt->execute([$user['user_id'], $otp, $expires_at]); - - // SMS senden (wie im Seelsorgeplaner) - $phone_full = "+49" . $user['phone']; - $messageText = "Intranet NAK Reinickendorf\n\nIhr Bestaetigungscode: {$otp}\n\nDer Code ist 10 Minuten gueltig."; - - $smsData = array( - "phone" => $phone_full, - "message" => $messageText - ); - - $options = array( - 'http' => array( - 'header' => "Content-Type: application/json; charset=utf-8", - 'method' => 'POST', - 'content' => json_encode($smsData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - 'timeout' => 10, - ), - ); - - $context = stream_context_create($options); - $smsResult = @file_get_contents(rtrim(intranet_env('SMS_GATEWAY_URL'), '/') . '/send_sms', false, $context); - - if ($smsResult === FALSE) { - header("Location: reset-password.php?error=4"); - exit; - } - - // OTP zusätzlich per Mail senden - $greeting = !empty($user['first_name']) ? "Hallo {$user['first_name']}" : "Hallo"; - $mailBetreff = "Ihr Bestätigungscode"; - $mailNachricht = "{$greeting},\n\n"; - $mailNachricht .= "Sie haben das Zurücksetzen Ihres Passworts angefordert.\n\n"; - $mailNachricht .= "Ihr Bestätigungscode lautet: {$otp}\n\n"; - $mailNachricht .= "Der Code ist 10 Minuten gültig.\n\n"; - $mailNachricht .= "Falls Sie diese Anfrage nicht gestellt haben, können Sie diese Nachricht ignorieren."; - - send_intranet_mail($email, $mailBetreff, $mailNachricht); - - // Telefonnummer maskieren für Anzeige (nur letzte 3 Ziffern zeigen) - $phone_masked = str_repeat('*', max(0, strlen($user['phone']) - 3)) . substr($user['phone'], -3); - - header("Location: reset-password.php?step=verify&uid={$user['user_id']}&phone=" . urlencode($phone_masked)); - exit; -} - -// ============================================= -// SCHRITT 2: OTP verifizieren → Reset-Token erzeugen -// ============================================= -if ($action === "verify_otp") { - $user_id = (int)($_POST["user_id"] ?? 0); - $otp_code = trim($_POST["otp_code"] ?? ''); - - if ($user_id === 0 || strlen($otp_code) !== 6) { - header("Location: reset-password.php?error=3"); - exit; - } - - // OTP prüfen – Vergleich mit PHP-Zeit statt DB NOW() - $now = date('Y-m-d H:i:s'); - $stmt = $pdo->prepare("SELECT id FROM password_reset_otp WHERE user_id = ? AND otp_code = ? AND used = 0 AND expires_at > ?"); - $stmt->execute([$user_id, $otp_code, $now]); - $otpRecord = $stmt->fetch(); - - if (!$otpRecord) { - header("Location: reset-password.php?step=verify&uid={$user_id}&error=3"); - exit; - } - - // OTP als verwendet markieren - $stmt = $pdo->prepare("UPDATE password_reset_otp SET used = 1 WHERE id = ?"); - $stmt->execute([$otpRecord['id']]); - - // Einmal-Token für Passwort-Änderung generieren (in Session speichern) - $reset_token = bin2hex(random_bytes(32)); - if (session_status() == PHP_SESSION_NONE) { - session_start(); - } - $_SESSION['reset_token'] = $reset_token; - $_SESSION['reset_user_id'] = $user_id; - $_SESSION['reset_token_expires'] = time() + 600; // 10 Minuten - - header("Location: reset-password.php?step=newpassword&token=" . urlencode($reset_token)); - exit; -} - -// ============================================= -// SCHRITT 3: Neues Passwort setzen (nach OTP-Verifizierung) -// ============================================= -if ($action === "set_password") { - if (session_status() == PHP_SESSION_NONE) { - session_start(); - } - - $reset_token = $_POST["reset_token"] ?? ''; - $new_password = $_POST["new_password"] ?? ''; - $new_password_confirm = $_POST["new_password_confirm"] ?? ''; - - // Token validieren - if (empty($reset_token) - || !isset($_SESSION['reset_token']) - || $reset_token !== $_SESSION['reset_token'] - || !isset($_SESSION['reset_token_expires']) - || $_SESSION['reset_token_expires'] < time()) { - // Session-Daten aufräumen - unset($_SESSION['reset_token'], $_SESSION['reset_user_id'], $_SESSION['reset_token_expires']); - header("Location: reset-password.php?error=7"); - exit; - } - - $user_id = (int)$_SESSION['reset_user_id']; - - // Passwort-Validierung - if (strlen($new_password) < 8 || !preg_match("/[a-z]/i", $new_password) || !preg_match("/[0-9]/", $new_password)) { - header("Location: reset-password.php?step=newpassword&token=" . urlencode($reset_token) . "&error=5"); - exit; - } - - if ($new_password !== $new_password_confirm) { - header("Location: reset-password.php?step=newpassword&token=" . urlencode($reset_token) . "&error=6"); - exit; - } - - // Neues Passwort setzen - $password_hash = password_hash($new_password, PASSWORD_DEFAULT); - $stmt = $pdo->prepare("UPDATE user_login SET password_hash = ? WHERE user_id = ?"); - $stmt->execute([$password_hash, $user_id]); - - // User-Daten laden für Mail und Vermerk - $stmt = $pdo->prepare("SELECT mail, first_name, last_name FROM user_general_information WHERE user_id = ?"); - $stmt->execute([$user_id]); - $resetUserData = $stmt->fetch(); - - if ($resetUserData) { - $loginUrl = "https://" . ($_SERVER['HTTP_HOST'] ?? intranet_env('INTRANET_FQDN')) . "/intranet_repo/projects/authorization/login.php"; - $greeting = !empty($resetUserData['first_name']) ? "Hallo {$resetUserData['first_name']}" : "Hallo"; - - $mailBetreff = "Passwort erfolgreich geändert"; - $mailNachricht = "{$greeting},\n\n"; - $mailNachricht .= "Ihr Passwort für das Intranet der NAK Reinickendorf wurde erfolgreich geändert.\n\n"; - $mailNachricht .= "Sie können sich ab sofort mit Ihrem neuen Passwort anmelden."; - - send_intranet_mail($resetUserData['mail'], $mailBetreff, $mailNachricht, null, "intranet@nak-reinickendorf.de", "Jetzt anmelden", $loginUrl); - - // Vermerk in erledigte Tasks - $userName = trim(($resetUserData['first_name'] ?? '') . ' ' . ($resetUserData['last_name'] ?? '')); - if (empty($userName)) $userName = $resetUserData['mail']; - - $taskSubject = "Passwort selbst zurückgesetzt: {$userName}"; - $taskMessage = "{$userName} ({$resetUserData['mail']}) hat das eigene Passwort erfolgreich zurückgesetzt."; - - $stmt = $pdo->prepare("INSERT INTO admin_messages (type, sender_name, sender_email, subject, message, status, processed_at, created_at) VALUES ('system', ?, ?, ?, ?, 'done', NOW(), NOW())"); - $stmt->execute([$userName, $resetUserData['mail'], $taskSubject, $taskMessage]); - } - - // Session-Daten aufräumen - unset($_SESSION['reset_token'], $_SESSION['reset_user_id'], $_SESSION['reset_token_expires']); - - header("Location: login.php?message=1"); - exit; -} - -header("Location: reset-password.php"); -exit; -?> diff --git a/web/intranet_repo/projects/authorization/reset-password.php b/web/intranet_repo/projects/authorization/reset-password.php index 9d93673..bbd7d31 100644 --- a/web/intranet_repo/projects/authorization/reset-password.php +++ b/web/intranet_repo/projects/authorization/reset-password.php @@ -1,159 +1,324 @@ - - - - - - - - - - - - - - - + 'Bitte geben Sie eine gültige E-Mail-Adresse ein.', + 3 => 'Ungültiger oder abgelaufener Bestätigungscode.', + 5 => 'Passwort muss mindestens 8 Zeichen, einen Buchstaben und eine Zahl enthalten.', + 6 => 'Passwörter stimmen nicht überein.', + 7 => 'Sitzung abgelaufen. Bitte starten Sie den Vorgang erneut.', +]; +if (isset($errorMessages[$errorCode])) { + $error = $errorMessages[$errorCode]; +} + +if ($step === 'verify' && !authorization_reset_request_is_active()) { + authorization_clear_reset_state(); + authorization_redirect('reset-password.php?error=7', 302); +} +if ($step === 'newpassword' && !authorization_reset_is_authorized()) { + authorization_clear_reset_state(); + authorization_redirect('reset-password.php?error=7', 302); +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = (string) ($_POST['action'] ?? ''); + + if (!authorization_csrf_is_valid($_POST['csrf_token'] ?? null)) { + authorization_clear_reset_state(); + authorization_redirect('reset-password.php?error=7'); + } + + $pdo = require $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/database.php'; + $pdo->exec("SET timezone = 'Europe/Berlin'"); + + if ($action === 'request_otp') { + $email = trim((string) ($_POST['email'] ?? '')); + if (strlen($email) > 254 || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + authorization_redirect('reset-password.php?error=1'); + } + + authorization_clear_reset_state(); + $_SESSION['auth_reset_started_at'] = time(); + $_SESSION['auth_reset_attempts'] = 0; + $_SESSION['auth_reset_user_id'] = 0; + + $stmt = $pdo->prepare( + 'SELECT ugi.user_id, ugi.phone, ugi.first_name, ugi.mail ' + . 'FROM user_general_information ugi ' + . 'INNER JOIN user_login ul ON ul.user_id = ugi.user_id ' + . 'WHERE LOWER(ugi.mail) = LOWER(?) LIMIT 1' + ); + $stmt->execute([$email]); + $resetUser = $stmt->fetch(PDO::FETCH_ASSOC); + + if (is_array($resetUser)) { + $userId = (int) $resetUser['user_id']; + $otp = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); + $expiresAt = date('Y-m-d H:i:s', time() + AUTH_RESET_TTL_SECONDS); + + $pdo->beginTransaction(); + try { + $stmt = $pdo->prepare('UPDATE password_reset_otp SET used = 1 WHERE user_id = ? AND used = 0'); + $stmt->execute([$userId]); + $stmt = $pdo->prepare('INSERT INTO password_reset_otp (user_id, otp_code, expires_at) VALUES (?, ?, ?)'); + $stmt->execute([$userId, $otp, $expiresAt]); + $pdo->commit(); + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + error_log('authorization reset OTP persistence failed: ' . $e->getMessage()); + authorization_redirect('reset-password.php?step=verify&sent=1'); + } + + $mailSent = false; + try { + require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/send_mail.php'; + $greeting = !empty($resetUser['first_name']) ? 'Hallo ' . $resetUser['first_name'] : 'Hallo'; + $mailMessage = $greeting . ",\n\n" + . "Sie haben das Zurücksetzen Ihres Passworts angefordert.\n\n" + . "Ihr Bestätigungscode lautet: {$otp}\n\n" + . "Der Code ist 10 Minuten gültig.\n\n" + . 'Falls Sie diese Anfrage nicht gestellt haben, können Sie diese Nachricht ignorieren.'; + $mailSent = send_intranet_mail((string) $resetUser['mail'], 'Ihr Bestätigungscode', $mailMessage); + } catch (Throwable $e) { + error_log('authorization reset mail delivery failed: ' . $e->getMessage()); + } + $smsSent = authorization_send_reset_sms(authorization_normalize_phone((string) ($resetUser['phone'] ?? '')), $otp); + + if ($mailSent || $smsSent) { + $_SESSION['auth_reset_user_id'] = $userId; + } else { + error_log('authorization reset delivery failed for user_id=' . $userId); + $stmt = $pdo->prepare('UPDATE password_reset_otp SET used = 1 WHERE user_id = ? AND used = 0'); + $stmt->execute([$userId]); + } + } + + // Immer dieselbe Antwort, damit aus dem Reset-Flow kein Kontostatus abgeleitet werden kann. + authorization_redirect('reset-password.php?step=verify&sent=1'); + } + + if ($action === 'verify_otp') { + if (!authorization_reset_request_is_active()) { + authorization_clear_reset_state(); + authorization_redirect('reset-password.php?error=7'); + } + + $otp = trim((string) ($_POST['otp_code'] ?? '')); + $attempts = (int) ($_SESSION['auth_reset_attempts'] ?? 0) + 1; + $_SESSION['auth_reset_attempts'] = $attempts; + $userId = (int) ($_SESSION['auth_reset_user_id'] ?? 0); + + if (!preg_match('/^[0-9]{6}$/', $otp) || $userId <= 0 || $attempts > AUTH_RESET_MAX_ATTEMPTS) { + if ($attempts >= AUTH_RESET_MAX_ATTEMPTS && $userId > 0) { + $stmt = $pdo->prepare('UPDATE password_reset_otp SET used = 1 WHERE user_id = ? AND used = 0'); + $stmt->execute([$userId]); + authorization_clear_reset_state(); + authorization_redirect('reset-password.php?error=7'); + } + authorization_redirect('reset-password.php?step=verify&error=3'); + } + + $now = date('Y-m-d H:i:s'); + $pdo->beginTransaction(); + try { + $stmt = $pdo->prepare( + 'SELECT id FROM password_reset_otp ' + . 'WHERE user_id = ? AND otp_code = ? AND used = 0 AND expires_at > ? ' + . 'ORDER BY id DESC LIMIT 1 FOR UPDATE' + ); + $stmt->execute([$userId, $otp, $now]); + $otpId = (int) ($stmt->fetchColumn() ?: 0); + + if ($otpId <= 0) { + $pdo->rollBack(); + authorization_redirect('reset-password.php?step=verify&error=3'); + } + + $stmt = $pdo->prepare('UPDATE password_reset_otp SET used = 1 WHERE id = ?'); + $stmt->execute([$otpId]); + $pdo->commit(); + + session_regenerate_id(true); + $_SESSION['auth_reset_user_id'] = $userId; + $_SESSION['auth_reset_authorized_until'] = time() + AUTH_RESET_TTL_SECONDS; + unset($_SESSION['auth_reset_attempts']); + authorization_redirect('reset-password.php?step=newpassword'); + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + error_log('authorization OTP verification failed: ' . $e->getMessage()); + authorization_redirect('reset-password.php?step=verify&error=3'); + } + } + + if ($action === 'set_password') { + if (!authorization_reset_is_authorized()) { + authorization_clear_reset_state(); + authorization_redirect('reset-password.php?error=7'); + } + + $newPassword = (string) ($_POST['new_password'] ?? ''); + $confirmation = (string) ($_POST['new_password_confirm'] ?? ''); + $passwordError = authorization_password_error($newPassword, $confirmation); + if ($passwordError !== null) { + $code = str_contains($passwordError, 'stimmen') ? 6 : 5; + authorization_redirect('reset-password.php?step=newpassword&error=' . $code); + } + + $userId = (int) $_SESSION['auth_reset_user_id']; + $passwordHash = password_hash($newPassword, PASSWORD_DEFAULT); + $resetUser = null; + + $pdo->beginTransaction(); + try { + $stmt = $pdo->prepare('UPDATE user_login SET password_hash = ? WHERE user_id = ?'); + $stmt->execute([$passwordHash, $userId]); + if ($stmt->rowCount() !== 1) { + throw new RuntimeException('Reset target login no longer exists.'); + } + + $stmt = $pdo->prepare('UPDATE password_reset_otp SET used = 1 WHERE user_id = ? AND used = 0'); + $stmt->execute([$userId]); + + $stmt = $pdo->prepare('SELECT mail, first_name, last_name FROM user_general_information WHERE user_id = ?'); + $stmt->execute([$userId]); + $resetUser = $stmt->fetch(PDO::FETCH_ASSOC); + if (!is_array($resetUser)) { + throw new RuntimeException('Reset target user no longer exists.'); + } + + $userName = trim(($resetUser['first_name'] ?? '') . ' ' . ($resetUser['last_name'] ?? '')); + if ($userName === '') { + $userName = (string) $resetUser['mail']; + } + $taskSubject = "Passwort selbst zurückgesetzt: {$userName}"; + $taskMessage = "{$userName} ({$resetUser['mail']}) hat das eigene Passwort erfolgreich zurückgesetzt."; + $stmt = $pdo->prepare( + "INSERT INTO admin_messages (type, sender_name, sender_email, subject, message, status, processed_at, created_at) " + . "VALUES ('system', ?, ?, ?, ?, 'done', NOW(), NOW())" + ); + $stmt->execute([$userName, $resetUser['mail'], $taskSubject, $taskMessage]); + + $pdo->commit(); + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + error_log('authorization password reset failed: ' . $e->getMessage()); + authorization_clear_reset_state(); + authorization_redirect('reset-password.php?error=7'); + } + + try { + require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/send_mail.php'; + $loginUrl = authorization_public_url('/intranet_repo/projects/authorization/login.php'); + $greeting = !empty($resetUser['first_name']) ? 'Hallo ' . $resetUser['first_name'] : 'Hallo'; + $message = $greeting . ",\n\n" + . "Ihr Passwort für das Intranet der NAK Reinickendorf wurde erfolgreich geändert.\n\n" + . 'Sie können sich ab sofort mit Ihrem neuen Passwort anmelden.'; + send_intranet_mail((string) $resetUser['mail'], 'Passwort erfolgreich geändert', $message, null, 'intranet@nak-reinickendorf.de', 'Jetzt anmelden', $loginUrl); + } catch (Throwable $e) { + error_log('authorization password reset notification failed: ' . $e->getMessage()); + } + + authorization_clear_reset_state(); + session_regenerate_id(true); + authorization_redirect('login.php?message=1'); + } + + authorization_redirect('reset-password.php'); +} + +$csrfToken = authorization_csrf_token(); +?> + + + + + + + + + + + + + diff --git a/web/intranet_repo/projects/authorization/self-signup-process.php b/web/intranet_repo/projects/authorization/self-signup-process.php deleted file mode 100644 index ca86ca7..0000000 --- a/web/intranet_repo/projects/authorization/self-signup-process.php +++ /dev/null @@ -1,97 +0,0 @@ -prepare($checkSql); -$stmt->execute([$email]); -$count1 = $stmt->fetchColumn(); - -$checkSql2 = "SELECT COUNT(*) FROM pending_registrations WHERE email = ? AND status = 'pending'"; -$stmt2 = $pdo->prepare($checkSql2); -$stmt2->execute([$email]); -$count2 = $stmt2->fetchColumn(); - -if ($count1 > 0 || $count2 > 0) { - header("Location: self-signup.php?error=2"); - exit; -} - -// Passwort hashen und Token generieren -$password_hash = password_hash($_POST["password"], PASSWORD_DEFAULT); -$token = bin2hex(random_bytes(32)); - -// In pending_registrations speichern -$insertSql = "INSERT INTO pending_registrations (first_name, last_name, email, password_hash, token) VALUES (?, ?, ?, ?, ?)"; -$stmt = $pdo->prepare($insertSql); -if (!$stmt) { - header("Location: self-signup.php?error=1"); - exit; -} - -if (!$stmt->execute([$first_name, $last_name, $email, $password_hash, $token])) { - header("Location: self-signup.php?error=1"); - exit; -} -$pending_id = $pdo->lastInsertId(); - -// Admin-Nachricht in Inbox speichern -$subject = "Neue Registrierungsanfrage: {$first_name} {$last_name}"; -$message_text = "Neue Selbstregistrierung:\n\nName: {$first_name} {$last_name}\nE-Mail: {$email}\n\nBitte im Adminpanel freischalten."; -$data_json = json_encode(['pending_id' => $pending_id, 'token' => $token]); - -$inboxSql = "INSERT INTO admin_messages (type, sender_name, sender_email, subject, message, data_json) VALUES ('registration', ?, ?, ?, ?, ?)"; -$stmt = $pdo->prepare($inboxSql); -$sender_name = "{$first_name} {$last_name}"; -$stmt->execute([$sender_name, $email, $subject, $message_text, $data_json]); - -// Mail an Admin-Team (Admin + Technik) -$admin_email = ADMIN_EMAIL; -$mail_betreff = "Intranet: Neue Registrierungsanfrage von {$first_name} {$last_name}"; -$mail_nachricht = "Hallo Admin,\n\n"; -$mail_nachricht .= "Es liegt eine neue Registrierungsanfrage vor:\n\n"; -$mail_nachricht .= "Name: {$first_name} {$last_name}\n"; -$mail_nachricht .= "E-Mail: {$email}\n\n"; -$mail_nachricht .= "Bitte melden Sie sich im Adminpanel an, um die Anfrage zu bearbeiten.\n\n"; -$mail_nachricht .= "-- Intranet NAK Reinickendorf"; - -send_admin_team_mail($pdo, $mail_betreff, $mail_nachricht); - -// Erfolg -header("Location: self-signup.php?success=1"); -exit; -?> diff --git a/web/intranet_repo/projects/authorization/self-signup.php b/web/intranet_repo/projects/authorization/self-signup.php deleted file mode 100644 index dc8f546..0000000 --- a/web/intranet_repo/projects/authorization/self-signup.php +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/web/intranet_repo/projects/authorization/signup-process.php b/web/intranet_repo/projects/authorization/signup-process.php deleted file mode 100644 index b2c6999..0000000 --- a/web/intranet_repo/projects/authorization/signup-process.php +++ /dev/null @@ -1,118 +0,0 @@ -prepare("SELECT user_id FROM user_general_information WHERE mail = ?"); -$stmt->execute([$email]); -$user_id = $stmt->fetchColumn(); - -if ($user_id) { - // Nutzer ist vorregistriert – prüfen ob bereits Login existiert - $stmt = $pdo->prepare("SELECT COUNT(*) FROM user_login WHERE user_id = ?"); - $stmt->execute([$user_id]); - $loginExists = $stmt->fetchColumn(); - - if ($loginExists > 0) { - // Bereits vollständig registriert - header("Location: signup.php?error=2"); - exit; - } - - // Direkte Registrierung durchführen - $stmt = $pdo->prepare("INSERT INTO user_login (user_id, password_hash) VALUES (?, ?)"); - - if ($stmt->execute([$user_id, $password_hash])) { - - // Namen und Telefonnummer aktualisieren - $stmt = $pdo->prepare("UPDATE user_general_information SET first_name=?, last_name=?, phone=? WHERE user_id=?"); - $stmt->execute([$first_name, $last_name, $phone, $user_id]); - - header("Location: login.php"); - exit; - } else { - header("Location: signup.php?error=1"); - exit; - } -} - -// === Prüfung 2: Liegt bereits ein Antrag vor? === -$stmt = $pdo->prepare("SELECT COUNT(*) FROM pending_registrations WHERE email = ? AND status = 'pending'"); -$stmt->execute([$email]); -$pendingCount = $stmt->fetchColumn(); - -if ($pendingCount > 0) { - header("Location: signup.php?error=2"); - exit; -} - -// === Antrag stellen (Nutzer nicht vorregistriert) === -$token = bin2hex(random_bytes(32)); - -$stmt = $pdo->prepare("INSERT INTO pending_registrations (first_name, last_name, email, phone, password_hash, token) VALUES (?, ?, ?, ?, ?, ?)"); - -if (!$stmt->execute([$first_name, $last_name, $email, $phone, $password_hash, $token])) { - header("Location: signup.php?error=1"); - exit; -} -$pending_id = $pdo->lastInsertId(); - -// Admin-Nachricht in Inbox speichern -$subject = "Neue Registrierungsanfrage: {$first_name} {$last_name}"; -$message_text = "Neue Registrierungsanfrage:\n\nName: {$first_name} {$last_name}\nE-Mail: {$email}\n\nBitte im Adminpanel freischalten."; -$data_json = json_encode(['pending_id' => $pending_id, 'token' => $token]); - -$stmt = $pdo->prepare("INSERT INTO admin_messages (type, sender_name, sender_email, subject, message, data_json) VALUES ('registration', ?, ?, ?, ?, ?)"); -$sender_name = "{$first_name} {$last_name}"; -$stmt->execute([$sender_name, $email, $subject, $message_text, $data_json]); - -// Mail an Admin-Team (Admin + Technik) -$mail_betreff = "Neue Registrierungsanfrage von {$first_name} {$last_name}"; -$mail_nachricht = "Es liegt eine neue Registrierungsanfrage vor:\n\n"; -$mail_nachricht .= "Name: {$first_name} {$last_name}\n"; -$mail_nachricht .= "E-Mail: {$email}\n"; -$mail_nachricht .= "Telefon: {$phone}\n\n"; -$mail_nachricht .= "Bitte melden Sie sich im Adminpanel an, um die Anfrage zu bearbeiten."; - -send_admin_team_mail($pdo, $mail_betreff, $mail_nachricht, "Im Adminpanel bearbeiten", "https://" . ($_SERVER['HTTP_HOST'] ?? intranet_env('INTRANET_FQDN')) . "/intranet_repo/projects/adminpanel/admin_inbox.php"); - -// Erfolg – gleiche Meldung wie bei direkter Registrierung (Nutzer merkt keinen Unterschied) -header("Location: signup.php?success=1"); -exit; -?> diff --git a/web/intranet_repo/projects/authorization/signup.php b/web/intranet_repo/projects/authorization/signup.php index 9863a4f..950c34c 100644 --- a/web/intranet_repo/projects/authorization/signup.php +++ b/web/intranet_repo/projects/authorization/signup.php @@ -1,111 +1,136 @@ -getMessage()); + } + } + + authorization_redirect('signup.php?success=1'); + } catch (Throwable $e) { + error_log('authorization signup failed: ' . $e->getMessage()); + $error = 'Die Registrierung konnte nicht verarbeitet werden. Bitte versuchen Sie es erneut.'; + } + } +} + +$csrfToken = authorization_csrf_token(); ?> - - - - - - + + + + - + + - \ No newline at end of file + diff --git a/web/intranet_repo/projects/authorization/util/authorization_helpers.php b/web/intranet_repo/projects/authorization/util/authorization_helpers.php new file mode 100644 index 0000000..2ba8f78 --- /dev/null +++ b/web/intranet_repo/projects/authorization/util/authorization_helpers.php @@ -0,0 +1,313 @@ + trim((string) ($input['first_name'] ?? '')), + 'last_name' => trim((string) ($input['last_name'] ?? '')), + 'email' => trim((string) ($input['email'] ?? '')), + 'phone' => authorization_normalize_phone((string) ($input['phone'] ?? '')), + 'password' => (string) ($input['password'] ?? ''), + 'password_confirmation' => (string) ($input['password_confirmation'] ?? ''), + ]; +} + +function authorization_text_length(string $value): int +{ + return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value); +} + +function authorization_registration_error(array $input): ?string +{ + if ($input['first_name'] === '' || authorization_text_length($input['first_name']) > 100) { + return 'Bitte geben Sie einen gültigen Vornamen ein.'; + } + if ($input['last_name'] === '' || authorization_text_length($input['last_name']) > 100) { + return 'Bitte geben Sie einen gültigen Nachnamen ein.'; + } + if (strlen($input['email']) > 254 || !filter_var($input['email'], FILTER_VALIDATE_EMAIL)) { + return 'Bitte geben Sie eine gültige E-Mail-Adresse ein.'; + } + if (!preg_match('/^[1-9][0-9]{5,14}$/', $input['phone'])) { + return 'Bitte geben Sie eine gültige Telefonnummer ohne führende 0 ein.'; + } + + return authorization_password_error($input['password'], $input['password_confirmation']); +} + +function authorization_authenticate(PDO $pdo, string $email, string $password): ?int +{ + $stmt = $pdo->prepare( + 'SELECT u.user_id, l.password_hash ' + . 'FROM user_general_information u ' + . 'INNER JOIN user_login l ON l.user_id = u.user_id ' + . 'WHERE LOWER(u.mail) = LOWER(?) ' + . 'LIMIT 1' + ); + $stmt->execute([trim($email)]); + $user = $stmt->fetch(PDO::FETCH_ASSOC); + + $storedHash = is_array($user) && is_string($user['password_hash'] ?? null) + ? $user['password_hash'] + : AUTH_DUMMY_PASSWORD_HASH; + $valid = password_verify($password, $storedHash); + + if (!$valid || !is_array($user)) { + return null; + } + + $userId = (int) $user['user_id']; + if ($userId <= 0) { + return null; + } + + if (password_needs_rehash($storedHash, PASSWORD_DEFAULT)) { + $newHash = password_hash($password, PASSWORD_DEFAULT); + $update = $pdo->prepare('UPDATE user_login SET password_hash = ? WHERE user_id = ?'); + $update->execute([$newHash, $userId]); + } + + return $userId; +} + +/** + * @return array{status:string,pending_id?:int,token?:string} + */ +function authorization_register(PDO $pdo, array $input): array +{ + $passwordHash = password_hash($input['password'], PASSWORD_DEFAULT); + $email = $input['email']; + + $pdo->beginTransaction(); + try { + // Serialisiert parallele Registrierungsversuche derselben E-Mail ohne neues Schema. + $lock = $pdo->prepare('SELECT pg_advisory_xact_lock(hashtext(LOWER(?)))'); + $lock->execute([$email]); + + $stmt = $pdo->prepare( + 'SELECT user_id FROM user_general_information ' + . 'WHERE LOWER(mail) = LOWER(?) LIMIT 1 FOR UPDATE' + ); + $stmt->execute([$email]); + $userId = (int) ($stmt->fetchColumn() ?: 0); + + if ($userId > 0) { + $stmt = $pdo->prepare('SELECT 1 FROM user_login WHERE user_id = ? LIMIT 1'); + $stmt->execute([$userId]); + if ($stmt->fetchColumn()) { + $pdo->commit(); + return ['status' => 'existing']; + } + + $stmt = $pdo->prepare('INSERT INTO user_login (user_id, password_hash) VALUES (?, ?)'); + $stmt->execute([$userId, $passwordHash]); + + $stmt = $pdo->prepare( + 'UPDATE user_general_information ' + . 'SET first_name = ?, last_name = ?, phone = ? WHERE user_id = ?' + ); + $stmt->execute([$input['first_name'], $input['last_name'], $input['phone'], $userId]); + + $pdo->commit(); + return ['status' => 'activated']; + } + + $stmt = $pdo->prepare( + "SELECT id FROM pending_registrations WHERE LOWER(email) = LOWER(?) AND status = 'pending' LIMIT 1 FOR UPDATE" + ); + $stmt->execute([$email]); + if ($stmt->fetchColumn()) { + $pdo->commit(); + return ['status' => 'existing']; + } + + // Token bleibt für Schema-/Admin-Kompatibilität erhalten, wird aber nicht mehr in admin_messages dupliziert. + $token = bin2hex(random_bytes(32)); + $stmt = $pdo->prepare( + 'INSERT INTO pending_registrations ' + . '(first_name, last_name, email, phone, password_hash, token) ' + . 'VALUES (?, ?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $input['first_name'], + $input['last_name'], + $email, + $input['phone'], + $passwordHash, + $token, + ]); + $pendingId = (int) $pdo->lastInsertId(); + + $subject = "Neue Registrierungsanfrage: {$input['first_name']} {$input['last_name']}"; + $message = "Neue Registrierungsanfrage:\n\n" + . "Name: {$input['first_name']} {$input['last_name']}\n" + . "E-Mail: {$email}\n\n" + . 'Bitte im Adminpanel freischalten.'; + $dataJson = json_encode(['pending_id' => $pendingId], JSON_THROW_ON_ERROR); + $senderName = trim($input['first_name'] . ' ' . $input['last_name']); + + $stmt = $pdo->prepare( + "INSERT INTO admin_messages (type, sender_name, sender_email, subject, message, data_json) " + . "VALUES ('registration', ?, ?, ?, ?, ?)" + ); + $stmt->execute([$senderName, $email, $subject, $message, $dataJson]); + + $pdo->commit(); + return ['status' => 'pending', 'pending_id' => $pendingId]; + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $e; + } +} + +function authorization_public_url(string $path): string +{ + require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/runtime_env.php'; + $host = intranet_env('INTRANET_FQDN'); + return 'https://' . $host . '/' . ltrim($path, '/'); +} + +function authorization_clear_reset_state(): void +{ + authorization_start_session(); + unset( + $_SESSION['auth_reset_user_id'], + $_SESSION['auth_reset_started_at'], + $_SESSION['auth_reset_attempts'], + $_SESSION['auth_reset_authorized_until'] + ); +} + +function authorization_reset_request_is_active(): bool +{ + authorization_start_session(); + $startedAt = (int) ($_SESSION['auth_reset_started_at'] ?? 0); + return $startedAt > 0 && ($startedAt + AUTH_RESET_TTL_SECONDS) >= time(); +} + +function authorization_reset_is_authorized(): bool +{ + authorization_start_session(); + $userId = (int) ($_SESSION['auth_reset_user_id'] ?? 0); + $authorizedUntil = (int) ($_SESSION['auth_reset_authorized_until'] ?? 0); + return $userId > 0 && $authorizedUntil >= time(); +} + +function authorization_masked_reset_message(): string +{ + return 'Wenn ein passendes Konto vorhanden ist, wurde ein 6-stelliger Bestätigungscode an die hinterlegten Kontaktwege gesendet.'; +} + +function authorization_send_reset_sms(string $phone, string $otp): bool +{ + if ($phone === '') { + return false; + } + + require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/runtime_env.php'; + $payload = json_encode([ + 'phone' => '+49' . $phone, + 'message' => "Intranet NAK Reinickendorf\n\nIhr Bestaetigungscode: {$otp}\n\nDer Code ist 10 Minuten gueltig.", + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + if ($payload === false) { + return false; + } + + $context = stream_context_create([ + 'http' => [ + 'header' => "Content-Type: application/json; charset=utf-8\r\n", + 'method' => 'POST', + 'content' => $payload, + 'timeout' => 10, + 'ignore_errors' => true, + ], + ]); + + try { + $gatewayUrl = intranet_env('SMS_GATEWAY_URL'); + } catch (RuntimeException $e) { + error_log('SMS gateway unavailable: ' . $e->getMessage()); + return false; + } + + $result = @file_get_contents(rtrim($gatewayUrl, '/') . '/send_sms', false, $context); + if ($result === false) { + return false; + } + + $statusLine = $http_response_header[0] ?? ''; + return preg_match('/\s2\d\d\s/', $statusLine) === 1; +} diff --git a/web/intranet_repo/projects/authorization/validate-email.php b/web/intranet_repo/projects/authorization/validate-email.php deleted file mode 100644 index 4340828..0000000 --- a/web/intranet_repo/projects/authorization/validate-email.php +++ /dev/null @@ -1,40 +0,0 @@ - false]); - exit; -} - -$email = trim($_GET["email"]); -$is_available = true; - -// Prüfung 1: Gibt es einen vollständigen Login für diese E-Mail? -$stmt = $pdo->prepare("SELECT u.user_id FROM user_general_information u INNER JOIN user_login l ON u.user_id = l.user_id WHERE u.mail = ?"); -$stmt->execute([$email]); - -if ($stmt->fetch()) { - $is_available = false; -} - -// Prüfung 2: Liegt bereits ein offener Antrag vor? -if ($is_available) { - $stmt = $pdo->prepare("SELECT COUNT(*) FROM pending_registrations WHERE email = ? AND status = 'pending'"); - $stmt->execute([$email]); - $pendingCount = $stmt->fetchColumn(); - - if ($pendingCount > 0) { - $is_available = false; - } -} - -echo json_encode(["available" => $is_available]); -exit; -?> diff --git a/web/intranet_repo/projects/authorization/validation.js b/web/intranet_repo/projects/authorization/validation.js deleted file mode 100644 index 5169dd7..0000000 --- a/web/intranet_repo/projects/authorization/validation.js +++ /dev/null @@ -1,74 +0,0 @@ -const validation = new JustValidate("#signup"); -validation - .addField("#first_name", [ - { - rule: "required", - errorMessage: "Pflichtfeld" - } - ]) - .addField("#last_name", [ - { - rule: "required", - errorMessage: "Pflichtfeld" - } - ]) - .addField("#email", [ - { - rule: "required", - errorMessage: "Pflichtfeld" - }, - { - rule: "email", - errorMessage: "Ungültig" - }, - { - validator: (value) => () => { - return fetch("validate-email.php?email=" + encodeURIComponent(value)) - .then(function(response) { - return response.json(); - }) - .then(function(json) { - return json.available; - }); - }, - errorMessage: "Bereits registriert" - } - ]) - .addField("#phone", [ - { - rule: "required", - errorMessage: "Pflichtfeld" - }, - { - rule: "minLength", - value: 6, - errorMessage: "Ungültige Telefonnummer" - }, - { - validator: (value) => { - return /^[1-9][0-9\s\-\/()]{4,}$/.test(value); - }, - errorMessage: "Ohne führende 0, nur Ziffern" - } - ]) - .addField("#password", [ - { - rule: "required", - errorMessage: "Pflichtfeld" - }, - { - rule: "password", - errorMessage: "Zu leicht" - } - ]) - .addField("#password_confirmation", [ - { - validator: (value, fields) => { - return value === fields["#password"].elem.value; - }, - errorMessage: "Passwörter müssen übereinstimmen" - } - ]) - .onSuccess((event) => { - document.getElementById("signup").submit(); - }); diff --git a/web/intranet_repo/projects/brandschutz/brandschutz.php b/web/intranet_repo/projects/brandschutz/brandschutz.php index 5dd741f..c930ffa 100644 --- a/web/intranet_repo/projects/brandschutz/brandschutz.php +++ b/web/intranet_repo/projects/brandschutz/brandschutz.php @@ -1,49 +1,79 @@ -prepare("SELECT \"Brandschutz\" FROM user_permissions WHERE user_id = ?"); - $stmtCheck->execute([$userId]); - $hasBrandschutz = $stmtCheck->fetchColumn(); +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + try { + brandschutz_verify_csrf($_POST['csrf_token'] ?? null); + } catch (InvalidArgumentException $exception) { + http_response_code(400); + exit($exception->getMessage()); + } - if ((int)$hasBrandschutz === 1) { - $today = date('Y-m-d'); + if (($_POST['action'] ?? '') !== 'complete_training') { + http_response_code(400); + exit('Ungültige Aktion.'); + } - // Tracking-Eintrag aktualisieren oder anlegen - $stmtUpsert = $pdo->prepare("INSERT INTO brandschutz_training (user_id, last_training_date, reminder_count, admin_reminder_count) VALUES (?, ?, 0, 0) ON CONFLICT (user_id) DO UPDATE SET last_training_date = EXCLUDED.last_training_date, reminder_count = 0, admin_reminder_count = 0"); - $stmtUpsert->execute([$userId, $today]); + if ((int) ($groups['Brandschutz'] ?? 0) !== 1) { + http_response_code(403); + exit('Keine Berechtigung für diese Aktion.'); + } - // Bestätigungsmail an den Nutzer senden - require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/ini/send_mail.php'; - $userName = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? '')); - $greeting = !empty($user['first_name']) ? "Hallo {$user['first_name']}" : "Hallo"; - $mailSubject = "Brandschutzunterweisung erfolgreich absolviert"; - $mailMessage = "{$greeting},\n\nSie haben die Brandschutzunterweisung am " . date('d.m.Y') . " erfolgreich absolviert.\n\nIhre nächste Unterweisung ist bis zum " . date('d.m.Y', strtotime('+365 days')) . " fällig.\n\nVielen Dank für Ihre Teilnahme!"; - send_intranet_mail($user['mail'], $mailSubject, $mailMessage); + $userId = (int) $_SESSION['user_id']; + $today = brandschutz_today(); + $trainingDate = $today->format('Y-m-d'); + $trainingDateDisplay = $today->format('d.m.Y'); + $dueDateDisplay = $today->modify('+' . BRANDSCHUTZ_VALIDITY_DAYS . ' days')->format('d.m.Y'); + $userName = brandschutz_display_name(is_array($user) ? $user : []); + $senderMail = trim((string) ($user['mail'] ?? '')); - // Systemnachricht ins Admin-Postfach (erledigt) - if (empty($userName)) $userName = $user['mail'] ?? 'Unbekannt'; - $sysSubject = "Brandschutzschulung absolviert: {$userName}"; - $sysMessage = "{$userName} hat die Brandschutzunterweisung am " . date('d.m.Y') . " erfolgreich absolviert."; - $senderMail = $user['mail'] ?? ''; - $stmtMsg = $pdo->prepare("INSERT INTO admin_messages (type, sender_name, sender_email, subject, message, status, processed_at, created_at) VALUES ('system', ?, ?, ?, ?, 'done', NOW(), NOW())"); - $stmtMsg->execute([$userName, $senderMail, $sysSubject, $sysMessage]); + $pdo->beginTransaction(); + try { + brandschutz_store_training_date($pdo, $userId, $trainingDate, true); + brandschutz_log_admin_message( + $pdo, + $userName, + $senderMail, + 'Brandschutzschulung absolviert: ' . $userName, + $userName . ' hat die Brandschutzunterweisung am ' . $trainingDateDisplay . ' erfolgreich absolviert.' + ); + $pdo->commit(); + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $exception; + } - header("Location: /intranet_repo/projects/intranet/index.php?message=8"); - exit(); + if ($senderMail !== '') { + require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/send_mail.php'; + $mailSubject = 'Brandschutzunterweisung erfolgreich absolviert'; + $mailMessage = brandschutz_greeting(is_array($user) ? $user : []) + . ",\n\nSie haben die Brandschutzunterweisung am {$trainingDateDisplay} erfolgreich absolviert." + . "\n\nIhre nächste Unterweisung ist bis zum {$dueDateDisplay} fällig." + . "\n\nVielen Dank für Ihre Teilnahme!"; + + if (!send_intranet_mail($senderMail, $mailSubject, $mailMessage)) { + error_log('Brandschutz: Bestätigungsmail an ' . $senderMail . ' konnte nicht gesendet werden.'); } } + + header('Location: /intranet_repo/projects/intranet/index.php?message=8', true, 303); + exit(); +} ?> + + Brandschutzunterweisung @@ -56,911 +86,12 @@ Brandschutzunterweisung
-
@@ -2236,831 +1367,7 @@
-
diff --git a/web/intranet_repo/projects/brandschutz/brandschutz.png b/web/intranet_repo/projects/brandschutz/brandschutz.png deleted file mode 100644 index c4e489e..0000000 Binary files a/web/intranet_repo/projects/brandschutz/brandschutz.png and /dev/null differ diff --git a/web/intranet_repo/projects/brandschutz/brandschutz_cron.php b/web/intranet_repo/projects/brandschutz/brandschutz_cron.php index d4d30b2..061bc30 100644 --- a/web/intranet_repo/projects/brandschutz/brandschutz_cron.php +++ b/web/intranet_repo/projects/brandschutz/brandschutz_cron.php @@ -1,16 +1,7 @@ query($sql); -$users = $result->fetchAll(PDO::FETCH_ASSOC); +// Tracking-Zeilen gesammelt anlegen statt einmal pro Nutzer im Loop. +$pdo->exec( + 'INSERT INTO brandschutz_training (user_id) ' + . 'SELECT user_id FROM user_permissions WHERE "Brandschutz" = 1 ' + . 'ON CONFLICT (user_id) DO NOTHING' +); + +$users = $pdo->query( + 'SELECT ugi.user_id, ugi.first_name, ugi.last_name, ugi.mail, ' + . 'bt.last_training_date, bt.reminder_count, bt.admin_reminder_count ' + . 'FROM user_permissions up ' + . 'INNER JOIN user_general_information ugi ON up.user_id = ugi.user_id ' + . 'INNER JOIN brandschutz_training bt ON ugi.user_id = bt.user_id ' + . 'WHERE up."Brandschutz" = 1' +)->fetchAll(PDO::FETCH_ASSOC); + +$incrementReminder = $pdo->prepare( + 'UPDATE brandschutz_training SET reminder_count = reminder_count + 1 WHERE user_id = ?' +); +$incrementAdminReminder = $pdo->prepare( + 'UPDATE brandschutz_training SET admin_reminder_count = admin_reminder_count + 1 WHERE user_id = ?' +); foreach ($users as $bsUser) { - $userId = (int)$bsUser['user_id']; - $firstName = $bsUser['first_name'] ?? ''; - $lastName = $bsUser['last_name'] ?? ''; - $mail = $bsUser['mail'] ?? ''; - $fullName = trim("{$firstName} {$lastName}"); - if (empty($fullName)) $fullName = $mail; - $greeting = !empty($firstName) ? "Hallo {$firstName}" : "Hallo"; + $userId = (int) $bsUser['user_id']; + $mail = trim((string) ($bsUser['mail'] ?? '')); + $fullName = brandschutz_display_name($bsUser, $mail !== '' ? $mail : 'Unbekannt'); + $greeting = brandschutz_greeting($bsUser); + $reminderCount = (int) ($bsUser['reminder_count'] ?? 0); + $adminReminderCount = (int) ($bsUser['admin_reminder_count'] ?? 0); + $status = brandschutz_training_status($bsUser['last_training_date'] ?? null, $today); + $actions = brandschutz_reminder_actions($status, $reminderCount); - $lastTraining = $bsUser['last_training_date']; - $reminderCount = (int)($bsUser['reminder_count'] ?? 0); - $adminReminderCount = (int)($bsUser['admin_reminder_count'] ?? 0); - - // Sicherstellen dass ein Tracking-Eintrag existiert - $stmtEnsure = $pdo->prepare("INSERT INTO brandschutz_training (user_id) VALUES (?) ON CONFLICT (user_id) DO NOTHING"); - $stmtEnsure->execute([$userId]); - - // Fälligkeit berechnen - if (empty($lastTraining)) { - // Nie geschult = sofort fällig - $dueDate = $today; - $daysUntilDue = -1; // Bereits fällig - $daysOverdue = 1; - } else { - $dueDate = date('Y-m-d', strtotime($lastTraining . ' +365 days')); - $daysUntilDue = (int)((strtotime($dueDate) - strtotime($today)) / 86400); - $daysOverdue = $daysUntilDue < 0 ? abs($daysUntilDue) : 0; - } - - // Szenario 1: Einen Monat vor Ablauf (0-30 Tage verbleibend) - Erste Erinnerung - if ($daysUntilDue >= 0 && $daysUntilDue <= 30 && $reminderCount === 0) { - $dueDateFormatted = date('d.m.Y', strtotime($dueDate)); + if ($actions['pre_due']) { + $dueDateFormatted = (string) $status['due_date_display']; $subject = "Erinnerung: Brandschutzunterweisung bis {$dueDateFormatted} fällig"; - $message = "{$greeting},\n\nIhre Brandschutzunterweisung läuft am {$dueDateFormatted} ab.\n\n"; - $message .= "Die Schulung dauert nur etwa 20 Minuten und kann bequem vom Handy oder PC aus durchgeführt werden.\n\n"; - $message .= "Bitte absolvieren Sie die Unterweisung innerhalb der nächsten zwei Monate über das Intranet.\n\n"; - $message .= "Vielen Dank!"; + $message = "{$greeting},\n\nIhre Brandschutzunterweisung läuft am {$dueDateFormatted} ab.\n\n" + . "Die Schulung dauert nur etwa 20 Minuten und kann bequem vom Handy oder PC aus durchgeführt werden.\n\n" + . "Bitte absolvieren Sie die Unterweisung innerhalb der nächsten zwei Monate über das Intranet.\n\n" + . 'Vielen Dank!'; - $brandschutzUrl = "https://" . $_SERVER['HTTP_HOST'] . "/intranet_repo/projects/brandschutz/brandschutz.php"; - send_intranet_mail($mail, $subject, $message, null, "intranet@nak-reinickendorf.de", "Zur Brandschutzunterweisung", $brandschutzUrl); - - // Zähler erhöhen - $stmtUpdate = $pdo->prepare("UPDATE brandschutz_training SET reminder_count = reminder_count + 1 WHERE user_id = ?"); - $stmtUpdate->execute([$userId]); - - // Log in erledigte Nachrichten - $sysSubject = "Brandschutz-Erinnerung gesendet: {$fullName}"; - $sysMessage = "Automatische Erinnerungsmail an {$fullName} ({$mail}) gesendet. Schulung fällig am {$dueDateFormatted}."; - $stmtLog = $pdo->prepare("INSERT INTO admin_messages (type, sender_name, sender_email, subject, message, status, processed_at, created_at) VALUES ('system', 'System', ?, ?, ?, 'done', NOW(), NOW())"); - $stmtLog->execute([$mail, $sysSubject, $sysMessage]); + send_intranet_mail( + $mail, + $subject, + $message, + null, + 'intranet@nak-reinickendorf.de', + 'Zur Brandschutzunterweisung', + $brandschutzUrl + ); + $incrementReminder->execute([$userId]); + brandschutz_log_admin_message( + $pdo, + 'System', + $mail, + "Brandschutz-Erinnerung gesendet: {$fullName}", + "Automatische Erinnerungsmail an {$fullName} ({$mail}) gesendet. Schulung fällig am {$dueDateFormatted}." + ); $log[] = "Erste Erinnerung an {$fullName} gesendet (fällig am {$dueDateFormatted})."; } - // Szenario 2: Frist überschritten - Monatliche Erinnerung an Nutzer - if ($daysOverdue > 0) { - $dueDateFormatted = !empty($lastTraining) ? date('d.m.Y', strtotime($dueDate)) : 'sofort'; - $monthsOverdue = (int)ceil($daysOverdue / 30); - + if ($actions['overdue']) { + $daysOverdue = (int) $status['days_overdue']; + $dueDateFormatted = $status['due_date'] === null ? 'sofort' : (string) $status['due_date_display']; + $reminderNumber = $reminderCount + 1; $subject = "Überfällig: Brandschutzunterweisung ({$daysOverdue} Tage)"; $message = "{$greeting},\n\nIhre Brandschutzunterweisung ist seit {$daysOverdue} Tagen überfällig"; if ($dueDateFormatted !== 'sofort') { $message .= " (Frist war der {$dueDateFormatted})"; } - $message .= ".\n\nDie Schulung dauert nur etwa 20 Minuten und kann bequem vom Handy oder PC aus durchgeführt werden.\n\n"; - $message .= "Bitte absolvieren Sie die Unterweisung umgehend über das Intranet.\n\n"; - $message .= "Dies ist die {$reminderCount}. Erinnerung zu dieser Schulung."; + $message .= ".\n\nDie Schulung dauert nur etwa 20 Minuten und kann bequem vom Handy oder PC aus durchgeführt werden.\n\n" + . "Bitte absolvieren Sie die Unterweisung umgehend über das Intranet.\n\n" + . "Dies ist die {$reminderNumber}. Erinnerung zu dieser Schulung."; - $brandschutzUrl = "https://" . $_SERVER['HTTP_HOST'] . "/intranet_repo/projects/brandschutz/brandschutz.php"; - send_intranet_mail($mail, $subject, $message, null, "intranet@nak-reinickendorf.de", "Zur Brandschutzunterweisung", $brandschutzUrl); + send_intranet_mail( + $mail, + $subject, + $message, + null, + 'intranet@nak-reinickendorf.de', + 'Zur Brandschutzunterweisung', + $brandschutzUrl + ); + $incrementReminder->execute([$userId]); - // Zähler erhöhen - $stmtUpdate = $pdo->prepare("UPDATE brandschutz_training SET reminder_count = reminder_count + 1 WHERE user_id = ?"); - $stmtUpdate->execute([$userId]); + brandschutz_log_admin_message( + $pdo, + 'System', + $mail, + "Brandschutz-Erinnerung gesendet: {$fullName} ({$daysOverdue} Tage überfällig)", + "Automatische Erinnerungsmail (Nr. {$reminderNumber}) an {$fullName} ({$mail}) gesendet. Schulung seit {$daysOverdue} Tagen überfällig." + ); + $log[] = "Erinnerung Nr.{$reminderNumber} an {$fullName} ({$daysOverdue} Tage überfällig)."; - // Log in erledigte Nachrichten - $sysSubject = "Brandschutz-Erinnerung gesendet: {$fullName} ({$daysOverdue} Tage überfällig)"; - $sysMessage = "Automatische Erinnerungsmail (Nr. " . ($reminderCount + 1) . ") an {$fullName} ({$mail}) gesendet. Schulung seit {$daysOverdue} Tagen überfällig."; - $stmtLog = $pdo->prepare("INSERT INTO admin_messages (type, sender_name, sender_email, subject, message, status, processed_at, created_at) VALUES ('system', 'System', ?, ?, ?, 'done', NOW(), NOW())"); - $stmtLog->execute([$mail, $sysSubject, $sysMessage]); - - $log[] = "Erinnerung Nr." . ($reminderCount + 1) . " an {$fullName} ({$daysOverdue} Tage überfällig)."; - - // Szenario 3: 4+ Monate überfällig - Eskalation an Brandschutzbeauftragte + Admins - if ($daysOverdue >= 120) { + if ($actions['escalate']) { + $nextAdminReminder = $adminReminderCount + 1; $adminSubject = "ESKALATION: Brandschutzunterweisung überfällig – {$fullName}"; - $adminMessage = "Die Brandschutzunterweisung von {$fullName} ({$mail}) ist seit {$daysOverdue} Tagen überfällig (mehr als 4 Monate).\n\n"; - $adminMessage .= "Bitte erinnern Sie die Person persönlich an die ausstehende Schulung.\n\n"; - $adminMessage .= "Bisherige Erinnerungsmails an den Nutzer: " . ($reminderCount + 1) . "\n"; - $adminMessage .= "Bisherige Admin-Eskalationen: " . ($adminReminderCount + 1); + $adminMessage = "Die Brandschutzunterweisung von {$fullName} ({$mail}) ist seit {$daysOverdue} Tagen überfällig (mehr als 4 Monate).\n\n" + . "Bitte erinnern Sie die Person persönlich an die ausstehende Schulung.\n\n" + . "Bisherige Erinnerungsmails an den Nutzer: {$reminderNumber}\n" + . "Bisherige Admin-Eskalationen: {$nextAdminReminder}"; - // Mail an Brandschutzbeauftragte send_brandschutzbeauftragter_mail($pdo, $adminSubject, $adminMessage); - - // Aktive (pending) Systemnachricht im Postfach - $dataJson = json_encode([ - 'action' => 'brandschutz_escalation', - 'user_id' => $userId, - 'user_name' => $fullName, - 'days_overdue' => $daysOverdue - ]); - $stmtAdmin = $pdo->prepare("INSERT INTO admin_messages (type, sender_name, sender_email, subject, message, status, created_at, data_json) VALUES ('system', 'System', ?, ?, ?, 'pending', NOW(), ?)"); - $stmtAdmin->execute([$mail, $adminSubject, $adminMessage, $dataJson]); - - // Admin-Zähler erhöhen - $stmtAdminCount = $pdo->prepare("UPDATE brandschutz_training SET admin_reminder_count = admin_reminder_count + 1 WHERE user_id = ?"); - $stmtAdminCount->execute([$userId]); - + brandschutz_log_admin_message( + $pdo, + 'System', + $mail, + $adminSubject, + $adminMessage, + 'pending', + [ + 'action' => 'brandschutz_escalation', + 'user_id' => $userId, + 'user_name' => $fullName, + 'days_overdue' => $daysOverdue, + ] + ); + $incrementAdminReminder->execute([$userId]); $log[] = "ESKALATION an Brandschutzbeauftragte für {$fullName} ({$daysOverdue} Tage überfällig)."; } } @@ -150,13 +144,8 @@ foreach ($users as $bsUser) { $pdo = null; -// Ausgabe/Logging -$output = "Brandschutz-Cron ausgeführt am " . date('d.m.Y H:i:s') . "\n"; +$output = 'Brandschutz-Cron ausgeführt am ' . date('d.m.Y H:i:s') . "\n"; $output .= count($users) . " Nutzer mit Brandschutz-Rolle geprüft.\n"; -if (!empty($log)) { - $output .= "\nAktionen:\n" . implode("\n", $log); -} else { - $output .= "\nKeine Aktionen erforderlich."; -} +$output .= $log === [] ? "\nKeine Aktionen erforderlich." : "\nAktionen:\n" . implode("\n", $log); echo $output . "\n"; diff --git a/web/intranet_repo/projects/brandschutz/util/brandschutz_helpers.php b/web/intranet_repo/projects/brandschutz/util/brandschutz_helpers.php new file mode 100644 index 0000000..a00f34a --- /dev/null +++ b/web/intranet_repo/projects/brandschutz/util/brandschutz_helpers.php @@ -0,0 +1,201 @@ +format('Y-m-d') === $value; +} + +function brandschutz_today(?DateTimeImmutable $today = null): DateTimeImmutable +{ + if ($today !== null) { + return $today->setTime(0, 0); + } + + return new DateTimeImmutable('today', new DateTimeZone('Europe/Berlin')); +} + +/** + * Berechnet den fachlichen Schulungsstatus an einer Stelle für Intranet, + * Adminpanel und Erinnerungsjob. + */ +function brandschutz_training_status(?string $lastTraining, ?DateTimeImmutable $today = null): array +{ + $today = brandschutz_today($today); + $lastTraining = trim((string) $lastTraining); + + if ($lastTraining === '' || !brandschutz_is_valid_date($lastTraining)) { + return [ + 'last_training' => null, + 'last_training_display' => 'Nie', + 'due_date' => null, + 'due_date_display' => 'Sofort fällig', + 'days_until_due' => -1, + 'days_overdue' => 1, + 'is_due' => true, + 'status_key' => 'never', + 'status_text' => 'Nie geschult', + 'status_class' => 'bs-status-overdue', + 'overdue_text' => 'Keine Schulung hinterlegt – sofort fällig', + 'overdue_class' => 'overdue', + ]; + } + + $trainingDate = new DateTimeImmutable($lastTraining, new DateTimeZone('Europe/Berlin')); + $dueDate = $trainingDate->modify('+' . BRANDSCHUTZ_VALIDITY_DAYS . ' days'); + // Bestehende 24h-Tagesberechnung bewusst beibehalten (inkl. DST-Verhalten). + $daysUntilDue = (int) (($dueDate->getTimestamp() - $today->getTimestamp()) / 86400); + $daysOverdue = $daysUntilDue <= 0 ? abs($daysUntilDue) : 0; + + if ($daysUntilDue > 30) { + $statusKey = 'current'; + $statusText = 'Aktuell'; + $statusClass = 'bs-status-ok'; + } elseif ($daysUntilDue > 0) { + $statusKey = 'warning'; + $statusText = 'Bald fällig'; + $statusClass = 'bs-status-warning'; + } elseif ($daysUntilDue > -120) { + $statusKey = 'overdue'; + $statusText = $daysOverdue . ' Tage überfällig'; + $statusClass = 'bs-status-overdue'; + } else { + $statusKey = 'critical'; + $statusText = $daysOverdue . ' Tage überfällig'; + $statusClass = 'bs-status-critical'; + } + + return [ + 'last_training' => $lastTraining, + 'last_training_display' => $trainingDate->format('d.m.Y'), + 'due_date' => $dueDate->format('Y-m-d'), + 'due_date_display' => $dueDate->format('d.m.Y'), + 'days_until_due' => $daysUntilDue, + 'days_overdue' => $daysOverdue, + 'is_due' => $daysUntilDue <= 0, + 'status_key' => $statusKey, + 'status_text' => $statusText, + 'status_class' => $statusClass, + 'overdue_text' => $daysUntilDue > 0 + ? 'Nicht überfällig (' . $daysUntilDue . ' Tage verbleibend)' + : $daysOverdue . ' Tage überfällig', + 'overdue_class' => $daysUntilDue > 0 ? 'ok' : 'overdue', + ]; +} + +function brandschutz_reminder_actions(array $status, int $reminderCount): array +{ + $daysUntilDue = (int) ($status['days_until_due'] ?? 0); + $daysOverdue = (int) ($status['days_overdue'] ?? 0); + + return [ + 'pre_due' => $daysUntilDue >= 0 && $daysUntilDue <= 30 && $reminderCount === 0, + 'overdue' => $daysOverdue > 0, + 'escalate' => $daysOverdue >= 120, + ]; +} + +function brandschutz_display_name(array $user, string $fallback = 'Unbekannt'): string +{ + $fullName = trim((string) ($user['first_name'] ?? '') . ' ' . (string) ($user['last_name'] ?? '')); + if ($fullName !== '') { + return $fullName; + } + + $mail = trim((string) ($user['mail'] ?? '')); + return $mail !== '' ? $mail : $fallback; +} + +function brandschutz_greeting(array $user): string +{ + $firstName = trim((string) ($user['first_name'] ?? '')); + return $firstName !== '' ? 'Hallo ' . $firstName : 'Hallo'; +} + +function brandschutz_training_url(?string $host = null): string +{ + $host = trim((string) ($host ?? ($_SERVER['HTTP_HOST'] ?? ''))); + $host = $host !== '' ? $host : 'localhost'; + + return 'https://' . $host . '/intranet_repo/projects/brandschutz/brandschutz.php'; +} + +function brandschutz_store_training_date(PDO $pdo, int $userId, string $date, bool $resetCounters): void +{ + if ($userId <= 0 || !brandschutz_is_valid_date($date)) { + throw new InvalidArgumentException('Ungültiges Schulungsdatum.'); + } + + if ($resetCounters) { + $sql = 'INSERT INTO brandschutz_training ' + . '(user_id, last_training_date, reminder_count, admin_reminder_count) ' + . 'VALUES (?, ?, 0, 0) ' + . 'ON CONFLICT (user_id) DO UPDATE SET ' + . 'last_training_date = EXCLUDED.last_training_date, reminder_count = 0, admin_reminder_count = 0'; + } else { + $sql = 'INSERT INTO brandschutz_training ' + . '(user_id, last_training_date, reminder_count, admin_reminder_count) ' + . 'VALUES (?, ?, 0, 0) ' + . 'ON CONFLICT (user_id) DO UPDATE SET last_training_date = EXCLUDED.last_training_date'; + } + + $statement = $pdo->prepare($sql); + $statement->execute([$userId, $date]); +} + +function brandschutz_log_admin_message( + PDO $pdo, + string $senderName, + string $senderEmail, + string $subject, + string $message, + string $status = 'done', + ?array $data = null +): void { + if (!in_array($status, ['done', 'pending'], true)) { + throw new InvalidArgumentException('Ungültiger Nachrichtenstatus.'); + } + + $dataJson = $data === null ? null : json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + + if ($status === 'done') { + $statement = $pdo->prepare( + "INSERT INTO admin_messages " + . "(type, sender_name, sender_email, subject, message, status, processed_at, created_at, data_json) " + . "VALUES ('system', ?, ?, ?, ?, 'done', NOW(), NOW(), ?)" + ); + } else { + $statement = $pdo->prepare( + "INSERT INTO admin_messages " + . "(type, sender_name, sender_email, subject, message, status, processed_at, created_at, data_json) " + . "VALUES ('system', ?, ?, ?, ?, 'pending', NULL, NOW(), ?)" + ); + } + + $statement->execute([$senderName, $senderEmail, $subject, $message, $dataJson]); +} diff --git a/web/intranet_repo/projects/contentpanel/insertion/insert.php b/web/intranet_repo/projects/contentpanel/insertion/insert.php index 83866ab..64e8789 100644 --- a/web/intranet_repo/projects/contentpanel/insertion/insert.php +++ b/web/intranet_repo/projects/contentpanel/insertion/insert.php @@ -1,952 +1,327 @@ - Text per pdftotext (Poppler) */ -function pdfToText($pdfFullPath) { - $bin = trim(shell_exec('command -v pdftotext')); - if ($bin === '') { $_SESSION['meldung_gottesdienst'] = "Fehler: 'pdftotext' nicht gefunden."; return false; } - $tmpTxt = tempnam(sys_get_temp_dir(), 'lg_').'.txt'; - $cmd = escapeshellcmd($bin).' -layout -enc UTF-8 '.escapeshellarg($pdfFullPath).' '.escapeshellarg($tmpTxt).' 2>&1'; - exec($cmd, $out, $ret); - if ($ret !== 0) { @unlink($tmpTxt); $_SESSION['meldung_gottesdienst'] = "Fehler beim PDF-Import:\n".htmlspecialchars(implode("\n", $out)); return false; } - $txt = file_get_contents($tmpTxt); - @unlink($tmpTxt); - return $txt; -} - -function parseLeitgedanken($textRaw) { - // Zeilenenden normalisieren + Silbentrennungen (…-\n…) zusammenziehen - $text = preg_replace("/\r\n?/", "\n", $textRaw); - $text = preg_replace("/(?<=\p{L})-\n(?=\p{L})/u", "", $text); - $lines = explode("\n", $text); - - // Abschnittsüberschriften mit Datum am Zeilenende - $dateHeaderRegex = '/(?P.*?)(?P\d{1,2})(?:\.\s*\/\s*(?P\d{1,2}))?\.(?P\d{1,2})\.(?P\d{4})\s*$/u'; - - // Whitelist der (deutschen) Buchkürzel - $BOOKS = '(?:Mo|Sam|Kön|Chr|Dan|Ps|Spr|Pred|Hld|Hi|Jes|Jer|Klgl|Hes|Ez|Hos|Joel|Am|Ob|Mi|Nah|Hab|Zef|Hag|Sach|Mal|Rut|Ri|Jos|Esra|Neh|Est|' - . 'Mt|Mk|Lk|Joh|Apg|Röm|Kor|Gal|Eph|Phil|Kol|Thess|Tim|Tit|Phlm|Hebr|Jak|Petr|Joh|Jud|Offb)'; - - // Referenz am Zeilenende - $refRegex = '/(?P(?:[1-3]\.?\s*)?' . $BOOKS . '\s*\d{1,3}\s*,\s*\d{1,3}(?:[–\-]\d{1,3}(?:[a-z])?|(?:\.\d{1,3})+(?:[a-z])?|[a-z])?)\s*$/u'; - - // Abschnittsgrenzen sammeln - $sections = []; - for ($i = 0; $i < count($lines); $i++) { - if (preg_match($dateHeaderRegex, $lines[$i], $m)) { - $d = str_pad((int)$m['d1'], 2, '0', STR_PAD_LEFT); - $mn = str_pad((int)$m['m'], 2, '0', STR_PAD_LEFT); - $y = (int)$m['y']; - $sections[] = ['start'=>$i, 'date'=>sprintf('%04d-%02d-%02d',$y,$mn,$d)]; - } - } - if (empty($sections)) return []; - $sections[array_key_last($sections)]['end'] = count($lines); - for ($s = 0; $s < count($sections)-1; $s++) { $sections[$s]['end'] = $sections[$s+1]['start']; } - - $out = []; - foreach ($sections as $sec) { - for ($i = $sec['start']; $i < $sec['end']; $i++) { - $line = rtrim($lines[$i]); if ($line === '') continue; - if (preg_match($refRegex, $line, $mRef)) { - $beforeRef = trim(preg_replace($refRegex, '', $line)); - $blkPrev = []; - for ($k = $i - 1; $k >= $sec['start'] && $k >= $i - 15; $k--) { - $pl = rtrim($lines[$k]); if ($pl === '') break; - if (preg_match($refRegex, $pl)) break; - $blkPrev[] = $pl; - } - $block = array_reverse($blkPrev); - if ($beforeRef !== '') $block[] = $beforeRef; - $vers = trim(preg_replace('/\s+/', ' ', implode(' ', $block))); - if ($vers !== '') { - $out[] = ['bibeldatum'=>$sec['date'], 'bibelwort'=>trim($mRef['ref']), 'bibelvers'=>$vers]; - break; // pro Abschnitt nur das erste Bibelwort - } - } - } - } - // Dubletten raus - $uniq=[]; $final=[]; - foreach ($out as $e) { $key=$e['bibeldatum'].'|'.$e['bibelwort']; if (!isset($uniq[$key])) { $uniq[$key]=1; $final[]=$e; } } - return $final; -} - -// Hilfsfunktion, um PDF in PNG umzuwandeln -function convertPDFtoPNG($pdfFilePath, $outputDirectory, $baseFileName) { - if (!is_file($pdfFilePath)) { - error_log('PDF-Datei nicht gefunden: ' . $pdfFilePath); - return false; - } - - $outputFilePath = $outputDirectory . '/' . $baseFileName . '_page_%d.png'; - - $command = - 'gs -dSAFER -dBATCH -dNOPAUSE ' - . '-sDEVICE=png16m -r300 ' - . '-sOutputFile=' . escapeshellarg($outputFilePath) . ' ' - . escapeshellarg($pdfFilePath) - . ' 2>&1'; - - $output = []; - $returnCode = 0; - exec($command, $output, $returnCode); - - if ($returnCode !== 0) { - error_log( - 'Ghostscript-Konvertierung fehlgeschlagen: ' - . implode("\n", $output) - ); - return false; - } - - return true; -} - -// LG-PDF importieren -> direkt alles in DB -if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['leitgedanken_pdf'])) { - if ($_FILES['leitgedanken_pdf']['error'] !== UPLOAD_ERR_OK) { - $_SESSION['meldung_gottesdienst'] = 'Fehler beim Upload der Leitgedanken-PDF.'; - } else { - $ext = strtolower(pathinfo($_FILES['leitgedanken_pdf']['name'], PATHINFO_EXTENSION)); - if ($ext !== 'pdf') { - $_SESSION['meldung_gottesdienst'] = 'Nur PDF-Dateien sind für den Import erlaubt.'; - } else { - $txt = pdfToText($_FILES['leitgedanken_pdf']['tmp_name']); - if ($txt !== false) { - $items = parseLeitgedanken($txt); - - // vorbereitete Statements - $stmtExists = $pdo->prepare("SELECT 1 FROM gottesdienst WHERE bibeldatum = ? AND bibelwort = ? LIMIT 1"); - $stmtInsert = $pdo->prepare("INSERT INTO gottesdienst (bibelwort, bibelvers, bibeldatum) VALUES (?, ?, ?)"); - - $ok=0; $dup=0; - foreach ($items as $it) { - $stmtExists->execute([$it['bibeldatum'], $it['bibelwort']]); - if ($stmtExists->fetchColumn() !== false) { $dup++; continue; } - - $stmtInsert->execute([$it['bibelwort'], $it['bibelvers'], $it['bibeldatum']]); $ok++; - } - - if ($ok > 0) { - $_SESSION['meldung_gottesdienst'] = "Leitgedanken importiert: $ok Einträge angelegt".($dup?(", $dup dupliziert übersprungen"):"")."."; - } else { - $_SESSION['meldung_gottesdienst'] = "Keine neuen Einträge erkannt.".($dup?(" ($dup Duplikate)"):""); - } - } - } - } - header("Location: ".$_SERVER['PHP_SELF']); - exit(); -} - -// Aushang manuell löschen -if ( - $_SERVER['REQUEST_METHOD'] === 'POST' && - isset($_POST['delete_file_base']) -) { - $fileBase = (string) $_POST['delete_file_base']; - - if (!isValidNewsFileBase($fileBase)) { - $_SESSION['meldung_dateien'] = 'Ungültiger Dateiname.'; - } elseif (deleteNewsFiles($fileBase)) { - $_SESSION['meldung_dateien'] = 'Aushang wurde vollständig gelöscht.'; - } else { - $_SESSION['meldung_dateien'] = - 'Aushang konnte nicht vollständig gelöscht werden. Bitte Logs prüfen.'; - } - - header('Location: insert.php#dateien'); - exit(); -} - -// Neuen Aushang hochladen und direkt in PNG-Seiten umwandeln -if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['fileToUpload'])) { - $date = (string) ($_POST['date'] ?? ''); - $dateObject = DateTimeImmutable::createFromFormat('!Y-m-d', $date); - - if ($dateObject === false || $dateObject->format('Y-m-d') !== $date) { - $_SESSION['meldung_dateien'] = 'Ungültiges Verfallsdatum.'; - header('Location: insert.php#dateien'); - exit(); - } - - $name = trim(sanitizeFileName((string) ($_POST['name'] ?? '')), '_-'); - - if ($name === '') { - $_SESSION['meldung_dateien'] = 'Bitte einen gültigen Titel eingeben.'; - header('Location: insert.php#dateien'); - exit(); - } - - if ( - $_FILES['fileToUpload']['error'] !== UPLOAD_ERR_OK || - strtolower(pathinfo($_FILES['fileToUpload']['name'], PATHINFO_EXTENSION)) !== 'pdf' - ) { - $_SESSION['meldung_dateien'] = 'Bitte eine gültige PDF-Datei hochladen.'; - header('Location: insert.php#dateien'); - exit(); - } - - $baseFileName = $date . '-' . $name; - - if (!is_dir(NEWS_PNG_DIRECTORY) && !mkdir(NEWS_PNG_DIRECTORY, 0755, true)) { - $_SESSION['meldung_dateien'] = 'Das Aushang-Verzeichnis konnte nicht angelegt werden.'; - header('Location: insert.php#dateien'); - exit(); - } - - if (!convertPDFtoPNG( - $_FILES['fileToUpload']['tmp_name'], - NEWS_PNG_DIRECTORY, - $baseFileName - )) { - deleteNewsFiles($baseFileName); - $_SESSION['meldung_dateien'] = 'Fehler bei der Umwandlung der PDF.'; - header('Location: insert.php#dateien'); - exit(); - } - - $_SESSION['meldung_dateien'] = 'Aushang wurde erfolgreich hochgeladen.'; - header('Location: insert.php#dateien'); - exit(); -} - - -// Löschen eines Gottesdienstes -if (isset($_POST['delete_id'])) { - $delete_id = $_POST['delete_id']; - - // Löschanfrage vorbereiten - try { - $sql_delete = "DELETE FROM gottesdienst WHERE id = ?"; - $stmt = $pdo->prepare($sql_delete); - $stmt->execute([$delete_id]); - $_SESSION['meldung_gottesdienst'] = "Eintrag erfolgreich gelöscht!"; - header("Location: " . $_SERVER['PHP_SELF']); // Seite neu laden - exit(); // Skript nach dem Redirect beenden - } catch (Exception $e) { - $_SESSION['meldung_gottesdienst'] = "Fehler beim Löschen: " . $e->getMessage(); - } -} - -// Gottesdienst anlegen (ohne EL) -if ($_SERVER["REQUEST_METHOD"] == "POST" && isset($_POST['bibelwort']) && isset($_POST['bibelvers']) && isset($_POST['bibeldatum'])) { - $bibelwort = $_POST['bibelwort']; - $bibelvers = $_POST['bibelvers']; - $bibeldatum = $_POST['bibeldatum']; - - try { - $sql = "INSERT INTO gottesdienst (bibelwort, bibelvers, bibeldatum) VALUES (?, ?, ?)"; - $stmt = $pdo->prepare($sql); - $stmt->execute([$bibelwort, $bibelvers, $bibeldatum]); - setlocale(LC_TIME, 'de_DE.UTF-8'); - $formattedDate = strftime('%d. %B', strtotime($bibeldatum)); - $_SESSION['meldung_gottesdienst'] = "Gottesdienst am " . $formattedDate . " erfolgreich angelegt!"; - header("Location: " . $_SERVER['PHP_SELF']); exit(); - } catch (Exception $e) { - $_SESSION['meldung_gottesdienst'] = "Fehler beim Einfügen: " . $e->getMessage(); - } -} - - -// Ansage anlegen -if ($_SERVER["REQUEST_METHOD"] == "POST" && isset($_POST['ansage_datum_von'])) { - $ansage_datum_von = $_POST['ansage_datum_von']; - $ansage_datum_bis = !empty($_POST['ansage_datum_bis']) ? $_POST['ansage_datum_bis'] : NULL; - $ansage_uhrzeit = !empty($_POST['ansage_uhrzeit']) ? $_POST['ansage_uhrzeit'] : NULL; - $ansage_ort = $_POST['ansage_ort']; - $ansage_titel = $_POST['ansage_titel']; - $ansage_untertitel = $_POST['ansage_untertitel']; - - try { - // SQL-Anweisung - $sql = "INSERT INTO ansagen (ansage_datum_von, ansage_datum_bis, ansage_uhrzeit, ansage_ort, ansage_titel, ansage_untertitel) - VALUES (?, ?, ?, ?, ?, ?)"; - - // Statement vorbereiten - $stmt = $pdo->prepare($sql); - - // Statement ausführen - $stmt->execute([$ansage_datum_von, $ansage_datum_bis, $ansage_uhrzeit, $ansage_ort, $ansage_titel, $ansage_untertitel]); - setlocale(LC_TIME, 'de_DE.UTF-8'); - $formattedDate = strftime('%d. %B', strtotime($ansage_datum_von)); - $_SESSION['meldung_ansage'] = $ansage_titel . " am " . $formattedDate . " erfolgreich angelegt!"; - header("Location: " . $_SERVER['PHP_SELF']); - exit(); - } catch (Exception $e) { - $_SESSION['meldung_ansage'] = "Fehler beim Einfügen: " . $e->getMessage(); - } -} - -// Löschen einer Ansage -if (isset($_POST['delete_ansage_id'])) { - $delete_ansage_id = $_POST['delete_ansage_id']; - - // Löschanfrage vorbereiten - try { - $sql_delete_ansage = "DELETE FROM ansagen WHERE id = ?"; - $stmt = $pdo->prepare($sql_delete_ansage); - $stmt->execute([$delete_ansage_id]); - $_SESSION['meldung_ansage'] = "Ansage erfolgreich gelöscht!"; - header("Location: " . $_SERVER['PHP_SELF']); - exit(); - } catch (Exception $e) { - $_SESSION['meldung_ansage'] = "Fehler beim Löschen: " . $e->getMessage(); - } -} - -// Meldungen aus der Session übernehmen -if (isset($_SESSION['meldung_gottesdienst'])) { - $meldung_gottesdienst = $_SESSION['meldung_gottesdienst']; - unset($_SESSION['meldung_gottesdienst']); -} - -if (isset($_SESSION['meldung_ansage'])) { - $meldung_ansage = $_SESSION['meldung_ansage']; - unset($_SESSION['meldung_ansage']); -} - -if (isset($_SESSION['meldung_dateien'])) { - $meldung_dateien = $_SESSION['meldung_dateien']; - unset($_SESSION['meldung_dateien']); -} - -$heute = date('Y-m-d'); - -// Gottesdienste aus der Datenbank abrufen -$sql_fetch_gottesdienste = "SELECT id, bibelwort, bibelvers, TO_CHAR(bibeldatum, 'DD.MM.YYYY') AS datum FROM gottesdienst WHERE bibeldatum >= ? ORDER BY bibeldatum ASC"; -$stmt = $pdo->prepare($sql_fetch_gottesdienste); -$stmt->execute([$heute]); -$planung_gottesdienste = $stmt->fetchAll(PDO::FETCH_ASSOC); - - -// Ansagen aus der Datenbank abrufen -$sql_fetch_ansagen = "SELECT id, ansage_untertitel, ansage_titel, ansage_ort, ansage_uhrzeit, TO_CHAR(ansage_datum_von, 'DD.MM.YYYY') AS datum_von, TO_CHAR(ansage_datum_bis, 'DD.MM.YYYY') AS datum_bis FROM ansagen WHERE ansage_datum_von >= ? OR ansage_datum_bis >= ? ORDER BY ansage_datum_von ASC"; -$stmt = $pdo->prepare($sql_fetch_ansagen); -$stmt->execute([$heute, $heute]); -$planung_ansagen = $stmt->fetchAll(PDO::FETCH_ASSOC); - - -// Schließe die Datenbankverbindung -$pdo = null; -?> - - - - - - - - - - -
- Schwarzes Brett -
- -
- - -
-
Gottesdienst
-
Ansagen
-
Dateiverwaltung
-
- - - - -
- - - -
- - - -
-
- - Geplante Gottesdienste -
-
- 0) : ?> - -
- -
- - -
-
- - -
-
- - -
-
- - Leitgedanken importieren -
-
-
-
- - -
-
- -
-
-
-
- - -
-
- - Neuen Gottesdienst planen -
-
-
-
- - -
-
- - -
-
- - -
-
- -
-
-
-
-
- - - - - -
- - - -
- - - -
-
- - Geplante Ansagen -
-
- 0) : ?> - -
- -
- - -
-
- - -
-
- - -
-
- - Neue Ansage planen -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
-
-
-
- - - - - -
- - - -
- - - -
-
- - Aktuelle Aushänge -
-
- Keine Aushänge vorhanden.

'; - } else { - foreach ($aushangFiles as $file) { - $baseFileName = basename($file, '_page_1.png'); - $expiryDate = substr($baseFileName, 0, 10); - $title = substr($baseFileName, 11); - $numPages = count(glob(NEWS_PNG_DIRECTORY . '/' . $baseFileName . '_page_*.png') ?: []); - $formattedExpiryDate = DateTime::createFromFormat('!Y-m-d', $expiryDate)?->format('d.m.Y') ?? $expiryDate; - $safeBase = htmlspecialchars($baseFileName, ENT_QUOTES, 'UTF-8'); - - echo '
'; - echo 'Aushang'; - echo '
' . htmlspecialchars($title) . '
'; - echo '
' . $numPages . ($numPages === 1 ? ' Seite' : ' Seiten') . ' · Verfällt ' . $formattedExpiryDate . '
'; - echo '
'; - echo ''; - echo ''; - echo '
'; - echo '
'; - } - } - ?> -
-
- - -
-
- - Neuen Aushang hochladen -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
-
- -
- - - - - - + contentpanel_import_leitgedanken($pdo, $_FILES['leitgedanken_pdf'] ?? []), + 'create_service' => contentpanel_create_service($pdo, $_POST), + 'delete_service' => contentpanel_delete_service($pdo, $_POST['id'] ?? null), + 'create_announcement' => contentpanel_create_announcement($pdo, $_POST), + 'delete_announcement' => contentpanel_delete_announcement($pdo, $_POST['id'] ?? null), + 'upload_news' => contentpanel_upload_news($_POST, $_FILES['fileToUpload'] ?? []), + 'delete_news' => contentpanel_delete_news_files((string) ($_POST['file_base'] ?? '')) + ? 'Aushang wurde vollständig gelöscht.' + : throw new InvalidArgumentException('Ungültiger Dateiname oder Aushang konnte nicht vollständig gelöscht werden.'), + default => throw new InvalidArgumentException('Unbekannte Aktion.'), + }; + } catch (InvalidArgumentException $exception) { + $message = $exception->getMessage(); + $flashType = 'error'; + } catch (Throwable $exception) { + error_log('Contentpanel action failed (' . $action . '): ' . $exception->getMessage()); + $message = 'Die Aktion konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.'; + $flashType = 'error'; + } + + $_SESSION['contentpanel_flash'] = [ + 'tab' => $tab, + 'message' => $message, + 'type' => $flashType, + ]; + + header('Location: insert.php#' . rawurlencode($tab), true, 303); + exit(); +} + +$flash = $_SESSION['contentpanel_flash'] ?? null; +unset($_SESSION['contentpanel_flash']); + +$today = date('Y-m-d'); +$services = contentpanel_fetch_services($pdo, $today); +$announcements = contentpanel_fetch_announcements($pdo, $today); +$newsItems = contentpanel_list_news(); +$pdo = null; + +$flashTab = is_array($flash) ? (string) ($flash['tab'] ?? '') : ''; +$flashMessage = is_array($flash) ? (string) ($flash['message'] ?? '') : ''; +$flashType = is_array($flash) ? (string) ($flash['type'] ?? '') : ''; +?> + + + + + + + + + + +
+ Schwarzes Brett +
+ +
+
+ + + +
+ +
+ +
+ +
+ + +
+
+ + Geplante Gottesdienste +
+
+ +
Keine Einträge vorhanden.
+ + +
+ +
+ + + + +
+
+ + +
+
+ +
+
+ + Leitgedanken importieren +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+ + Neuen Gottesdienst planen +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ +
+ +
+ +
+ + +
+
+ + Geplante Ansagen +
+
+ +
Keine Einträge vorhanden.
+ + +
+ +
+ + + + +
+
+ + +
+
+ +
+
+ + Neue Ansage planen +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ +
+ +
+ +
+ + +
+
+ + Aktuelle Aushänge +
+
+ +
Keine Aushänge vorhanden.
+ + +
+ Aushang +
+
+ · Verfällt +
+
+ + + + +
+
+ + +
+
+ +
+
+ + Neuen Aushang hochladen +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ + + + diff --git a/web/intranet_repo/projects/contentpanel/insertion/news_cleanup.php b/web/intranet_repo/projects/contentpanel/insertion/news_cleanup.php deleted file mode 100644 index dc7fe84..0000000 --- a/web/intranet_repo/projects/contentpanel/insertion/news_cleanup.php +++ /dev/null @@ -1,49 +0,0 @@ -format('Y-m-d') === $dateString && - $today > $expiryDate - ) { - deleteNewsFiles($fileBase); - } - } -} diff --git a/web/intranet_repo/projects/contentpanel/insertion/util/contentpanel_helpers.php b/web/intranet_repo/projects/contentpanel/insertion/util/contentpanel_helpers.php new file mode 100644 index 0000000..7a1bd9c --- /dev/null +++ b/web/intranet_repo/projects/contentpanel/insertion/util/contentpanel_helpers.php @@ -0,0 +1,574 @@ + 'ansagen', + 'upload_news', 'delete_news' => 'dateien', + default => 'gottesdienst', + }; +} + +function contentpanel_required_text(array $input, string $key, string $message): string +{ + $value = trim((string) ($input[$key] ?? '')); + if ($value === '') { + throw new InvalidArgumentException($message); + } + + return $value; +} + +function contentpanel_date($value, string $message): DateTimeImmutable +{ + $raw = trim((string) $value); + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $raw); + $errors = DateTimeImmutable::getLastErrors(); + + if ( + $date === false + || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) + || $date->format('Y-m-d') !== $raw + ) { + throw new InvalidArgumentException($message); + } + + return $date; +} + +function contentpanel_optional_date($value, string $message): ?DateTimeImmutable +{ + $raw = trim((string) $value); + return $raw === '' ? null : contentpanel_date($raw, $message); +} + +function contentpanel_optional_time($value, string $message): ?string +{ + $raw = trim((string) $value); + if ($raw === '') { + return null; + } + + if (preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/D', $raw) !== 1) { + throw new InvalidArgumentException($message); + } + + return $raw; +} + +function contentpanel_positive_id($value): int +{ + $id = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + if ($id === false) { + throw new InvalidArgumentException('Ungültiger Eintrag.'); + } + + return (int) $id; +} + +function contentpanel_format_message_date(DateTimeInterface $date): string +{ + static $months = [ + 1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', + ]; + + return sprintf('%02d. %s', (int) $date->format('d'), $months[(int) $date->format('n')]); +} + +function contentpanel_format_display_date($value): string +{ + $raw = trim((string) $value); + if ($raw === '') { + return ''; + } + + $date = DateTimeImmutable::createFromFormat('!Y-m-d', substr($raw, 0, 10)); + return $date ? $date->format('d.m.Y') : $raw; +} + +function contentpanel_find_binary(string $name): ?string +{ + $output = []; + $status = 0; + exec('command -v ' . escapeshellarg($name) . ' 2>/dev/null', $output, $status); + + if ($status !== 0 || empty($output[0])) { + return null; + } + + return trim((string) $output[0]); +} + +function contentpanel_validate_pdf_upload(array $file, string $uploadError, string $typeError): string +{ + if ((int) ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + throw new InvalidArgumentException($uploadError); + } + + $name = (string) ($file['name'] ?? ''); + $tmpPath = (string) ($file['tmp_name'] ?? ''); + if (strtolower(pathinfo($name, PATHINFO_EXTENSION)) !== 'pdf' || !is_file($tmpPath)) { + throw new InvalidArgumentException($typeError); + } + + $prefix = file_get_contents($tmpPath, false, null, 0, 1024); + if ($prefix === false || strpos($prefix, '%PDF-') === false) { + throw new InvalidArgumentException($typeError); + } + + return $tmpPath; +} + +function contentpanel_pdf_to_text(string $pdfPath): string +{ + $binary = contentpanel_find_binary('pdftotext'); + if ($binary === null) { + throw new RuntimeException("'pdftotext' wurde nicht gefunden."); + } + + $tempPath = tempnam(sys_get_temp_dir(), 'contentpanel_lg_'); + if ($tempPath === false) { + throw new RuntimeException('Temporäre Datei konnte nicht angelegt werden.'); + } + + try { + $command = escapeshellarg($binary) + . ' -layout -enc UTF-8 ' + . escapeshellarg($pdfPath) . ' ' + . escapeshellarg($tempPath) + . ' 2>&1'; + + $output = []; + $status = 0; + exec($command, $output, $status); + if ($status !== 0) { + throw new RuntimeException('pdftotext fehlgeschlagen: ' . implode("\n", $output)); + } + + $text = file_get_contents($tempPath); + if ($text === false) { + throw new RuntimeException('Der extrahierte PDF-Text konnte nicht gelesen werden.'); + } + + return $text; + } finally { + @unlink($tempPath); + } +} + +function contentpanel_parse_leitgedanken(string $rawText): array +{ + $text = preg_replace("/\r\n?/", "\n", $rawText); + $text = preg_replace("/(?<=\p{L})-\n(?=\p{L})/u", '', (string) $text); + $lines = explode("\n", (string) $text); + + $dateHeaderRegex = '/(?P.*?)(?P\d{1,2})(?:\.\s*\/\s*(?P\d{1,2}))?\.(?P\d{1,2})\.(?P\d{4})\s*$/u'; + $books = '(?:Mo|Sam|Kön|Chr|Dan|Ps|Spr|Pred|Hld|Hi|Jes|Jer|Klgl|Hes|Ez|Hos|Joel|Am|Ob|Mi|Nah|Hab|Zef|Hag|Sach|Mal|Rut|Ri|Jos|Esra|Neh|Est|' + . 'Mt|Mk|Lk|Joh|Apg|Röm|Kor|Gal|Eph|Phil|Kol|Thess|Tim|Tit|Phlm|Hebr|Jak|Petr|Joh|Jud|Offb)'; + $referenceRegex = '/(?P(?:[1-3]\.?\s*)?' . $books . '\s*\d{1,3}\s*,\s*\d{1,3}(?:[–\-]\d{1,3}(?:[a-z])?|(?:\.\d{1,3})+(?:[a-z])?|[a-z])?)\s*$/u'; + + $sections = []; + foreach ($lines as $index => $line) { + if (!preg_match($dateHeaderRegex, $line, $match)) { + continue; + } + + $day = str_pad((string) (int) $match['d1'], 2, '0', STR_PAD_LEFT); + $month = str_pad((string) (int) $match['m'], 2, '0', STR_PAD_LEFT); + $sections[] = [ + 'start' => $index, + 'date' => sprintf('%04d-%s-%s', (int) $match['y'], $month, $day), + ]; + } + + if ($sections === []) { + return []; + } + + foreach ($sections as $index => &$section) { + $section['end'] = $sections[$index + 1]['start'] ?? count($lines); + } + unset($section); + + $entries = []; + foreach ($sections as $section) { + for ($i = $section['start']; $i < $section['end']; $i++) { + $line = rtrim($lines[$i]); + if ($line === '' || !preg_match($referenceRegex, $line, $referenceMatch)) { + continue; + } + + $beforeReference = trim((string) preg_replace($referenceRegex, '', $line)); + $previousLines = []; + for ($k = $i - 1; $k >= $section['start'] && $k >= $i - 15; $k--) { + $previous = rtrim($lines[$k]); + if ($previous === '' || preg_match($referenceRegex, $previous)) { + break; + } + $previousLines[] = $previous; + } + + $verseLines = array_reverse($previousLines); + if ($beforeReference !== '') { + $verseLines[] = $beforeReference; + } + + $verse = trim((string) preg_replace('/\s+/', ' ', implode(' ', $verseLines))); + if ($verse !== '') { + $entries[] = [ + 'bibeldatum' => $section['date'], + 'bibelwort' => trim($referenceMatch['ref']), + 'bibelvers' => $verse, + ]; + } + break; + } + } + + $unique = []; + $result = []; + foreach ($entries as $entry) { + $key = $entry['bibeldatum'] . '|' . $entry['bibelwort']; + if (!isset($unique[$key])) { + $unique[$key] = true; + $result[] = $entry; + } + } + + return $result; +} + +function contentpanel_import_leitgedanken(PDO $pdo, array $file): string +{ + $pdfPath = contentpanel_validate_pdf_upload( + $file, + 'Fehler beim Upload der Leitgedanken-PDF.', + 'Nur PDF-Dateien sind für den Import erlaubt.' + ); + $entries = contentpanel_parse_leitgedanken(contentpanel_pdf_to_text($pdfPath)); + + if ($entries === []) { + return 'Keine neuen Einträge erkannt.'; + } + + $exists = $pdo->prepare('SELECT 1 FROM gottesdienst WHERE bibeldatum = ? AND bibelwort = ? LIMIT 1'); + $insert = $pdo->prepare('INSERT INTO gottesdienst (bibelwort, bibelvers, bibeldatum) VALUES (?, ?, ?)'); + $created = 0; + $duplicates = 0; + $ownsTransaction = !$pdo->inTransaction(); + + if ($ownsTransaction) { + $pdo->beginTransaction(); + } + + try { + foreach ($entries as $entry) { + $exists->execute([$entry['bibeldatum'], $entry['bibelwort']]); + if ($exists->fetchColumn() !== false) { + $duplicates++; + continue; + } + + $insert->execute([$entry['bibelwort'], $entry['bibelvers'], $entry['bibeldatum']]); + $created++; + } + + if ($ownsTransaction) { + $pdo->commit(); + } + } catch (Throwable $exception) { + if ($ownsTransaction && $pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $exception; + } + + if ($created === 0) { + return 'Keine neuen Einträge erkannt.' . ($duplicates > 0 ? " ($duplicates Duplikate)" : ''); + } + + return "Leitgedanken importiert: $created Einträge angelegt" + . ($duplicates > 0 ? ", $duplicates dupliziert übersprungen" : '') + . '.'; +} + +function contentpanel_create_service(PDO $pdo, array $input): string +{ + $date = contentpanel_date($input['bibeldatum'] ?? '', 'Bitte ein gültiges Gottesdienst-Datum angeben.'); + $reference = contentpanel_required_text($input, 'bibelwort', 'Bitte eine Bibelstelle angeben.'); + $verse = contentpanel_required_text($input, 'bibelvers', 'Bitte einen Bibelvers angeben.'); + + $statement = $pdo->prepare('INSERT INTO gottesdienst (bibelwort, bibelvers, bibeldatum) VALUES (?, ?, ?)'); + $statement->execute([$reference, $verse, $date->format('Y-m-d')]); + + return 'Gottesdienst am ' . contentpanel_format_message_date($date) . ' erfolgreich angelegt!'; +} + +function contentpanel_delete_service(PDO $pdo, $id): string +{ + $statement = $pdo->prepare('DELETE FROM gottesdienst WHERE id = ?'); + $statement->execute([contentpanel_positive_id($id)]); + return 'Eintrag erfolgreich gelöscht!'; +} + +function contentpanel_create_announcement(PDO $pdo, array $input): string +{ + $startDate = contentpanel_date($input['ansage_datum_von'] ?? '', 'Bitte ein gültiges Startdatum angeben.'); + $endDate = contentpanel_optional_date($input['ansage_datum_bis'] ?? '', 'Bitte ein gültiges Enddatum angeben.'); + if ($endDate !== null && $endDate < $startDate) { + throw new InvalidArgumentException('Das Enddatum darf nicht vor dem Startdatum liegen.'); + } + + $time = contentpanel_optional_time($input['ansage_uhrzeit'] ?? '', 'Bitte eine gültige Uhrzeit angeben.'); + $title = contentpanel_required_text($input, 'ansage_titel', 'Bitte einen Titel angeben.'); + $location = trim((string) ($input['ansage_ort'] ?? '')); + $subtitle = trim((string) ($input['ansage_untertitel'] ?? '')); + + $statement = $pdo->prepare( + 'INSERT INTO ansagen (ansage_datum_von, ansage_datum_bis, ansage_uhrzeit, ansage_ort, ansage_titel, ansage_untertitel) + VALUES (?, ?, ?, ?, ?, ?)' + ); + $statement->execute([ + $startDate->format('Y-m-d'), + $endDate?->format('Y-m-d'), + $time, + $location, + $title, + $subtitle, + ]); + + return $title . ' am ' . contentpanel_format_message_date($startDate) . ' erfolgreich angelegt!'; +} + +function contentpanel_delete_announcement(PDO $pdo, $id): string +{ + $statement = $pdo->prepare('DELETE FROM ansagen WHERE id = ?'); + $statement->execute([contentpanel_positive_id($id)]); + return 'Ansage erfolgreich gelöscht!'; +} + +function contentpanel_sanitize_news_title(string $name): string +{ + $safe = preg_replace('/[^A-Za-z0-9_\-]/', '_', str_replace(' ', '_', $name)); + return trim((string) $safe, '_-'); +} + +function contentpanel_is_valid_news_file_base(string $fileBase): bool +{ + return preg_match('/^\d{4}-\d{2}-\d{2}-[A-Za-z0-9_-]+$/D', $fileBase) === 1; +} + +function contentpanel_delete_news_files(string $fileBase): bool +{ + if (!contentpanel_is_valid_news_file_base($fileBase)) { + return false; + } + + $success = true; + foreach (glob(CONTENTPANEL_NEWS_PNG_DIRECTORY . '/' . $fileBase . '_page_*.png') ?: [] as $file) { + if (!@unlink($file)) { + $success = false; + } + } + + return $success; +} + +function contentpanel_cleanup_expired_news(): void +{ + $today = new DateTimeImmutable('today'); + + foreach (glob(CONTENTPANEL_NEWS_PNG_DIRECTORY . '/*_page_1.png') ?: [] as $file) { + $fileBase = basename($file, '_page_1.png'); + if (!contentpanel_is_valid_news_file_base($fileBase)) { + continue; + } + + $dateString = substr($fileBase, 0, 10); + $expiryDate = DateTimeImmutable::createFromFormat('!Y-m-d', $dateString); + if ($expiryDate !== false && $expiryDate->format('Y-m-d') === $dateString && $today > $expiryDate) { + contentpanel_delete_news_files($fileBase); + } + } +} + +function contentpanel_convert_pdf_to_png(string $pdfPath, string $outputDirectory, string $baseFileName): void +{ + $binary = contentpanel_find_binary('gs'); + if ($binary === null) { + throw new RuntimeException("'gs' (Ghostscript) wurde nicht gefunden."); + } + + $outputPattern = $outputDirectory . '/' . $baseFileName . '_page_%d.png'; + $command = escapeshellarg($binary) + . ' -dSAFER -dBATCH -dNOPAUSE -sDEVICE=png16m -r300' + . ' -sOutputFile=' . escapeshellarg($outputPattern) + . ' ' . escapeshellarg($pdfPath) + . ' 2>&1'; + + $output = []; + $status = 0; + exec($command, $output, $status); + if ($status !== 0 || !is_file($outputDirectory . '/' . $baseFileName . '_page_1.png')) { + throw new RuntimeException('Ghostscript-Konvertierung fehlgeschlagen: ' . implode("\n", $output)); + } +} + +function contentpanel_upload_news(array $input, array $file): string +{ + $expiryDate = contentpanel_date($input['date'] ?? '', 'Ungültiges Verfallsdatum.'); + $title = contentpanel_sanitize_news_title(trim((string) ($input['name'] ?? ''))); + if ($title === '') { + throw new InvalidArgumentException('Bitte einen gültigen Titel eingeben.'); + } + + $pdfPath = contentpanel_validate_pdf_upload( + $file, + 'Bitte eine gültige PDF-Datei hochladen.', + 'Bitte eine gültige PDF-Datei hochladen.' + ); + + if (!is_dir(CONTENTPANEL_NEWS_PNG_DIRECTORY) && !mkdir(CONTENTPANEL_NEWS_PNG_DIRECTORY, 0755, true)) { + throw new RuntimeException('Das Aushang-Verzeichnis konnte nicht angelegt werden.'); + } + + $fileBase = $expiryDate->format('Y-m-d') . '-' . $title; + $tempDirectory = sys_get_temp_dir() . '/contentpanel_news_' . bin2hex(random_bytes(8)); + if (!mkdir($tempDirectory, 0700, true)) { + throw new RuntimeException('Temporäres Aushang-Verzeichnis konnte nicht angelegt werden.'); + } + + $stagedFiles = []; + + try { + contentpanel_convert_pdf_to_png($pdfPath, $tempDirectory, $fileBase); + $generatedFiles = glob($tempDirectory . '/' . $fileBase . '_page_*.png') ?: []; + natsort($generatedFiles); + if ($generatedFiles === []) { + throw new RuntimeException('Die PDF-Konvertierung hat keine Seiten erzeugt.'); + } + + $token = bin2hex(random_bytes(6)); + foreach ($generatedFiles as $source) { + $target = CONTENTPANEL_NEWS_PNG_DIRECTORY . '/' . basename($source); + $staged = $target . '.tmp-' . $token; + if (!copy($source, $staged)) { + throw new RuntimeException('Konvertierte Aushangseite konnte nicht bereitgestellt werden.'); + } + $stagedFiles[$staged] = $target; + } + + contentpanel_delete_news_files($fileBase); + foreach ($stagedFiles as $staged => $target) { + if (!rename($staged, $target)) { + throw new RuntimeException('Konvertierte Aushangseite konnte nicht veröffentlicht werden.'); + } + } + } finally { + foreach (glob($tempDirectory . '/*') ?: [] as $filePath) { + @unlink($filePath); + } + @rmdir($tempDirectory); + foreach (array_keys($stagedFiles) as $temporaryFile) { + @unlink($temporaryFile); + } + } + + return 'Aushang wurde erfolgreich hochgeladen.'; +} + +function contentpanel_list_news(): array +{ + $items = []; + $files = glob(CONTENTPANEL_NEWS_PNG_DIRECTORY . '/*_page_1.png') ?: []; + sort($files, SORT_STRING); + + foreach ($files as $file) { + $fileBase = basename($file, '_page_1.png'); + if (!contentpanel_is_valid_news_file_base($fileBase)) { + continue; + } + + $expiry = substr($fileBase, 0, 10); + $items[] = [ + 'base' => $fileBase, + 'title' => substr($fileBase, 11), + 'expiry' => contentpanel_format_display_date($expiry), + 'pages' => count(glob(CONTENTPANEL_NEWS_PNG_DIRECTORY . '/' . $fileBase . '_page_*.png') ?: []), + 'image_url' => '/intranet_repo/projects/contentpanel/insertion/news/png/' . rawurlencode($fileBase) . '_page_1.png', + ]; + } + + return $items; +} + +function contentpanel_fetch_services(PDO $pdo, string $today): array +{ + $statement = $pdo->prepare( + 'SELECT id, bibelwort, bibelvers, bibeldatum + FROM gottesdienst + WHERE bibeldatum >= ? + ORDER BY bibeldatum ASC' + ); + $statement->execute([$today]); + $rows = $statement->fetchAll(PDO::FETCH_ASSOC); + + foreach ($rows as &$row) { + $row['datum'] = contentpanel_format_display_date($row['bibeldatum'] ?? ''); + } + unset($row); + + return $rows; +} + +function contentpanel_fetch_announcements(PDO $pdo, string $today): array +{ + $statement = $pdo->prepare( + 'SELECT id, ansage_untertitel, ansage_titel, ansage_ort, ansage_uhrzeit, ansage_datum_von, ansage_datum_bis + FROM ansagen + WHERE ansage_datum_von >= ? OR ansage_datum_bis >= ? + ORDER BY ansage_datum_von ASC' + ); + $statement->execute([$today, $today]); + $rows = $statement->fetchAll(PDO::FETCH_ASSOC); + + foreach ($rows as &$row) { + $row['datum_von'] = contentpanel_format_display_date($row['ansage_datum_von'] ?? ''); + $row['datum_bis'] = contentpanel_format_display_date($row['ansage_datum_bis'] ?? ''); + $row['uhrzeit'] = empty($row['ansage_uhrzeit']) ? '' : substr((string) $row['ansage_uhrzeit'], 0, 5); + } + unset($row); + + return $rows; +} diff --git a/web/intranet_repo/projects/dashboard/config.local.php b/web/intranet_repo/projects/dashboard/config.local.php deleted file mode 100644 index 2cb22a4..0000000 --- a/web/intranet_repo/projects/dashboard/config.local.php +++ /dev/null @@ -1,4 +0,0 @@ - { + 'use strict'; + + const CONTENT_REFRESH_MS = 30_000; + const INDOOR_REFRESH_MS = 10_000; + const OUTDOOR_REFRESH_MS = 30_000; + const VERTICAL_SPEED_PX_PER_SECOND = 7; + const HORIZONTAL_SPEED_PX_PER_SECOND = 10; + + const WEATHER_ICONS = { + cloudy: ``, + sun: ``, + moon: ``, + cloudy_day: ``, + cloudy_night: ``, + rain_shower: ``, + rain: ``, + thunder: ``, + snowy: ``, + mist: ``, + }; + + function setText(selector, value) { + const element = document.querySelector(selector); + if (element) { + element.textContent = value ?? ''; + } + } + + function updateClock() { + const now = new Date(); + const clock = document.getElementById('uhrzeit'); + if (clock) { + clock.textContent = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; + } + } + + function renderAnnouncements(announcements) { + const container = document.querySelector('.news-scroll'); + if (!container || !Array.isArray(announcements)) { + return; + } + + container.replaceChildren(); + announcements.forEach((announcement) => { + const box = document.createElement('div'); + box.className = 'news-box'; + + const date = document.createElement('div'); + date.className = 'news-date'; + date.append(document.createTextNode(announcement.date_label || '')); + if (announcement.end_label) { + date.append(document.createElement('br'), document.createTextNode(announcement.end_label)); + } + + const body = document.createElement('div'); + body.append(document.createTextNode(announcement.title || '')); + + if (announcement.subtitle) { + const subtitle = document.createElement('div'); + subtitle.className = 'news-subtitle'; + subtitle.textContent = announcement.subtitle; + body.append(subtitle); + } + + if (announcement.meta) { + const meta = document.createElement('div'); + meta.className = 'news-subtitle'; + const italic = document.createElement('i'); + italic.textContent = announcement.meta; + meta.append(italic); + body.append(meta); + } + + box.append(date, body); + container.append(box); + }); + } + + function renderPosters(posters) { + const container = document.querySelector('.aushang-flex'); + if (!container || !Array.isArray(posters)) { + return; + } + + container.replaceChildren(); + if (posters.length === 0) { + const empty = document.createElement('p'); + empty.style.textAlign = 'left'; + empty.textContent = 'Keine Aushänge vorgeplant.'; + container.append(empty); + return; + } + + posters.forEach((poster) => { + const image = document.createElement('img'); + image.src = poster.url; + image.alt = poster.alt || 'Aushang'; + image.style.height = '95%'; + image.addEventListener('load', () => scheduleScrollingSetup(0), { once: true }); + container.append(image); + }); + } + + function renderMonthImages(images) { + if (!Array.isArray(images)) { + return; + } + + images.forEach((image) => { + const element = document.querySelector(`[data-month-page="${image.page}"]`); + if (element && image.url && element.getAttribute('src') !== image.url) { + element.src = image.url; + } + }); + } + + function renderIndoor(indoor) { + if (indoor) { + setText('#temperature_txt', `${indoor.value}${indoor.unit || '°C'}`); + } + } + + function renderOutdoor(outdoor) { + const weather = document.getElementById('wetter'); + if (!weather || !outdoor) { + return; + } + + const icon = WEATHER_ICONS[outdoor.icon] || WEATHER_ICONS.cloudy; + weather.replaceChildren(); + + const iconContainer = document.createElement('div'); + iconContainer.innerHTML = icon; + const temperature = document.createElement('div'); + temperature.className = 'text-outdoor'; + temperature.textContent = `${outdoor.temperature}°C`; + weather.append(iconContainer, temperature); + } + + function renderContent(data) { + setText('[data-dashboard-date]', data.date_label); + if (data.service) { + setText('.bible', data.service.bibelwort); + setText('.verse', data.service.bibelvers); + setText('.GB', data.service.eingang_nummer); + setText('.GB-title', data.service.eingang_titel); + } + + renderAnnouncements(data.announcements); + renderPosters(data.posters); + renderMonthImages(data.month_images); + scheduleScrollingSetup(); + } + + function startPolling(resource, interval, render) { + const refresh = async () => { + try { + const response = await fetch(`data.php?resource=${encodeURIComponent(resource)}`, { cache: 'no-store' }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + if (data.error) { + throw new Error(data.error); + } + render(data); + } catch (error) { + console.error(`Dashboard-Aktualisierung (${resource}) fehlgeschlagen:`, error); + } finally { + window.setTimeout(refresh, interval); + } + }; + + refresh(); + } + + function applyAnimation(element, distance, speed, animationName) { + element.style.animation = 'none'; + if (distance <= 0) { + return; + } + + const variable = animationName === 'scroll-h' ? '--scroll-distance-h' : '--scroll-distance-v'; + element.style.setProperty(variable, `-${distance}px`); + element.style.animationName = animationName; + element.style.animationDuration = `${Math.max(1, distance / speed)}s`; + element.style.animationTimingFunction = 'linear'; + element.style.animationIterationCount = 'infinite'; + } + + function setupScrolling() { + const posterContainer = document.querySelector('.aushang-flex'); + if (posterContainer) { + const images = Array.from(posterContainer.querySelectorAll('img')); + const totalWidth = images.reduce((sum, image) => { + const styles = window.getComputedStyle(image); + return sum + image.offsetWidth + (parseFloat(styles.marginRight) || 0); + }, 0); + applyAnimation( + posterContainer, + Math.max(0, totalWidth - posterContainer.offsetWidth), + HORIZONTAL_SPEED_PX_PER_SECOND, + 'scroll-h' + ); + } + + const newsContainer = document.querySelector('.news-container'); + const news = document.querySelector('.news-scroll'); + if (newsContainer && news) { + applyAnimation( + news, + Math.max(0, news.scrollHeight - newsContainer.offsetHeight), + VERTICAL_SPEED_PX_PER_SECOND, + 'scroll-v' + ); + } + } + + let scrollingTimer = null; + function scheduleScrollingSetup(delay = 80) { + if (scrollingTimer !== null) { + window.clearTimeout(scrollingTimer); + } + scrollingTimer = window.setTimeout(setupScrolling, delay); + } + + updateClock(); + window.setInterval(updateClock, 1000); + window.addEventListener('load', () => scheduleScrollingSetup(50)); + window.addEventListener('resize', () => scheduleScrollingSetup(100)); + startPolling('content', CONTENT_REFRESH_MS, renderContent); + startPolling('indoor', INDOOR_REFRESH_MS, (data) => renderIndoor(data.indoor)); + startPolling('outdoor', OUTDOOR_REFRESH_MS, (data) => renderOutdoor(data.outdoor)); +})(); diff --git a/web/intranet_repo/projects/dashboard/dashboard.php b/web/intranet_repo/projects/dashboard/dashboard.php index 0b61363..993ea0a 100644 --- a/web/intranet_repo/projects/dashboard/dashboard.php +++ b/web/intranet_repo/projects/dashboard/dashboard.php @@ -1,58 +1,20 @@ prepare($sql); -$stmt->execute([$heute]); -$row = $stmt->fetch(PDO::FETCH_ASSOC); - -if ($row) { - $bibelwort = $row['bibelwort']; - $bibelvers = $row['bibelvers']; - $eingangNummer = $row['eingang_nummer']; - $eingangTitel = $row['eingang_titel']; -} else { - $bibelwort = "Bibelwort nicht eingetragen"; - $bibelvers = "Bibelvers nicht gefunden."; - $eingangNummer = "N/A"; - $eingangTitel = "Eingangslied nicht gefunden"; -} - -// --- Ansagen --- -$sqlAnsagen = "SELECT ansage_datum_von, ansage_datum_bis, ansage_uhrzeit, ansage_ort, ansage_titel, ansage_untertitel - FROM ansagen - WHERE ansage_datum_von >= ? OR ansage_datum_bis >= ? - ORDER BY ansage_datum_von ASC"; -$stmtAnsagen = $pdo->prepare($sqlAnsagen); -$stmtAnsagen->execute([$heute, $heute]); -$ansagenResult = $stmtAnsagen->fetchAll(PDO::FETCH_ASSOC); - -// Verbindung schließen (optional, aber sauber) +$content = dashboard_fetch_content($pdo, dashboard_now()); $pdo = null; -// --- Aushänge --- -$pngDirectoryUrl = '../contentpanel/insertion/news/png/'; - -// Abgelaufene Aushänge einschließlich Original-PDF bereinigen -cleanupExpiredNews(); - -$pngDirectoryFs = realpath( - __DIR__ . '/../contentpanel/insertion/news/png' -); - -$pngFiles = ($pngDirectoryFs && is_dir($pngDirectoryFs)) - ? scandir($pngDirectoryFs) - : []; +$service = $content['service']; +$announcements = $content['announcements']; +$posters = $content['posters']; +$monthImages = $content['month_images']; +$stylesheetVersion = (int) filemtime(__DIR__ . '/stylo.css'); +$scriptVersion = (int) filemtime(__DIR__ . '/dashboard.js'); ?> @@ -62,10 +24,9 @@ $pngFiles = ($pngDirectoryFs && is_dir($pngDirectoryFs)) NAK Reinickendorf - - + - +
@@ -73,63 +34,29 @@ $pngFiles = ($pngDirectoryFs && is_dir($pngDirectoryFs))
-
-
-
-
-
-
+
+
-
-
Heutige Ansagen
-
+
Heutige Ansagen
- +
- 'Mo','Tue' => 'Di','Wed' => 'Mi','Thu' => 'Do', - 'Fri' => 'Fr','Sat' => 'Sa','Sun' => 'So' - ]; - $datum_von_timestamp = strtotime($ansage['ansage_datum_von']); - $heute = date("Y-m-d"); - $morgen = date("Y-m-d", strtotime('+1 day')); - $datum_von_format = date("Y-m-d", $datum_von_timestamp); - - if ($datum_von_format === $heute) { - echo "Heute"; - } elseif ($datum_von_format === $morgen) { - echo "Morgen"; - } else { - $datum_von = date("d.m.", $datum_von_timestamp); - $wochentag_englisch = date("D", $datum_von_timestamp); - $wochentag = $wochentage[$wochentag_englisch] ?? $wochentag_englisch; - echo $wochentag . ' ' . $datum_von; - } - if (!empty($ansage['ansage_datum_bis'])) { - $datum_bis = date("d.m.", strtotime($ansage['ansage_datum_bis'])); - echo "
bis " . $datum_bis; - } - ?> + + +
+
- - -
+ + +
- -
,
- -
- -
+ +
@@ -141,32 +68,24 @@ $pngFiles = ($pngDirectoryFs && is_dir($pngDirectoryFs))
-
-
Bibelwort
-
-
-
+
Bibelwort
+
+
-
-
Eingangslied
-
-
-
+
Eingangslied
+
+
-
-
Temperatur
-
+
Temperatur
+
-
-
-
- + -
+
-
-
- -
-
-
Aushänge
-
-
- '; - } - } - if (!$hasPng) { - echo '

Keine Aushänge vorgeplant.

'; - } - ?>
-
+ +
+
Aushänge
+
+ +

Keine Aushänge vorgeplant.

+ + + <?= dashboard_html($poster['alt']) ?> + + +
+
+
-
-
-
Monatsplan
-
+
Monatsplan
- image - image + + Monatsplan Seite <?= (int) $image['page'] ?> +
-
-
- - - +
+
diff --git a/web/intranet_repo/projects/dashboard/data.php b/web/intranet_repo/projects/dashboard/data.php new file mode 100644 index 0000000..8900e2f --- /dev/null +++ b/web/intranet_repo/projects/dashboard/data.php @@ -0,0 +1,38 @@ + dashboard_fetch_indoor_temperature()]; + break; + case 'outdoor': + $payload = ['outdoor' => dashboard_fetch_outdoor_weather()]; + break; + default: + http_response_code(400); + $payload = ['error' => 'Unbekannte Dashboard-Ressource.']; + } + + echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); +} catch (Throwable $exception) { + http_response_code(502); + error_log('Dashboard data endpoint (' . $resource . '): ' . $exception->getMessage()); + echo json_encode(['error' => 'Dashboard-Daten konnten nicht geladen werden.'], JSON_UNESCAPED_UNICODE); +} finally { + $pdo = null; +} diff --git a/web/intranet_repo/projects/dashboard/ha_temperature.php b/web/intranet_repo/projects/dashboard/ha_temperature.php deleted file mode 100644 index aab6f6e..0000000 --- a/web/intranet_repo/projects/dashboard/ha_temperature.php +++ /dev/null @@ -1,60 +0,0 @@ - true, - CURLOPT_HTTPHEADER => [ - 'Authorization: Bearer ' . HA_TOKEN, - 'Content-Type: application/json' - ], - CURLOPT_CONNECTTIMEOUT => 3, - CURLOPT_TIMEOUT => 5, -]); -$response = curl_exec($ch); -$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); -$error = curl_error($ch); -curl_close($ch); - -if ($response === false || $httpCode < 200 || $httpCode >= 300) { - http_response_code(502); - echo json_encode([ - 'error' => 'Home Assistant request failed', - 'details' => $error ?: ('HTTP ' . $httpCode) - ]); - exit; -} - -$data = json_decode($response, true); -if (!is_array($data) || !isset($data['state'])) { - http_response_code(500); - echo json_encode(['error' => 'Unexpected HA response']); - exit; -} - -$val = $data['state']; -$unit = $data['attributes']['unit_of_measurement'] ?? '°C'; - -$CALIBRATION_OFFSET = +0; - -if (is_numeric($val)) { - $number = round((float)$val + $CALIBRATION_OFFSET, 1); -} else { - $number = $val; -} - -echo json_encode([ - 'entity_id' => $entityId, - 'value' => $number, - 'unit' => $unit, - 'friendly_name' => $data['attributes']['friendly_name'] ?? null, - 'last_changed' => $data['last_changed'] ?? null, -]); diff --git a/web/intranet_repo/projects/dashboard/update_content.php b/web/intranet_repo/projects/dashboard/update_content.php deleted file mode 100644 index afd4cea..0000000 --- a/web/intranet_repo/projects/dashboard/update_content.php +++ /dev/null @@ -1,54 +0,0 @@ -prepare($sql); - $stmt->execute([$heute]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); - - if ($row) { - $bibelwort = $row['bibelwort']; - $bibelvers = $row['bibelvers']; - $eingangNummer = $row['eingang_nummer']; - $eingangTitel = $row['eingang_titel']; - } else { - $bibelwort = "Bibelwort nicht eingetragen"; - $bibelvers = "Bibelvers nicht gefunden."; - $eingangNummer = "N/A"; - $eingangTitel = "Eingangslied nicht gefunden"; - } - - // --- Ansagen --- - $sqlAnsagen = "SELECT ansage_datum_von, ansage_datum_bis, ansage_uhrzeit, ansage_ort, ansage_titel, ansage_untertitel - FROM ansagen - WHERE ansage_datum_von >= ? OR ansage_datum_bis >= ? - ORDER BY ansage_datum_von ASC"; - $stmtAnsagen = $pdo->prepare($sqlAnsagen); - $stmtAnsagen->execute([$heute, $heute]); - $ansagenResult = $stmtAnsagen->fetchAll(PDO::FETCH_ASSOC); - - // Rückgabe als JSON - echo json_encode([ - 'bibelwort' => $bibelwort, - 'bibelvers' => $bibelvers, - 'eingang_nummer' => $eingangNummer, - 'eingang_titel' => $eingangTitel, - 'ansagen' => $ansagenResult - ], JSON_UNESCAPED_UNICODE); - -} catch (Throwable $e) { - // Optional: error_log($e->getMessage()); - echo json_encode(['error' => 'Fehler bei der Verbindung zur Datenbank'], JSON_UNESCAPED_UNICODE); -} finally { - $pdo = null; -} diff --git a/web/intranet_repo/projects/dashboard/util/dashboard_helpers.php b/web/intranet_repo/projects/dashboard/util/dashboard_helpers.php new file mode 100644 index 0000000..79bb7fe --- /dev/null +++ b/web/intranet_repo/projects/dashboard/util/dashboard_helpers.php @@ -0,0 +1,257 @@ +format('Y-m-d'); + + $serviceStatement = $pdo->prepare( + 'SELECT bibelwort, bibelvers, eingang_nummer, eingang_titel + FROM gottesdienst + WHERE bibeldatum = ?' + ); + $serviceStatement->execute([$today]); + $service = $serviceStatement->fetch(PDO::FETCH_ASSOC) ?: []; + + $announcementStatement = $pdo->prepare( + 'SELECT ansage_datum_von, ansage_datum_bis, ansage_uhrzeit, ansage_ort, ansage_titel, ansage_untertitel + FROM ansagen + WHERE ansage_datum_von >= ? OR ansage_datum_bis >= ? + ORDER BY ansage_datum_von ASC' + ); + $announcementStatement->execute([$today, $today]); + $announcements = $announcementStatement->fetchAll(PDO::FETCH_ASSOC); + + foreach ($announcements as &$announcement) { + $announcement = dashboard_prepare_announcement($announcement, $now); + } + unset($announcement); + + contentpanel_cleanup_expired_news(); + + return [ + 'date_label' => dashboard_date_label($now), + 'service' => [ + 'bibelwort' => (string) ($service['bibelwort'] ?? 'Bibelwort nicht eingetragen'), + 'bibelvers' => (string) ($service['bibelvers'] ?? 'Bibelvers nicht gefunden.'), + 'eingang_nummer' => (string) ($service['eingang_nummer'] ?? 'N/A'), + 'eingang_titel' => (string) ($service['eingang_titel'] ?? 'Eingangslied nicht gefunden'), + ], + 'announcements' => $announcements, + 'posters' => dashboard_list_posters(), + 'month_images' => dashboard_month_images(), + ]; +} + +function dashboard_date_label(DateTimeImmutable $date): string +{ + static $months = [ + 1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', + ]; + + return sprintf('%02d. %s', (int) $date->format('d'), $months[(int) $date->format('n')]); +} + +function dashboard_prepare_announcement(array $announcement, DateTimeImmutable $now): array +{ + $startRaw = substr((string) ($announcement['ansage_datum_von'] ?? ''), 0, 10); + $endRaw = substr((string) ($announcement['ansage_datum_bis'] ?? ''), 0, 10); + $timeRaw = substr((string) ($announcement['ansage_uhrzeit'] ?? ''), 0, 5); + + $startDate = DateTimeImmutable::createFromFormat('!Y-m-d', $startRaw, $now->getTimezone()); + $tomorrow = $now->setTime(0, 0)->modify('+1 day'); + + if ($startDate && $startDate->format('Y-m-d') === $now->format('Y-m-d')) { + $dateLabel = 'Heute'; + } elseif ($startDate && $startDate->format('Y-m-d') === $tomorrow->format('Y-m-d')) { + $dateLabel = 'Morgen'; + } elseif ($startDate) { + static $weekdays = [1 => 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; + $dateLabel = $weekdays[(int) $startDate->format('N')] . ' ' . $startDate->format('d.m.'); + } else { + $dateLabel = $startRaw; + } + + $endLabel = ''; + if ($endRaw !== '') { + $endDate = DateTimeImmutable::createFromFormat('!Y-m-d', $endRaw, $now->getTimezone()); + $endLabel = $endDate ? 'bis ' . $endDate->format('d.m.') : 'bis ' . $endRaw; + } + + $meta = []; + if ($timeRaw !== '') { + $meta[] = $timeRaw . ' Uhr'; + } + $place = trim((string) ($announcement['ansage_ort'] ?? '')); + if ($place !== '') { + $meta[] = $place; + } + + return [ + 'date_label' => $dateLabel, + 'end_label' => $endLabel, + 'title' => (string) ($announcement['ansage_titel'] ?? ''), + 'subtitle' => (string) ($announcement['ansage_untertitel'] ?? ''), + 'meta' => implode(', ', $meta), + ]; +} + +function dashboard_list_posters(): array +{ + $files = glob(CONTENTPANEL_NEWS_PNG_DIRECTORY . '/*.png') ?: []; + natsort($files); + + $posters = []; + foreach ($files as $file) { + if (!is_file($file)) { + continue; + } + + $basename = basename($file); + $posters[] = [ + 'url' => DASHBOARD_NEWS_URL . rawurlencode($basename) . '?v=' . (int) filemtime($file), + 'alt' => 'Aushang', + ]; + } + + return array_values($posters); +} + +function dashboard_month_images(): array +{ + $images = []; + foreach ([1, 2] as $page) { + $filename = 'terminuebersicht-' . $page . '.png'; + $path = __DIR__ . '/../month/' . $filename; + $version = is_file($path) ? (int) filemtime($path) : 0; + $images[] = [ + 'page' => $page, + 'url' => 'month/' . $filename . '?v=' . $version, + ]; + } + + return $images; +} + +function dashboard_fetch_indoor_temperature(): array +{ + $baseUrl = rtrim(intranet_env('HA_BASE_URL'), '/'); + $token = intranet_env('HA_TOKEN'); + $url = $baseUrl . '/api/states/' . rawurlencode(DASHBOARD_HA_ENTITY_ID); + $data = dashboard_http_json($url, ['Authorization: Bearer ' . $token]); + + if (!array_key_exists('state', $data)) { + throw new RuntimeException('Unerwartete Home-Assistant-Antwort.'); + } + + $value = $data['state']; + if (is_numeric($value)) { + $value = round((float) $value, 1); + } + + return [ + 'value' => $value, + 'unit' => (string) ($data['attributes']['unit_of_measurement'] ?? '°C'), + ]; +} + +function dashboard_fetch_outdoor_weather(): array +{ + $query = http_build_query([ + 'lat' => DASHBOARD_OPENWEATHER_LATITUDE, + 'lon' => DASHBOARD_OPENWEATHER_LONGITUDE, + 'appid' => intranet_env('OPENWEATHER_API_KEY'), + 'units' => 'metric', + 'lang' => 'de', + ], '', '&', PHP_QUERY_RFC3986); + + $data = dashboard_http_json('https://api.openweathermap.org/data/2.5/weather?' . $query); + $temperature = $data['main']['temp'] ?? null; + $icon = (string) ($data['weather'][0]['icon'] ?? ''); + + if (!is_numeric($temperature)) { + throw new RuntimeException('Unerwartete OpenWeather-Antwort.'); + } + + return [ + 'temperature' => (int) round((float) $temperature), + 'icon' => dashboard_weather_kind($icon), + ]; +} + +function dashboard_weather_kind(string $icon): string +{ + return match ($icon) { + '04d', '04n', '03d', '03n' => 'cloudy', + '02d' => 'cloudy_day', + '02n' => 'cloudy_night', + '01d' => 'sun', + '01n' => 'moon', + '09d', '09n', '10n' => 'rain_shower', + '10d' => 'rain', + '11d', '11n' => 'thunder', + '13d', '13n' => 'snowy', + '50d', '50n' => 'mist', + default => 'cloudy', + }; +} + +function dashboard_http_json(string $url, array $headers = []): array +{ + $handle = curl_init($url); + if ($handle === false) { + throw new RuntimeException('HTTP-Anfrage konnte nicht initialisiert werden.'); + } + + curl_setopt_array($handle, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => array_merge(['Accept: application/json'], $headers), + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_TIMEOUT => 5, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 3, + ]); + + try { + $response = curl_exec($handle); + $status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE); + $error = curl_error($handle); + } finally { + curl_close($handle); + } + + if ($response === false || $status < 200 || $status >= 300) { + throw new RuntimeException($error !== '' ? $error : 'HTTP ' . $status); + } + + try { + $data = json_decode($response, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new RuntimeException('Ungültige JSON-Antwort.', 0, $exception); + } + + if (!is_array($data)) { + throw new RuntimeException('Unerwartete JSON-Antwort.'); + } + + return $data; +} diff --git a/web/intranet_repo/projects/developerpanel/developer.php b/web/intranet_repo/projects/developerpanel/developer.php index 377d1bd..41f04cb 100644 --- a/web/intranet_repo/projects/developerpanel/developer.php +++ b/web/intranet_repo/projects/developerpanel/developer.php @@ -34,10 +34,15 @@
Postgres-Datenbank
- + +
diff --git a/web/intranet_repo/projects/intranet/index.php b/web/intranet_repo/projects/intranet/index.php index 8fc6530..75b7e0b 100644 --- a/web/intranet_repo/projects/intranet/index.php +++ b/web/intranet_repo/projects/intranet/index.php @@ -3,6 +3,7 @@ // Initialisierung require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/initialise.php'; + require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/brandschutz/util/brandschutz_helpers.php'; ?> @@ -56,24 +57,17 @@ prepare("SELECT last_training_date FROM brandschutz_training WHERE user_id = ?"); $bsStmt->execute([$_SESSION['user_id']]); $bsLastTraining = $bsStmt->fetchColumn(); - if ($bsLastTraining === false) { - $bsOverdue = true; - } else { - if (empty($bsLastTraining) || strtotime($bsLastTraining . ' +365 days') < time()) { - $bsOverdue = true; - } - } + $bsTrainingStatus = brandschutz_training_status($bsLastTraining === false ? null : (string) $bsLastTraining); + $bsOverdue = (bool) $bsTrainingStatus['is_due']; ?>
Brandschutzunterweisung fällig!
-
Bitte führen Sie zeitnah die Online-Brandschutzunterweisung durch. Die Schulung dauert nur ca. 15 bis 20 Minuten. Eine regelmäßige Durchführung ist gesetzlich vorgeschrieben. Vielen Dank!
+
Bitte führen Sie zeitnah die Online-Brandschutzunterweisung durch. Die Schulung dauert nur ca. 15 bis 20 Minuten. Eine regelmäßige Durchführung ist gesetzlich vorgeschrieben. Vielen Dank!
diff --git a/web/intranet_repo/projects/kalli/kalli.php b/web/intranet_repo/projects/kalli/kalli.php index ac5ea06..9a655ee 100644 --- a/web/intranet_repo/projects/kalli/kalli.php +++ b/web/intranet_repo/projects/kalli/kalli.php @@ -1,782 +1,217 @@ -getMessage(); + } catch (Throwable $exception) { + error_log('Kalli: XLSX-Erzeugung fehlgeschlagen: ' . $exception->getMessage()); + $formError = 'Die Eintragung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut oder kontaktieren Sie uns.'; + } +} ?> - + + + - - - -getActiveSheet(); - - // Set the header row - $sheet->setCellValue('A1', 'ID'); - $sheet->setCellValue('B1', 'Lang-ID'); - $sheet->setCellValue('C1', 'Termin-Typ'); - $sheet->setCellValue('D1', 'Von-Datum'); - $sheet->setCellValue('E1', 'Von-Zeit'); - $sheet->setCellValue('F1', 'Bis-Datum'); - $sheet->setCellValue('G1', 'Bis-Zeit'); - $sheet->setCellValue('H1', 'Zeitzusatz'); - $sheet->setCellValue('I1', 'Titel'); - $sheet->setCellValue('J1', 'Text'); - $sheet->setCellValue('K1', 'Ort'); - $sheet->setCellValue('L1', 'Planungsstatus'); - $sheet->setCellValue('M1', 'Anzeigeziel'); - $sheet->setCellValue('N1', 'Gruppenzuordnung'); - $sheet->setCellValue('O1', 'Zyklusart'); - $sheet->setCellValue('P1', 'Zyklusintervall'); - $sheet->setCellValue('Q1', 'Zyklusdetail'); - $sheet->setCellValue('R1', 'Notiz'); - $sheet->setCellValue('S1', 'Verantwortlich'); - $sheet->setCellValue('T1', 'Hintergrundfarbe'); - $sheet->setCellValue('U1', 'BucherID'); - $sheet->setCellValue('V1', 'Benutzerdefinierte Felder'); - $sheet->setCellValue('W1', 'Dokument-Titel'); - $sheet->setCellValue('X1', 'Anzeigeziel Ort'); - $sheet->setCellValue('Y1', 'Anzeigeziel Notiz'); - $sheet->setCellValue('Z1', 'Anzeigeziel Verantwortlicher'); - $sheet->setCellValue('AA1', 'Erzeugt'); - $sheet->setCellValue('AB1', 'Aktualisiert'); - - // Set the data row - // Titel - $selectedGroup = isset($_POST['group']) ? $_POST['group'] : ''; - $sheet->setCellValue('I2', $selectedGroup); - // Datum - $date_s = isset($_POST['date_s']) ? $_POST['date_s'] : ''; - - $sheet->setCellValue('D2', date('d.m.Y', strtotime($date_s))); - - $date_e = isset($_POST['date_e']) ? $_POST['date_e'] : ''; - if (date('d.m.Y', strtotime($date_e)) != "01.01.1970"){ - $sheet->setCellValue('F2', date('d.m.Y', strtotime($date_e))); - } - // Zeit - $time_s = isset($_POST['time_s']) ? $_POST['time_s'] : ''; - if ($time_s !== '' && preg_match('/^\d{2}:\d{2}$/', $time_s)) { - $sheet->setCellValue('E2', $time_s); - } elseif ($time_s !== '') { - $error[] = "Ungültiges Zeitformat. Verwenden Sie das Format hh:mm."; - } - $time_e = isset($_POST['time_e']) ? $_POST['time_e'] : ''; - if ($time_e !== '' && preg_match('/^\d{2}:\d{2}$/', $time_e)) { - $sheet->setCellValue('G2', $time_e); - } elseif ($time_e !== '') { - $error[] = "Ungültiges Zeitformat. Verwenden Sie das Format hh:mm."; - } - // FIX - $sheet->setCellValue('C2', 'Einzel'); - $sheet->setCellValue('O2', 'einmalig'); - - // Setze den Ort in Zelle K2 - $location = isset($_POST['location']) ? htmlspecialchars($_POST['location']) : ''; - $sheet->setCellValue('K2', $location); - - // Setze den Text in Zelle J2 - $further_text = isset($_POST['further_text']) ? htmlspecialchars($_POST['further_text']) : ''; - $sheet->setCellValue('J2', $further_text); - $sheet->setCellValue('R2', $info); - $responsibility = isset($_POST['responsibility']) ? htmlspecialchars($_POST['responsibility']) : ''; - $sheet->setCellValue('S2', $responsibility); - - $userInput = isset($_POST['userinput']) ? $_POST['userinput'] : ''; - $userInput2 = isset($_POST['userinput2']) ? $_POST['userinput2'] : ''; - - // DB-Lookup: Preset-Werte für die gewählte Terminart laden - $presetStmt = $pdo->prepare("SELECT planungsstatus, anzeigeziel, gruppenzuordnung, anzeigeziel_ort, anzeigeziel_notiz, anzeigeziel_verantwortlicher, default_text FROM kalli_appointment_types WHERE name = ? LIMIT 1"); - $presetStmt->execute([$selectedGroup]); - $preset = $presetStmt->fetch(PDO::FETCH_ASSOC); - - if ($preset) { - $sheet->setCellValue('L2', $preset['planungsstatus']); - $sheet->setCellValue('M2', $preset['anzeigeziel']); - $sheet->setCellValue('N2', $preset['gruppenzuordnung']); - $sheet->setCellValue('X2', $preset['anzeigeziel_ort']); - $sheet->setCellValue('Y2', $preset['anzeigeziel_notiz']); - $sheet->setCellValue('Z2', $preset['anzeigeziel_verantwortlicher']); - if (!empty($preset['default_text']) && empty($further_text)) { - $sheet->setCellValue('J2', $preset['default_text']); - } - } elseif ($userInput != "" && $selectedGroup == "oeffentlich") { - $sheet->setCellValue('I2', $userInput); - $sheet->setCellValue('L2', 'fix'); - $sheet->setCellValue('M2', 'öffentlich'); - $sheet->setCellValue('N2', 'AN ALLE'); - $sheet->setCellValue('X2', 'öffentlich'); - $sheet->setCellValue('Y2', 'intern'); - $sheet->setCellValue('Z2', 'intern'); - } elseif ($userInput != "" && $selectedGroup == "intern") { - $sheet->setCellValue('I2', $userInput); - $sheet->setCellValue('L2', 'fix'); - $sheet->setCellValue('M2', 'intern'); - $sheet->setCellValue('N2', 'AN ALLE'); - $sheet->setCellValue('X2', 'intern'); - $sheet->setCellValue('Y2', 'intern'); - $sheet->setCellValue('Z2', 'intern'); - } - - // Save the file on the server - $xlsxDir = rtrim(intranet_env('CKAL_XLSX_DIR', '/data/ckal-xlsx'), '/'); - if (!is_dir($xlsxDir) && !mkdir($xlsxDir, 0770, true) && !is_dir($xlsxDir)) { - throw new RuntimeException("CKalender-XLSX-Verzeichnis kann nicht angelegt werden: {$xlsxDir}"); - } - $FileName = $xlsxDir . "/" . $name . "-" . date("Ymd-His") . ".xlsx"; - $writer = new Xlsx($spreadsheet); - $writer->save($FileName); - - // Redirect to prevent form resubmission on page reload - print ""; - //header("Location: kalli.php"); - //exit(); - } -} - - - // ACHTUNG: Die PHP-Header Weiterleitungen müssen hier abgeschlossen sein. -?> - - - - - - - + +
-Kalli: Eintragungsassistent + Kalli: Eintragungsassistent
+
+
+ $label): ?> + 0): ?>
+
+
+ +
+ +
- + + + -' - : ''; - $toastText = ($error == 3) ? 'Eintragung erfolgreich! Kalli wird Ihren Termin direkt vermerken.' : 'Es gab einen Fehler. Bitte kontaktieren Sie uns!'; - echo "
"; - echo "
{$toastIcon}
"; - echo "" . htmlspecialchars($toastText) . ""; - echo ""; - echo "
"; - echo ""; -} -?> +
+ - - -
- - -
-
-
1
-Terminart -
-
-
-
2
-Zeitraum -
-
-
-
3
-Informationen -
-
-
-
4
-Übersicht -
+ + +
- -
- - -
-
-
Terminart auswählen
-
-query("SELECT * FROM kalli_categories ORDER BY sort_order, name"); -$kalliCategories = []; -while ($cat = $catQuery->fetch(PDO::FETCH_ASSOC)) { - $kalliCategories[] = $cat; -} -$typeQuery = $pdo->query("SELECT * FROM kalli_appointment_types ORDER BY sort_order, name"); -$kalliTypesByCategory = []; -while ($type = $typeQuery->fetch(PDO::FETCH_ASSOC)) { - $kalliTypesByCategory[$type['category_id']][] = $type; -} - -// Alle verwendeten Rollen sammeln für die "Terminart nicht aufgelistet"-Sektion -$kalliAllRoles = []; -$isAdminUser = (isset($groups["Admin"]) && $groups["Admin"] == 1); - -foreach ($kalliCategories as $cat): - $role = $cat['permission_role']; - $kalliAllRoles[$role] = 1; - if (!$isAdminUser && !(isset($groups[$role]) && $groups[$role] == 1)) continue; - $types = $kalliTypesByCategory[$cat['id']] ?? []; - if (empty($types)) continue; -?> -
-
- - - -
- -
- - 0; -if ($hasAnyKalliRole): ?> -
-
Terminart nicht aufgelistet?
-

Nur ausfüllen, wenn die Terminart oben nicht vorhanden ist.

-
- - -
- -
- -
- -
-
-
- -
-
-
- - - - - - - - - - -
-
- - - - - -
- - + diff --git a/web/intranet_repo/projects/kalli/kalli_modal.php b/web/intranet_repo/projects/kalli/kalli_modal.php deleted file mode 100644 index 0a649e4..0000000 --- a/web/intranet_repo/projects/kalli/kalli_modal.php +++ /dev/null @@ -1,746 +0,0 @@ - - - - - - diff --git a/web/intranet_repo/projects/kalli/util/kalli_helpers.php b/web/intranet_repo/projects/kalli/util/kalli_helpers.php new file mode 100644 index 0000000..2b5e6c4 --- /dev/null +++ b/web/intranet_repo/projects/kalli/util/kalli_helpers.php @@ -0,0 +1,405 @@ +query($sql)->fetchAll(PDO::FETCH_ASSOC); +} + +/** + * Erzeugt die einzige Berechtigungs- und Preset-Sicht für UI und POST-Verarbeitung. + */ +function kalliBuildCatalog(array $rows, array $groups): array +{ + $isAdmin = kalliPermissionEnabled($groups['Admin'] ?? null); + $categories = []; + $typesByName = []; + $allowCustom = $isAdmin; + + foreach ($rows as $row) { + $role = (string)($row['permission_role'] ?? ''); + $allowed = $isAdmin || kalliPermissionEnabled($groups[$role] ?? null); + if (!$allowed) { + continue; + } + + // Entspricht dem bisherigen Verhalten: Wer mindestens eine Kalli-Kategorie + // sehen darf, darf auch einen benutzerdefinierten Termin anlegen. + $allowCustom = true; + + if ($row['type_id'] === null) { + continue; + } + + $categoryId = (string)$row['category_id']; + if (!isset($categories[$categoryId])) { + $categories[$categoryId] = [ + 'id' => $row['category_id'], + 'name' => (string)$row['category_name'], + 'types' => [], + ]; + } + + $type = [ + 'id' => $row['type_id'], + 'name' => (string)$row['type_name'], + 'display_label' => (string)($row['display_label'] ?? ''), + 'planungsstatus' => (string)($row['planungsstatus'] ?? ''), + 'anzeigeziel' => (string)($row['anzeigeziel'] ?? ''), + 'gruppenzuordnung' => (string)($row['gruppenzuordnung'] ?? ''), + 'anzeigeziel_ort' => (string)($row['anzeigeziel_ort'] ?? ''), + 'anzeigeziel_notiz' => (string)($row['anzeigeziel_notiz'] ?? ''), + 'anzeigeziel_verantwortlicher' => (string)($row['anzeigeziel_verantwortlicher'] ?? ''), + 'default_text' => (string)($row['default_text'] ?? ''), + ]; + + $categories[$categoryId]['types'][] = $type; + + // Das Formular übermittelt aus Kompatibilitätsgründen weiterhin den Namen. + // Bei versehentlichen Dubletten entspricht der erste sortierte Treffer dem + // deterministischen Ersatz für das frühere "LIMIT 1" ohne ORDER BY. + if (!isset($typesByName[$type['name']])) { + $typesByName[$type['name']] = $type; + } + } + + return [ + 'categories' => array_values($categories), + 'types_by_name' => $typesByName, + 'allow_custom' => $allowCustom, + ]; +} + +function kalliFormValues(array $source = []): array +{ + $keys = [ + 'group', + 'userinput', + 'date_s', + 'date_e', + 'time_s', + 'time_e', + 'further_text', + 'location', + 'responsibility', + ]; + + $values = []; + foreach ($keys as $key) { + $value = $source[$key] ?? ''; + $values[$key] = is_scalar($value) ? trim((string)$value) : ''; + } + + return $values; +} + +function kalliParseDate(string $value, string $label, bool $required = false): ?DateTimeImmutable +{ + if ($value === '') { + if ($required) { + throw new InvalidArgumentException($label . ' fehlt.'); + } + return null; + } + + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); + $errors = DateTimeImmutable::getLastErrors(); + $hasErrors = $errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0); + + if (!$date || $hasErrors || $date->format('Y-m-d') !== $value) { + throw new InvalidArgumentException($label . ' ist ungültig.'); + } + + return $date; +} + +function kalliParseTime(string $value, string $label): ?string +{ + if ($value === '') { + return null; + } + + if (!preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/D', $value)) { + throw new InvalidArgumentException($label . ' ist ungültig. Bitte hh:mm verwenden.'); + } + + return $value; +} + +/** + * Validiert einen vollständigen Formular-POST und löst die CKalender-Presets auf. + */ +function kalliValidateEntry(array $source, array $catalog): array +{ + $values = kalliFormValues($source); + $group = $values['group']; + + if ($group === '') { + throw new InvalidArgumentException('Bitte wählen Sie eine Terminart aus.'); + } + + $preset = $catalog['types_by_name'][$group] ?? null; + if ($preset !== null) { + $title = $preset['name']; + } elseif (in_array($group, [KALLI_CUSTOM_PUBLIC, KALLI_CUSTOM_INTERNAL], true)) { + if (!$catalog['allow_custom']) { + throw new InvalidArgumentException('Für benutzerdefinierte Termine fehlt die Berechtigung.'); + } + + $title = $values['userinput']; + if ($title === '') { + throw new InvalidArgumentException('Bitte geben Sie einen Titel für den benutzerdefinierten Termin ein.'); + } + + $isPublic = $group === KALLI_CUSTOM_PUBLIC; + $preset = [ + 'planungsstatus' => 'fix', + 'anzeigeziel' => $isPublic ? 'öffentlich' : 'intern', + 'gruppenzuordnung' => 'AN ALLE', + 'anzeigeziel_ort' => $isPublic ? 'öffentlich' : 'intern', + 'anzeigeziel_notiz' => 'intern', + 'anzeigeziel_verantwortlicher' => 'intern', + 'default_text' => '', + ]; + } else { + throw new InvalidArgumentException('Die gewählte Terminart ist ungültig oder nicht freigegeben.'); + } + + $dateStart = kalliParseDate($values['date_s'], 'Das Startdatum', true); + $dateEnd = kalliParseDate($values['date_e'], 'Das Enddatum'); + if ($dateEnd !== null && $dateEnd < $dateStart) { + throw new InvalidArgumentException('Das Enddatum darf nicht vor dem Startdatum liegen.'); + } + + $timeStart = kalliParseTime($values['time_s'], 'Die Startzeit'); + $timeEnd = kalliParseTime($values['time_e'], 'Die Endzeit'); + if ($timeStart !== null && $timeEnd !== null && ($dateEnd === null || $dateEnd == $dateStart) && $timeEnd < $timeStart) { + throw new InvalidArgumentException('Die Endzeit darf am selben Tag nicht vor der Startzeit liegen.'); + } + + $text = $values['further_text']; + if ($text === '' && $preset['default_text'] !== '') { + $text = $preset['default_text']; + } + + return [ + 'title' => $title, + 'date_start' => $dateStart->format('d.m.Y'), + 'date_end' => $dateEnd?->format('d.m.Y') ?? '', + 'time_start' => $timeStart ?? '', + 'time_end' => $timeEnd ?? '', + 'text' => $text, + 'location' => $values['location'], + 'responsibility' => $values['responsibility'], + 'planungsstatus' => $preset['planungsstatus'], + 'anzeigeziel' => $preset['anzeigeziel'], + 'gruppenzuordnung' => $preset['gruppenzuordnung'], + 'anzeigeziel_ort' => $preset['anzeigeziel_ort'], + 'anzeigeziel_notiz' => $preset['anzeigeziel_notiz'], + 'anzeigeziel_verantwortlicher' => $preset['anzeigeziel_verantwortlicher'], + ]; +} + +function kalliCalendarRow(array $entry, array $user): array +{ + $userName = trim((string)($user['first_name'] ?? '') . ' ' . (string)($user['last_name'] ?? '')); + $info = 'Von ' . $userName . ' eingetragen (Eintragungsassistent)'; + + return [ + '', // ID + '', // Lang-ID + 'Einzel', // Termin-Typ + $entry['date_start'], // Von-Datum + $entry['time_start'], // Von-Zeit + $entry['date_end'], // Bis-Datum + $entry['time_end'], // Bis-Zeit + '', // Zeitzusatz + $entry['title'], // Titel + $entry['text'], // Text + $entry['location'], // Ort + $entry['planungsstatus'], // Planungsstatus + $entry['anzeigeziel'], // Anzeigeziel + $entry['gruppenzuordnung'], // Gruppenzuordnung + 'einmalig', // Zyklusart + '', // Zyklusintervall + '', // Zyklusdetail + $info, // Notiz + $entry['responsibility'], // Verantwortlich + '', // Hintergrundfarbe + '', // BucherID + '', // Benutzerdefinierte Felder + '', // Dokument-Titel + $entry['anzeigeziel_ort'], + $entry['anzeigeziel_notiz'], + $entry['anzeigeziel_verantwortlicher'], + '', // Erzeugt + '', // Aktualisiert + ]; +} + +function kalliSafeFilenamePart(string $value, string $fallback): string +{ + $safe = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $value); + $safe = trim((string)$safe, " ._-\t\n\r\0\x0B"); + + return $safe !== '' ? $safe : $fallback; +} + +function kalliReserveExportPath(string $directory, array $user): array +{ + $displayName = trim((string)($user['first_name'] ?? '') . ' ' . (string)($user['last_name'] ?? '')); + $fallback = 'user-' . (string)($user['user_id'] ?? $_SESSION['user_id'] ?? 'unknown'); + $safeName = kalliSafeFilenamePart($displayName, $fallback); + $base = $directory . '/' . $safeName . '-' . date('Ymd-His'); + + for ($suffix = 1; $suffix <= 1000; $suffix++) { + $target = $base . ($suffix === 1 ? '' : '-' . $suffix) . '.xlsx'; + $lockPath = $target . '.lock'; + $lockHandle = @fopen($lockPath, 'x'); + if ($lockHandle === false) { + continue; + } + + if (file_exists($target)) { + fclose($lockHandle); + @unlink($lockPath); + continue; + } + + return [$target, $lockPath, $lockHandle]; + } + + throw new RuntimeException('Kein freier Dateiname für den CKalender-Export verfügbar.'); +} + +/** + * Schreibt die XLSX zunächst unter einer Nicht-XLSX-Endung und veröffentlicht sie + * anschließend per rename(). So kann ein Ordner-Watcher keine halbfertige Datei lesen. + */ +function kalliWriteCalendarFile(array $entry, array $user, string $directory): string +{ + $directory = rtrim($directory, '/'); + if ($directory === '') { + throw new RuntimeException('CKalender-XLSX-Verzeichnis ist nicht konfiguriert.'); + } + + if (!is_dir($directory) && !mkdir($directory, 0770, true) && !is_dir($directory)) { + throw new RuntimeException('CKalender-XLSX-Verzeichnis kann nicht angelegt werden: ' . $directory); + } + if (!is_writable($directory)) { + throw new RuntimeException('CKalender-XLSX-Verzeichnis ist nicht beschreibbar: ' . $directory); + } + + [$target, $lockPath, $lockHandle] = kalliReserveExportPath($directory, $user); + $temporary = $target . '.tmp'; + $spreadsheet = new Spreadsheet(); + + try { + $sheet = $spreadsheet->getActiveSheet(); + $sheet->fromArray(KALLI_CKAL_HEADERS, null, 'A1'); + $sheet->fromArray(kalliCalendarRow($entry, $user), null, 'A2'); + + (new Xlsx($spreadsheet))->save($temporary); + if (!rename($temporary, $target)) { + throw new RuntimeException('CKalender-XLSX-Datei konnte nicht veröffentlicht werden.'); + } + } finally { + $spreadsheet->disconnectWorksheets(); + if (is_file($temporary)) { + @unlink($temporary); + } + fclose($lockHandle); + @unlink($lockPath); + } + + return $target; +} diff --git a/web/intranet_repo/projects/seelsorge/content/add_visit_entry.php b/web/intranet_repo/projects/seelsorge/content/add_visit_entry.php deleted file mode 100644 index 25daf11..0000000 --- a/web/intranet_repo/projects/seelsorge/content/add_visit_entry.php +++ /dev/null @@ -1,79 +0,0 @@ - 'error', 'message' => 'Nicht angemeldet.'], JSON_UNESCAPED_UNICODE); - exit; - } - - $pdo = require($_SERVER['DOCUMENT_ROOT'] . "/intranet_repo/general_files/ini/database.php"); - - // Eingaben - $last_name = trim($_POST['last_name'] ?? ''); - $first_name = trim($_POST['first_name'] ?? ''); - $birth_date = trim($_POST['birth_date'] ?? ''); - $address_street = trim($_POST['address_street'] ?? ''); - $address_plz = trim($_POST['address_plz'] ?? ''); - $address_link = trim($_POST['address_link'] ?? ''); - $phone_raw = trim($_POST['phone'] ?? ''); - $comment = trim($_POST['comment'] ?? ''); - $letzter_besuch = trim($_POST['letzter_besuch'] ?? ''); - $priority = isset($_POST['priority']) ? (int)$_POST['priority'] : 0; - $pr_bezirk_raw = trim($_POST['pr_bezirk'] ?? ''); - - // Pflichtfelder - if ($last_name === '' || $first_name === '' || $birth_date === '' || $address_street === '' || $address_plz === '') { - http_response_code(400); - echo json_encode(['status' => 'error', 'message' => 'Pflichtfelder fehlen.'], JSON_UNESCAPED_UNICODE); - exit; - } - - // Telefon: nur Ziffern (robust gegen +49/Leerzeichen) - $phone = preg_replace('/\D+/', '', $phone_raw); - if ($phone === '') { - $phone = null; // phone darf NULL sein (laut Tabelle) - } - - // WICHTIG: diese Spalten sind NOT NULL in deiner Tabelle -> niemals NULL senden - if ($address_link === '') $address_link = ''; - if ($comment === '') $comment = ''; - if ($letzter_besuch === '') $letzter_besuch = "2024-07-11"; - if ($pr_bezirk_raw === '') $pr_bezirk_raw = ''; // pr_bezirk ist NOT NULL (varchar) - - $sql = "INSERT INTO seelsorge_besuchsstellen - (last_name, first_name, birth_date, address_street, address_plz, address_link, phone, comment, letzter_besuch, priority, pr_bezirk, reservation) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)"; - - $stmt = $pdo->prepare($sql); - - $stmt->execute([ - $last_name, - $first_name, - $birth_date, - $address_street, - $address_plz, - $address_link, - $phone, - $comment, - $letzter_besuch, - $priority, - $pr_bezirk_raw - ]); - - $newId = $pdo->lastInsertId(); - - echo json_encode(['status' => 'success', 'besuch_id' => $newId], JSON_UNESCAPED_UNICODE); - -} catch (PDOException $e) { - http_response_code(500); - echo json_encode(['status' => 'error', 'message' => 'DB-Fehler: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE); -} catch (Throwable $e) { - http_response_code(500); - echo json_encode(['status' => 'error', 'message' => 'Server-Fehler: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE); -} diff --git a/web/intranet_repo/projects/seelsorge/content/calendar.php b/web/intranet_repo/projects/seelsorge/content/calendar.php index 570ac5a..20ac047 100644 --- a/web/intranet_repo/projects/seelsorge/content/calendar.php +++ b/web/intranet_repo/projects/seelsorge/content/calendar.php @@ -4,6 +4,7 @@ ?> - - \ No newline at end of file diff --git a/web/intranet_repo/projects/seelsorge/content/calendar_day.php b/web/intranet_repo/projects/seelsorge/content/calendar_day.php index 0351ade..6e21904 100644 --- a/web/intranet_repo/projects/seelsorge/content/calendar_day.php +++ b/web/intranet_repo/projects/seelsorge/content/calendar_day.php @@ -1,98 +1,113 @@ format('Y-m-d') !== $selectedDate) { + http_response_code(400); + exit('Ungültiges Datum.'); +} -if (isset($_POST['calendarDate'])): - $p_selectedDate = $_POST['calendarDate']; +$action = trim((string) ($_POST['action'] ?? 'view')); +if (!in_array($action, ['view', 'toggle'], true)) { + http_response_code(400); + exit('Ungültige Aktion.'); +} - require_once($g_projectsPath . 'seelsorge/util/connector_feiertage.php'); - $connector_feiertage = lib_Feiertage_Connector::getInstance(); - require_once($g_projectsPath . 'seelsorge/util/connector_kalendertage.php'); - $connector_kalendertage = lib_Kalendertage_Connector::getInstance(); - - // Tagesinformationen - $p_calendarMonthName = $connector_kalendertage->get_KalenderMonatsname($p_selectedDate); - $p_calendarWeekdayName = $connector_kalendertage->get_KalenderWochentag($p_selectedDate); - $p_dayNumber = date("j", strtotime($p_selectedDate)); - $p_year = date("Y", strtotime($p_selectedDate)); - $p_kw = date("W", strtotime($p_selectedDate)); - $feiertagsname = $connector_feiertage->get_feiertagsname($p_selectedDate); - - $p_dateTitle = $p_calendarWeekdayName . ", " . $p_dayNumber . ". " . $p_calendarMonthName . " " . $p_year; - - // Verfuegbare Diakon*innen laden - $sql = "SELECT u.*, p.\"DiakonIn\" FROM user_general_information u - LEFT JOIN user_permissions p ON u.user_id = p.user_id - ORDER BY RANDOM()"; - $result = $pdo->query($sql); - - $availableUsers = []; - $allRows = $result->fetchAll(); - if (count($allRows) > 0) { - foreach($allRows as $row) { - $userId = $row['user_id']; - $name = $row['first_name'] . " " . $row['last_name']; - if ($row['first_name'] != null && $row['last_name'] != null && $row['DiakonIn'] == 1) { - $stmt_av = $pdo->prepare("SELECT * FROM verfuegbarkeiten WHERE user_id = ? AND datum = ?"); - $stmt_av->execute([$userId, $p_selectedDate]); - if ($stmt_av->rowCount() > 0) { - $availableUsers[$userId] = $name; - } - } - } +if ($action === 'toggle') { + try { + seelsorge_verify_csrf($_POST); + } catch (InvalidArgumentException $e) { + http_response_code(400); + exit($e->getMessage()); } + if ($selectedDate < date('Y-m-d')) { + http_response_code(400); + exit('Vergangene Tage können nicht geändert werden.'); + } + try { + $nowAvailable = seelsorge_toggle_availability($pdo, $selectedDate, (int) $_SESSION['user_id']); + } catch (Throwable $e) { + error_log('Seelsorge availability toggle failed: ' . $e->getMessage()); + http_response_code(500); + exit('Verfügbarkeit konnte nicht geändert werden.'); + } +} - // Pruefe ob aktueller User eingetragen ist - $stmt_self = $pdo->prepare("SELECT COUNT(*) AS count FROM verfuegbarkeiten WHERE datum=? AND user_id=?"); - $stmt_self->execute([$p_selectedDate, $_SESSION["user_id"]]); - $selfResult = $stmt_self->fetch(); - $selfAvailable = $selfResult['count'] > 0; +$holidayConnector = lib_Feiertage_Connector::getInstance(); +$calendarConnector = lib_Kalendertage_Connector::getInstance(); +$weekdayName = $calendarConnector->get_KalenderWochentag($selectedDate); +$monthName = $calendarConnector->get_KalenderMonatsname($selectedDate); +$dateTitle = sprintf('%s, %d. %s %s', $weekdayName, (int) $dateObject->format('j'), $monthName, $dateObject->format('Y')); +$holidayName = $holidayConnector->get_feiertagsname($selectedDate); - // JSON-Metadaten fuer JS - echo ''; +try { + $stmt = $pdo->prepare( + 'SELECT v.user_id, u.first_name, u.last_name, u.mail AS email, COALESCE(p."DiakonIn", 0) AS "DiakonIn" + FROM verfuegbarkeiten v + LEFT JOIN user_general_information u ON u.user_id = v.user_id + LEFT JOIN user_permissions p ON p.user_id = v.user_id + WHERE v.datum = ? + ORDER BY RANDOM()' + ); + $stmt->execute([$selectedDate]); + $availabilityRows = $stmt->fetchAll(); +} catch (Throwable $e) { + error_log('Seelsorge calendar day load failed: ' . $e->getMessage()); + http_response_code(500); + exit('Tagesdaten konnten nicht geladen werden.'); +} + +$selfAvailable = false; +$availableUsers = []; +foreach ($availabilityRows as $row) { + $userId = (int) $row['user_id']; + if ($userId === (int) $_SESSION['user_id']) { + $selfAvailable = true; + } + if ((int) $row['DiakonIn'] === 1 && !empty($row['first_name']) && !empty($row['last_name'])) { + $availableUsers[$userId] = trim($row['first_name'] . ' ' . $row['last_name']); + } +} + +if ($action === 'toggle' && !empty($nowAvailable) && count($availabilityRows) > 1) { + try { + seelsorge_notify_match_by_mail($availabilityRows, $dateObject); + } catch (Throwable $e) { + error_log('Seelsorge match notification failed: ' . $e->getMessage()); + } +} ?> + - +
-
Feiertag
-
+
-
+ -
Verfügbare Personen
- 0): ?> +