redesign - new dashboard, new contentpanel template functionality, monatsplan fix #29

Merged
samuelbias merged 14 commits from dev into main 2026-09-04 14:31:10 +02:00
71 changed files with 8269 additions and 9119 deletions
Showing only changes of commit 0314bced40 - Show all commits
+87 -23
View File
@@ -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 <https://git-scm.com/install/windows> 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 <https://brew.sh/> 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:
> <https://docs.docker.com/engine/install/debian/>
> **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/>
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>
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
+40
View File
@@ -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:
+3
View File
@@ -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
+33 -25
View File
@@ -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();
}
?>
$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();
}
?>
@@ -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%; }
}
@@ -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); }
}
@@ -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); }
}
@@ -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)); }
}
@@ -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; }
}
@@ -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; }
}
@@ -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';
});
}
});
@@ -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();
});
});
@@ -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 =
'<div class="bs-chapter-title">Zwischenstand nach Säule ' + completedSection + '</div>' +
'<div class="bs-score-bar-wrap">' +
'<div class="bs-score-bar"><div class="bs-score-bar-fill" style="width:' + maxReachable + '%;background:' + barColor + '"></div></div>' +
'<div class="bs-score-bar-label">Max. erreichbar: ' + maxReachable + '% (mindestens 70% erforderlich)</div>' +
'</div>' +
'<div class="bs-score-grid">' +
'<div class="bs-score-card bs-score-correct"><div class="bs-score-card-num">' + stats.correct + '</div><div class="bs-score-card-label">Richtig</div></div>' +
'<div class="bs-score-card bs-score-wrong"><div class="bs-score-card-num">' + stats.wrong + '</div><div class="bs-score-card-label">Falsch</div></div>' +
'<div class="bs-score-card bs-score-open"><div class="bs-score-card-num">' + stats.open + '</div><div class="bs-score-card-label">Offen</div></div>' +
'<div class="bs-score-card"><div class="bs-score-card-num">' + answered + '/' + stats.total + '</div><div class="bs-score-card-label">Gesamt</div></div>' +
'</div>';
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 = '<button type="button" class="bs-btn" onclick="bsGoToSection(' + completedSection + ')">< Zurück zu Säule ' + completedSection + '</button>';
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 = '<div class="bs-chapter-title">' + (passed ? 'Schulung bestanden!' : 'Schulung nicht bestanden') + '</div>';
html += '<div class="bs-score-bar-wrap">' +
'<div class="bs-score-bar"><div class="bs-score-bar-fill" style="width:' + stats.percent + '%;background:' + barColor + '"></div></div>' +
'<div class="bs-score-bar-label">' + stats.percent + '% richtig (' + stats.correct + ' von ' + stats.total + ' Fragen)</div>' +
'</div>';
html += '<div class="bs-score-grid">' +
'<div class="bs-score-card bs-score-correct"><div class="bs-score-card-num">' + stats.correct + '</div><div class="bs-score-card-label">Richtig</div></div>' +
'<div class="bs-score-card bs-score-wrong"><div class="bs-score-card-num">' + stats.wrong + '</div><div class="bs-score-card-label">Falsch</div></div>' +
'</div>';
if (passed) {
html += '<div class="bs-successbox" style="margin-top:20px;">' +
'<strong>Herzlichen Glückwunsch!</strong> 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.' +
'</div>';
html += '<div style="margin-top:24px;padding:20px;background:var(--gray-50);border:1px solid var(--gray-200);">' +
'<label style="display:flex;align-items:flex-start;gap:12px;cursor:pointer;">' +
'<input type="checkbox" id="bsConfirmCheckbox" onchange="bsToggleConfirm()" style="width:20px;height:20px;margin-top:2px;">' +
'<span style="font-size:var(--font-size-sm);color:var(--gray-800);line-height:1.6;">' +
'Ich bestätige, dass ich die Inhalte der <a href="/intranet_repo/projects/brandschutz/Brandschutzordnung.pdf" target="_blank" onclick="event.stopPropagation();">Brandschutzordnung</a> verstanden und verinnerlicht habe. ' +
'Ich bin bereit, die Aufgaben als vBsU zu übernehmen, sofern ich dazu eingeteilt werde.' +
'</span>' +
'</label>' +
'</div>';
html += '<button type="button" class="bs-btn bs-btn-primary bs-btn-locked" id="bsFinishBtn" onclick="bsFinishTraining()" style="margin-top:24px;width:100%;padding:16px;font-size:var(--font-size-md);">Schulung abschließen</button>';
} else {
html += '<div class="bs-warnbox" style="margin-top:20px;">' +
'<div class="bs-warnbox-title">Leider nicht bestanden</div>' +
'Du hast ' + stats.percent + '% der Fragen richtig beantwortet. Zum Bestehen sind mindestens 70% erforderlich. ' +
'Bitte wiederhole die Schulung, um alle Inhalte zu verinnerlichen.' +
'</div>';
html += '<button type="button" class="bs-btn bs-btn-primary" onclick="bsRestartTraining()" style="margin-top:24px;width:100%;padding:16px;font-size:var(--font-size-md);">Schulung neu starten</button>';
}
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); });
});
})();
@@ -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;
});
});
});
})();
@@ -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');
@@ -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();
}
}());
@@ -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 = '<div class="seelsorge-modal-loading"><div class="loading-spinner"></div></div>';
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 = '<span class="seelsorge-modal-chip">KW ' + meta.kw + '</span>';
if (meta.feiertag) {
chips += '<span class="seelsorge-modal-chip seelsorge-chip-holiday">' + meta.feiertag + '</span>';
}
subtitle.innerHTML = chips;
metaEl.remove();
}
} else {
// PHP-Fehler direkt im Modal anzeigen
title.textContent = 'Fehler ' + response.status;
body.innerHTML = '<div style="padding:16px;font-family:monospace;font-size:0.85em;color:#d21400;white-space:pre-wrap;word-break:break-word;background:#fff5f5;border:1px solid #fcc;">'
+ (html || 'Keine Antwort vom Server.') + '</div>';
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 = '<span class="seelsorge-modal-chip">KW ' + meta.kw + '</span>';
if (meta.feiertag) {
chips += '<span class="seelsorge-modal-chip seelsorge-chip-holiday">' + escHtml(meta.feiertag) + '</span>';
}
subtitle.innerHTML = chips;
metaEl.remove();
return meta;
} catch (error) {
body.innerHTML = '<div class="seelsorge-modal-empty">Fehler beim Laden der Daten: ' + error.message + '</div>';
title.textContent = 'Fehler';
body.innerHTML = '<div class="seelsorge-modal-empty">' + escHtml(error.message || 'Fehler beim Laden der Daten.') + '</div>';
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';
}
}
// 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 += '<div class="visit-detail-section-title">Haushaltsmitglieder</div>';
members.forEach(m => {
html += `<div class="visit-detail-member">
<div class="visit-detail-member-info">
${escHtml(m.first_name)} ${escHtml(m.last_name)}
<span class="age">geb. ${escHtml(m.birth_formatted)} (${m.age} Jahre)</span>
</div>
<button class="visit-detail-member-edit" onclick="event.stopPropagation(); editMemberFromDetail(${m.besuch_id})" title="Bearbeiten"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg></button>
</div>`;
});
// Info grid
html += '<div class="visit-detail-section-title">Details</div>';
html += '<div class="visit-detail-info-grid">';
html += `<div class="visit-detail-info-item"><div class="visit-detail-info-label">Adresse</div><div class="visit-detail-info-value"><a href="${escHtml(data.address_link)}" target="_blank" style="color:var(--theme-color);text-decoration:none;">${escHtml(data.address_street)}, ${escHtml(data.address_plz)}</a></div></div>`;
const phone = members[0]?.phone || data.phone || '';
const phoneFormatted = phone ? ('0' + phone) : '—';
html += `<div class="visit-detail-info-item"><div class="visit-detail-info-label">Telefon</div><div class="visit-detail-info-value">${phone ? '<a href="tel:0'+escHtml(phone)+'" style="color:var(--theme-color);text-decoration:none;">'+escHtml(phoneFormatted)+'</a>' : '—'}</div></div>`;
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 += `<div class="visit-detail-info-item"><div class="visit-detail-info-label">Priester</div><div class="visit-detail-info-value">`;
html += `<span style="display:inline-flex;align-items:center;gap:8px;cursor:pointer;" onclick="openVisitProfile(${priestId})">`;
html += `<img src="${escHtml(priestImg)}" alt="" style="width:28px;height:28px;border-radius:50%;object-fit:cover;">`;
html += `<span style="color:var(--theme-color);text-decoration:none;">${escHtml(priestName)}</span>`;
html += `</span></div></div>`;
} else {
html += `<div class="visit-detail-info-item"><div class="visit-detail-info-label">Priester</div><div class="visit-detail-info-value">${escHtml(priestName)}</div></div>`;
}
const comment = members.map(m => m.comment).filter(c => c).join('; ') || '—';
html += `<div class="visit-detail-info-item"><div class="visit-detail-info-label">Hinweis</div><div class="visit-detail-info-value">${escHtml(comment)}</div></div>`;
html += '</div>';
// Reservation info
if (data.reservation) {
html += '<div class="visit-detail-section-title">Geplanter Besuch</div>';
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 += '<div class="visit-detail-info-item" style="grid-column:1/-1;"><div class="visit-detail-info-label">Geplant von</div><div class="visit-detail-info-value">';
html += `<span style="display:inline-flex;align-items:center;gap:8px;cursor:pointer;" onclick="openVisitProfile(${resId})">`;
html += `<img src="${escHtml(resImg)}" alt="" style="width:28px;height:28px;border-radius:50%;object-fit:cover;">`;
html += `<span style="color:var(--theme-color);text-decoration:none;">${escHtml(resName)}</span>`;
html += `</span>`;
html += '</div></div>';
}
// Actions
const firstBesuchId = members[0]?.besuch_id || 0;
html += '<div class="visit-detail-actions">';
if (!data.reservation) {
html += `<button class="visit-action-btn visit-action-btn-primary" onclick="doReservation(${firstBesuchId})">Reservieren</button>`;
} else if (parseInt(data.reservation) === getVisitConfig().currentUserId) {
html += `<button class="visit-action-btn" style="color:hsl(145,55%,34%);border-color:hsl(145,55%,80%);background:hsl(145,55%,96%);" onclick="doComplete(${firstBesuchId})"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" style="vertical-align:middle;margin-right:4px;"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>Abschließen</button>`;
}
html += `<button class="visit-action-btn" onclick="doPrioritize(${firstBesuchId})">Priorisieren</button>`;
html += '</div>';
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 = '<div style="opacity:.75;padding:8px;">Keine Einträge vorhanden.</div>';
return;
}
const filtered = getVisitConfig().entries.filter(e => matchesQuery(e, query));
if (filtered.length === 0) {
list.innerHTML = '<div style="opacity:.75;padding:8px;">Keine Treffer.</div>';
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);
}
});
@@ -1,457 +1,117 @@
<?php /* Name: admin_brandschutz.php
Beschreibung: Brandschutz-Schulungsverwaltung im Adminpanel
- Übersicht aller Nutzer mit Brandschutz-Rolle
- Fälligkeitsstatus und Details
- Manuelle Anpassung des Schulungsdatums */
<?php
/* Name: admin_brandschutz.php
Beschreibung: Brandschutz-Schulungsverwaltung im Adminpanel */
require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/initialise.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/initialise.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/projects/brandschutz/util/brandschutz_helpers.php';
// Admin-Check
$stmt = $pdo->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);
?>
<!DOCTYPE html>
<html lang="de" dir="ltr">
<head>
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/html_head.php'; ?>
<style>
.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%);
}
<link rel="stylesheet" href="/intranet_repo/projects/_central/layouts/admin_brandschutz.css">
<script src="/intranet_repo/projects/_central/scripts/admin_brandschutz.js" defer></script>
/* 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%; }
}
</style>
</head>
<body class="theme-admin">
@@ -487,45 +147,9 @@
<tbody>
<?php foreach ($bsUsers as $bsUser): ?>
<?php
$nameOutput = getDisplayName_bs($bsUser);
$profileUrl = getProfileImageUrl_bs($bsUser, $g_profilePicturePath);
$lastTraining = $bsUser['last_training_date'];
$reminderCount = (int)($bsUser['reminder_count'] ?? 0);
$adminReminderCount = (int)($bsUser['admin_reminder_count'] ?? 0);
// Berechne Fälligkeit
if (empty($lastTraining)) {
$dueDate = null;
$daysOverdue = null;
$statusText = 'Nie geschult';
$statusClass = 'bs-status-overdue';
$dueDateDisplay = 'Sofort fällig';
} else {
$dueDate = date('Y-m-d', strtotime($lastTraining . ' +365 days'));
$today = date('Y-m-d');
$diff = (strtotime($dueDate) - strtotime($today)) / 86400;
if ($diff > 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'];
?>
<tr class="admin-user-row" tabindex="0" role="button" aria-label="<?= htmlspecialchars($nameOutput) ?> Details" onclick="openBsModal(<?= (int)$bsUser['user_id'] ?>)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();openBsModal(<?= (int)$bsUser['user_id'] ?>)}">
<td>
@@ -537,10 +161,10 @@
<span class="admin-user-name"><?= htmlspecialchars($nameOutput) ?></span>
</td>
<td>
<span class="bs-due-date"><?= $dueDateDisplay ?></span>
<span class="bs-due-date"><?= htmlspecialchars((string) $trainingStatus['due_date_display']) ?></span>
</td>
<td>
<span class="bs-status-badge <?= $statusClass ?>"><?= $statusText ?></span>
<span class="bs-status-badge <?= htmlspecialchars((string) $trainingStatus['status_class']) ?>"><?= htmlspecialchars((string) $trainingStatus['status_text']) ?></span>
</td>
</tr>
<?php endforeach; ?>
@@ -555,34 +179,12 @@
<!-- Modals für jeden Brandschutz-Nutzer -->
<?php foreach ($bsUsers as $bsUser): ?>
<?php
$nameOutput = getDisplayName_bs($bsUser);
$profileUrl = getProfileImageUrl_bs($bsUser, $g_profilePicturePath);
$nameOutput = (string) $bsUser['_display_name'];
$profileUrl = (string) $bsUser['_profile_url'];
$lastTraining = $bsUser['last_training_date'];
$reminderCount = (int)($bsUser['reminder_count'] ?? 0);
$adminReminderCount = (int)($bsUser['admin_reminder_count'] ?? 0);
if (empty($lastTraining)) {
$dueDate = null;
$dueDateDisplay = 'Sofort fällig';
$lastTrainingDisplay = 'Nie';
$overdueText = 'Keine Schulung hinterlegt – sofort fällig';
$overdueClass = 'overdue';
} else {
$dueDate = date('Y-m-d', strtotime($lastTraining . ' +365 days'));
$dueDateDisplay = date('d.m.Y', strtotime($dueDate));
$lastTrainingDisplay = date('d.m.Y', strtotime($lastTraining));
$today = date('Y-m-d');
$diff = (strtotime($dueDate) - strtotime($today)) / 86400;
if ($diff > 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'];
?>
<div class="standard_overlay" id="bsModal-<?= (int)$bsUser['user_id'] ?>" onclick="if(event.target.id==='bsModal-<?= (int)$bsUser['user_id'] ?>')closeBsModal(<?= (int)$bsUser['user_id'] ?>)">
<div class="admin-modal-card">
@@ -605,15 +207,15 @@
<div class="bs-detail-grid">
<div class="bs-detail-card">
<div class="bs-detail-label">Letzte Schulung</div>
<div class="bs-detail-value"><?= $lastTrainingDisplay ?></div>
<div class="bs-detail-value"><?= htmlspecialchars((string) $trainingStatus['last_training_display']) ?></div>
</div>
<div class="bs-detail-card">
<div class="bs-detail-label">Nächste Fälligkeit</div>
<div class="bs-detail-value"><?= $dueDateDisplay ?></div>
<div class="bs-detail-value"><?= htmlspecialchars((string) $trainingStatus['due_date_display']) ?></div>
</div>
<div class="bs-detail-card">
<div class="bs-detail-label">Überfälligkeit</div>
<div class="bs-detail-value <?= $overdueClass ?>"><?= $overdueText ?></div>
<div class="bs-detail-value <?= htmlspecialchars((string) $trainingStatus['overdue_class']) ?>"><?= htmlspecialchars((string) $trainingStatus['overdue_text']) ?></div>
</div>
<div class="bs-detail-card">
<div class="bs-detail-label">Erinnerungsmails gesendet</div>
@@ -629,6 +231,8 @@
<div class="admin-section-title">Schulungsdatum anpassen</div>
<form method="post" action="">
<input type="hidden" name="action" value="update_training_date">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="bs_user_id" value="<?= (int)$bsUser['user_id'] ?>">
<div style="display:flex;gap:10px;align-items:flex-end;margin-bottom:8px;">
<div style="flex:1;">
@@ -643,7 +247,7 @@
<button type="button" onclick="closeBsModal(<?= (int)$bsUser['user_id'] ?>)">Schließen</button>
</div>
<div>
<button type="submit" name="bs_update_date" value="1" class="admin-save-button">Datum speichern</button>
<button type="submit" class="admin-save-button">Datum speichern</button>
</div>
</div>
</form>
@@ -653,23 +257,7 @@
<?php endforeach; ?>
</div><!-- content -->
<script>
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';
});
}
});
</script>
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/footer.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 @@
<button type="button" class="inbox-btn inbox-btn-approve" style="background:linear-gradient(135deg,var(--nak-orange),hsl(25,95%,40%)) !important;border-color:hsl(25,95%,40%) !important;" onclick="openBsEscalationModal(<?= (int)$msg['id'] ?>, <?= (int)$data['user_id'] ?>, '<?= htmlspecialchars($data['user_name'] ?? '', ENT_QUOTES) ?>', <?= (int)($data['days_overdue'] ?? 0) ?>)">Persönliche Erinnerung bestätigen</button>
<?php else: ?>
<form method="post" action="">
<input type="hidden" name="csrf_token" value="<?= authorization_html($authorizationCsrfToken) ?>">
<input type="hidden" name="mark_done" value="<?= (int)$msg['id'] ?>">
<button type="submit" class="inbox-btn inbox-btn-done">Als erledigt markieren</button>
</form>
<?php endif; ?>
<?php else: ?>
<form method="post" action="">
<input type="hidden" name="csrf_token" value="<?= authorization_html($authorizationCsrfToken) ?>">
<input type="hidden" name="mark_done" value="<?= (int)$msg['id'] ?>">
<button type="submit" class="inbox-btn inbox-btn-done">Als erledigt markieren</button>
</form>
@@ -453,6 +462,7 @@
<div style="padding:22px 24px 24px 24px;">
<form method="post" action="approve-registration.php" id="regModalForm">
<input type="hidden" name="csrf_token" value="<?= authorization_html($authorizationCsrfToken) ?>">
<input type="hidden" name="pending_id" id="regModalPendingId">
<input type="hidden" name="message_id" id="regModalMessageId">
<input type="hidden" name="action" id="regModalAction">
@@ -542,6 +552,7 @@ document.addEventListener('keydown', function(e) {
</div>
<form method="post" action="" id="bsEscForm">
<input type="hidden" name="csrf_token" value="<?= authorization_html($authorizationCsrfToken) ?>">
<input type="hidden" name="bs_escalation_msg_id" id="bsEscMsgId">
<input type="hidden" name="bs_escalation_user_id" id="bsEscUserId">
<input type="hidden" name="bs_escalation_confirm" value="1">
@@ -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';
@@ -1,106 +1,96 @@
<?php /* Name: login.php
Beschreibung: Login-Seite (Authorisierung) */
require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/ini/parameters.php';
require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/ini/functions.php';
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$pdo = require_once($_SERVER['DOCUMENT_ROOT']."/intranet_repo/general_files/ini/database.php");
$sql = "SELECT
u.*,
l.password_hash AS password_hash
FROM
user_general_information AS u
INNER JOIN
user_login AS l ON u.user_id = l.user_id
WHERE
u.mail = ?";
$stmt = $pdo->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;
}
?>
<!DOCTYPE html>
<html lang="de" dir="ltr">
<head>
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/html_head.php'; ?>
<script src="https://unpkg.com/just-validate@latest/dist/just-validate.production.min.js" defer></script>
<script src="validation.js" defer></script>
</head>
<body>
<div class="login_panel">
<?php if (isset($is_invalid) && $is_invalid): ?>
<div class="nak-toast nak-toast--error nak-toast--inline" id="nakToastLogin">
<div class="nak-toast__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<span class="nak-toast__text">Ungültiger Login!</span>
</div>
<script>requestAnimationFrame(function(){document.getElementById('nakToastLogin').classList.add('nak-toast--visible');});</script>
<?php endif; ?>
<?php if (isset($_GET["message"]) && $_GET["message"] == 1): ?>
<div class="nak-toast nak-toast--success nak-toast--inline" id="nakToastPw">
<div class="nak-toast__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<span class="nak-toast__text">Passwort erfolgreich geändert. Sie können sich jetzt anmelden.</span>
</div>
<script>requestAnimationFrame(function(){document.getElementById('nakToastPw').classList.add('nak-toast--visible');});</script>
<?php endif; ?>
<div class="login_content">
<div class="nak-logo-link auth-logo">
<img class="auth-logo__icon" src="/intranet_repo/projects/_central/img/nak-logo.svg" alt="NAK Logo">
<div class="auth-logo__text">
<div class="auth-logo__name">Neuapostolische Kirche</div>
<div class="auth-logo__gemeinde">Berlin-Reinickendorf</div>
</div>
</div>
<h2>Anmelden</h2>
<form method="post">
<div class="flexcolumn_center">
<input class="login_field" placeholder="E-Mailadresse" type="email" name="email" id="email"
value="<?= htmlspecialchars($_POST["email"] ?? "") ?>">
<input class="login_field" placeholder="Passwort" type="password" name="password" id="password">
<input class="login_button" type="submit" name="submit" value="Anmelden">
</div>
</form>
<div class="small_text" style="text-align:center;margin-top:24px;color:var(--gray-600);">
Sie haben noch keinen Account für das Intranet?<br>
<a href="signup.php">Registrieren</a><br>
<br>
Passwort vergessen?<br>
<a href="reset-password.php">Passwort zurücksetzen</a><br>
<br>
Probleme bei der Anmeldung?<br>
<a href="#" onclick="openContactModal(); return false;">Admin kontaktieren</a>
</div>
</div><!-- EOD: login_content -->
</div><!-- EOD: login_panel -->
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/footer.php'; ?>
</body>
</html>
<?php
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/parameters.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/functions.php';
require_once __DIR__ . '/util/authorization_helpers.php';
authorization_start_session();
authorization_send_security_headers();
if (isset($_SESSION['user_id'])) {
authorization_redirect($g_httpHomePath, 302);
}
$isInvalid = false;
$email = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim((string) ($_POST['email'] ?? ''));
$loginPassword = (string) ($_POST['password'] ?? '');
if (!authorization_csrf_is_valid($_POST['csrf_token'] ?? null)) {
$isInvalid = true;
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL) || $loginPassword === '') {
$isInvalid = true;
} else {
$pdo = require $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/database.php';
$userId = authorization_authenticate($pdo, $email, $loginPassword);
if ($userId !== null) {
session_regenerate_id(true);
$_SESSION = ['user_id' => $userId];
authorization_redirect($g_httpHomePath);
}
$isInvalid = true;
}
}
$csrfToken = authorization_csrf_token();
?>
<!DOCTYPE html>
<html lang="de" dir="ltr">
<head>
<?php require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/projects/_central/php/html_head.php'; ?>
<link rel="stylesheet" href="/intranet_repo/projects/_central/layouts/authorization.css">
<script src="/intranet_repo/projects/_central/scripts/authorization.js" defer></script>
</head>
<body>
<div class="login_panel">
<?php if ($isInvalid): ?>
<div class="nak-toast nak-toast--error nak-toast--inline" data-auth-toast>
<div class="nak-toast__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<span class="nak-toast__text">Ungültige E-Mail-Adresse oder ungültiges Passwort.</span>
</div>
<?php endif; ?>
<?php if (($_GET['message'] ?? '') === '1'): ?>
<div class="nak-toast nak-toast--success nak-toast--inline" data-auth-toast>
<div class="nak-toast__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<span class="nak-toast__text">Passwort erfolgreich geändert. Sie können sich jetzt anmelden.</span>
</div>
<?php endif; ?>
<div class="login_content">
<div class="nak-logo-link auth-logo">
<img class="auth-logo__icon" src="/intranet_repo/projects/_central/img/nak-logo.svg" alt="NAK Logo">
<div class="auth-logo__text">
<div class="auth-logo__name">Neuapostolische Kirche</div>
<div class="auth-logo__gemeinde">Berlin-Reinickendorf</div>
</div>
</div>
<h2>Anmelden</h2>
<form method="post" autocomplete="on">
<input type="hidden" name="csrf_token" value="<?= authorization_html($csrfToken) ?>">
<div class="flexcolumn_center">
<input class="login_field" placeholder="E-Mailadresse" type="email" name="email" id="email" value="<?= authorization_html($email) ?>" required autocomplete="username">
<input class="login_field" placeholder="Passwort" type="password" name="password" id="password" required autocomplete="current-password">
<input class="login_button" type="submit" value="Anmelden">
</div>
</form>
<div class="small_text" style="text-align:center;margin-top:24px;color:var(--gray-600);">
Sie haben noch keinen Account für das Intranet?<br>
<a href="signup.php">Registrieren</a><br><br>
Passwort vergessen?<br>
<a href="reset-password.php">Passwort zurücksetzen</a><br><br>
Probleme bei der Anmeldung?<br>
<a href="#" onclick="openContactModal(); return false;">Admin kontaktieren</a>
</div>
</div>
</div>
<?php require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/projects/_central/php/footer.php'; ?>
</body>
</html>
@@ -1,13 +1,22 @@
<?php /* Name: loginout.php
Beschreibung: Logout-Seite */
<?php
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/parameters.php';
require_once __DIR__ . '/util/authorization_helpers.php';
if (session_status() == PHP_SESSION_NONE) {
session_start();
}
require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/ini/parameters.php';
require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/ini/functions.php';
authorization_start_session();
authorization_send_security_headers();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', [
'expires' => 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();
authorization_redirect($g_httpLoginPath, 302);
@@ -1,223 +0,0 @@
<?php /* Name: reset-password-process.php
Beschreibung: Verarbeitung des Passwort-Resets (3 Schritte)
- Schritt 1: OTP generieren und per SMS + Mail senden
- Schritt 2: OTP verifizieren → Reset-Token erzeugen
- Schritt 3: Neues Passwort setzen mit Reset-Token */
$pdo = require_once($_SERVER['DOCUMENT_ROOT']."/intranet_repo/general_files/ini/database.php");
require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/ini/send_mail.php';
// PostgreSQL-Zeitzone auf PHP-Zeitzone setzen
$pdo->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;
?>
@@ -1,159 +1,324 @@
<?php /* Name: reset-password.php
Beschreibung: Passwort-Reset per SMS OTP (3 Schritte)
- Schritt 1: E-Mail eingeben → OTP per SMS
- Schritt 2: OTP-Code verifizieren
- Schritt 3: Neues Passwort setzen */
if (session_status() == PHP_SESSION_NONE) {
session_start();
}
require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/ini/parameters.php';
require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/ini/functions.php';
$step = $_GET["step"] ?? "email";
?>
<!DOCTYPE html>
<html lang="de" dir="ltr">
<head>
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/html_head.php'; ?>
<style>
.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: #FFFFFF;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="login_panel">
<?php if (isset($_GET["error"])): ?>
<?php
$errorMessages = [
1 => "E-Mail-Adresse nicht gefunden oder nicht registriert.",
2 => "Keine Telefonnummer hinterlegt. Bitte wenden Sie sich an den Administrator.",
3 => "Ungültiger oder abgelaufener Code.",
4 => "SMS konnte nicht gesendet werden. Bitte versuchen Sie es erneut.",
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."
];
$errorNum = (int)$_GET["error"];
$errorText = $errorMessages[$errorNum] ?? "Unbekannter Fehler.";
?>
<div class="nak-toast nak-toast--error nak-toast--inline" id="nakToastErr">
<div class="nak-toast__icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
<span class="nak-toast__text"><?= htmlspecialchars($errorText) ?></span>
</div>
<script>requestAnimationFrame(function(){document.getElementById('nakToastErr').classList.add('nak-toast--visible');});</script>
<?php endif; ?>
<?php if (isset($_GET["success"]) && $_GET["success"] == 1): ?>
<div class="nak-toast nak-toast--success nak-toast--inline" id="nakToastOk">
<div class="nak-toast__icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter"><path d="M20 6L9 17l-5-5"/></svg></div>
<span class="nak-toast__text">Passwort erfolgreich geändert. Sie können sich jetzt anmelden.</span>
</div>
<script>requestAnimationFrame(function(){document.getElementById('nakToastOk').classList.add('nak-toast--visible');});</script>
<?php endif; ?>
<div class="login_content">
<div class="nak-logo-link auth-logo">
<img class="auth-logo__icon" src="/intranet_repo/projects/_central/img/nak-logo.svg" alt="NAK Logo">
<div class="auth-logo__text">
<div class="auth-logo__name">Neuapostolische Kirche</div>
<div class="auth-logo__gemeinde">Berlin-Reinickendorf</div>
</div>
</div>
<h2>Passwort zurücksetzen</h2>
<?php if ($step === "email"): ?>
<!-- SCHRITT 1: E-Mail eingeben -->
<p style="text-align:center;color:var(--gray-600);font-size:var(--font-size-sm);margin-bottom:var(--space-sm);">Geben Sie Ihre E-Mail-Adresse ein. Ein Bestätigungscode wird an Ihre hinterlegte Telefonnummer und per Mail gesendet.</p>
<form action="reset-password-process.php" method="post" id="otpRequestForm">
<input type="hidden" name="action" value="request_otp">
<label for="email">E-Mailadresse</label>
<div class="item">
<input class="login_field" type="email" id="email" name="email" required>
</div>
<button class="login_button" type="submit" id="requestBtn">Code anfordern</button>
</form>
<script>
document.getElementById('otpRequestForm').addEventListener('submit', function() {
var btn = document.getElementById('requestBtn');
btn.classList.add('is-loading');
btn.disabled = true;
});
</script>
<?php elseif ($step === "verify"): ?>
<!-- SCHRITT 2: Nur OTP-Code eingeben -->
<?php $phone_masked = htmlspecialchars($_GET["phone"] ?? "***"); ?>
<p style="text-align:center;color:var(--gray-600);font-size:var(--font-size-sm);margin-bottom:var(--space-sm);">Ein 6-stelliger Code wurde an die Nummer +49 <?= $phone_masked ?> und an Ihre E-Mail-Adresse gesendet.</p>
<form action="reset-password-process.php" method="post">
<input type="hidden" name="action" value="verify_otp">
<input type="hidden" name="user_id" value="<?= (int)($_GET["uid"] ?? 0) ?>">
<label for="otp_code">Bestätigungscode</label>
<div class="item">
<input class="login_field" type="text" id="otp_code" name="otp_code" maxlength="6" inputmode="numeric" pattern="[0-9]{6}" placeholder="000000" required autocomplete="one-time-code" style="text-align:center;font-size:1.4em;letter-spacing:8px;">
</div>
<input class="login_button" type="submit" value="Code bestätigen">
</form>
<?php elseif ($step === "newpassword"): ?>
<!-- SCHRITT 3: Neues Passwort setzen (nur nach verifiziertem OTP) -->
<p style="text-align:center;color:var(--gray-600);font-size:var(--font-size-sm);margin-bottom:var(--space-sm);">Ihr Code wurde bestätigt. Geben Sie nun Ihr neues Passwort ein.</p>
<form action="reset-password-process.php" method="post">
<input type="hidden" name="action" value="set_password">
<input type="hidden" name="reset_token" value="<?= htmlspecialchars($_GET["token"] ?? '') ?>">
<label for="new_password">Neues Passwort</label>
<div class="item">
<input class="login_field" type="password" id="new_password" name="new_password" required>
</div>
<label for="new_password_confirm">Neues Passwort wiederholen</label>
<div class="item">
<input class="login_field" type="password" id="new_password_confirm" name="new_password_confirm" required>
</div>
<input class="login_button" type="submit" value="Passwort ändern">
</form>
<?php endif; ?>
<div class="small_text" align="center">
<br>
<a href="login.php">Zurück zur Anmeldung</a><br>
<br>
Probleme beim Zurücksetzen?<br>
<a href="#" onclick="openContactModal(); return false;">Admin kontaktieren</a>
</div>
</div><!-- EOD: login_content -->
</div><!-- EOD: login_panel -->
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/footer.php'; ?>
</body>
</html>
<?php
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/parameters.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/functions.php';
require_once __DIR__ . '/util/authorization_helpers.php';
authorization_start_session();
authorization_send_security_headers();
if (isset($_SESSION['user_id'])) {
authorization_redirect($g_httpHomePath, 302);
}
$allowedSteps = ['email', 'verify', 'newpassword'];
$step = (string) ($_GET['step'] ?? 'email');
if (!in_array($step, $allowedSteps, true)) {
$step = 'email';
}
$error = null;
$errorCode = (int) ($_GET['error'] ?? 0);
$errorMessages = [
1 => '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();
?>
<!DOCTYPE html>
<html lang="de" dir="ltr">
<head>
<?php require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/projects/_central/php/html_head.php'; ?>
<link rel="stylesheet" href="/intranet_repo/projects/_central/layouts/authorization.css">
<script src="/intranet_repo/projects/_central/scripts/authorization.js" defer></script>
</head>
<body>
<div class="login_panel">
<?php if ($error !== null): ?>
<div class="nak-toast nak-toast--error nak-toast--inline" data-auth-toast>
<div class="nak-toast__icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
<span class="nak-toast__text"><?= authorization_html($error) ?></span>
</div>
<?php endif; ?>
<div class="login_content">
<div class="nak-logo-link auth-logo">
<img class="auth-logo__icon" src="/intranet_repo/projects/_central/img/nak-logo.svg" alt="NAK Logo">
<div class="auth-logo__text">
<div class="auth-logo__name">Neuapostolische Kirche</div>
<div class="auth-logo__gemeinde">Berlin-Reinickendorf</div>
</div>
</div>
<h2>Passwort zurücksetzen</h2>
<?php if ($step === 'email'): ?>
<p style="text-align:center;color:var(--gray-600);font-size:var(--font-size-sm);margin-bottom:var(--space-sm);">Geben Sie Ihre E-Mail-Adresse ein. Wenn ein passendes Konto vorhanden ist, senden wir einen Bestätigungscode an die hinterlegten Kontaktwege.</p>
<form method="post" id="otpRequestForm">
<input type="hidden" name="action" value="request_otp">
<input type="hidden" name="csrf_token" value="<?= authorization_html($csrfToken) ?>">
<label for="email">E-Mailadresse</label>
<div class="item"><input class="login_field" type="email" id="email" name="email" maxlength="254" required autocomplete="email"></div>
<button class="login_button" type="submit" id="requestBtn">Code anfordern</button>
</form>
<?php elseif ($step === 'verify'): ?>
<p style="text-align:center;color:var(--gray-600);font-size:var(--font-size-sm);margin-bottom:var(--space-sm);"><?= authorization_html(authorization_masked_reset_message()) ?></p>
<form method="post">
<input type="hidden" name="action" value="verify_otp">
<input type="hidden" name="csrf_token" value="<?= authorization_html($csrfToken) ?>">
<label for="otp_code">Bestätigungscode</label>
<div class="item"><input class="login_field" type="text" id="otp_code" name="otp_code" maxlength="6" inputmode="numeric" pattern="[0-9]{6}" placeholder="000000" required autocomplete="one-time-code" style="text-align:center;font-size:1.4em;letter-spacing:8px;"></div>
<input class="login_button" type="submit" value="Code bestätigen">
</form>
<?php elseif ($step === 'newpassword'): ?>
<p style="text-align:center;color:var(--gray-600);font-size:var(--font-size-sm);margin-bottom:var(--space-sm);">Ihr Code wurde bestätigt. Geben Sie nun Ihr neues Passwort ein.</p>
<form method="post">
<input type="hidden" name="action" value="set_password">
<input type="hidden" name="csrf_token" value="<?= authorization_html($csrfToken) ?>">
<label for="new_password">Neues Passwort</label>
<div class="item"><input class="login_field" type="password" id="new_password" name="new_password" required autocomplete="new-password"></div>
<label for="new_password_confirm">Neues Passwort wiederholen</label>
<div class="item"><input class="login_field" type="password" id="new_password_confirm" name="new_password_confirm" required autocomplete="new-password"></div>
<input class="login_button" type="submit" value="Passwort ändern">
</form>
<?php endif; ?>
<div class="small_text" align="center">
<br><a href="login.php">Zurück zur Anmeldung</a><br><br>
Probleme beim Zurücksetzen?<br>
<a href="#" onclick="openContactModal(); return false;">Admin kontaktieren</a>
</div>
</div>
</div>
<?php require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/projects/_central/php/footer.php'; ?>
</body>
</html>
@@ -1,97 +0,0 @@
<?php /* Name: self-signup-process.php
Beschreibung: Verarbeitung der Selbstregistrierung
- Validierung der Eingaben
- Speicherung in pending_registrations
- Mail an Admin (samuel.bias@aol.de)
- Eintrag in admin_messages */
$pdo = require_once($_SERVER['DOCUMENT_ROOT']."/intranet_repo/general_files/ini/database.php");
require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/ini/send_mail.php';
// Validierung
if (empty($_POST["first_name"])) {
die("Vorname ist erforderlich.");
}
if (empty($_POST["last_name"])) {
die("Nachname ist erforderlich.");
}
if (!filter_var($_POST["email"], FILTER_VALIDATE_EMAIL)) {
die("Gültige E-Mail-Adresse erforderlich.");
}
if (strlen($_POST["password"]) < 8) {
die("Passwort muss mindestens 8 Zeichen lang sein.");
}
if (!preg_match("/[a-z]/i", $_POST["password"])) {
die("Passwort muss mindestens einen Buchstaben enthalten.");
}
if (!preg_match("/[0-9]/", $_POST["password"])) {
die("Passwort muss mindestens eine Zahl enthalten.");
}
if ($_POST["password"] !== $_POST["password_confirmation"]) {
die("Passwörter stimmen nicht überein.");
}
$email = trim($_POST["email"]);
$first_name = trim($_POST["first_name"]);
$last_name = trim($_POST["last_name"]);
// Prüfen ob E-Mail bereits existiert (in user_general_information oder pending_registrations)
$checkSql = "SELECT COUNT(*) FROM user_general_information WHERE mail = ?";
$stmt = $pdo->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;
?>
@@ -1,101 +0,0 @@
<?php /* Name: self-signup.php
Beschreibung: Selbstregistrierung - Nutzer kann sich eigenständig registrieren.
Admin erhält Mail + Inbox-Nachricht zur Freischaltung. */
if (session_status() == PHP_SESSION_NONE) {
session_start();
}
require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/ini/parameters.php';
require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/ini/functions.php';
?>
<!DOCTYPE html>
<html lang="de" dir="ltr">
<head>
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/html_head.php'; ?>
<script src="just-validate.js" defer></script>
<script src="validation.js" defer></script>
</head>
<body>
<div class="login_panel">
<?php if (isset($_GET["error"])): ?>
<?php if ($_GET["error"] == 1): ?>
<div class="nak-toast nak-toast--error nak-toast--inline" id="nakToastErr">
<div class="nak-toast__icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
<span class="nak-toast__text">Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.</span>
</div>
<script>requestAnimationFrame(function(){document.getElementById('nakToastErr').classList.add('nak-toast--visible');});</script>
<?php elseif ($_GET["error"] == 2): ?>
<div class="nak-toast nak-toast--error nak-toast--inline" id="nakToastErr">
<div class="nak-toast__icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
<span class="nak-toast__text">Diese E-Mailadresse ist bereits registriert oder es liegt bereits eine Anfrage vor.</span>
</div>
<script>requestAnimationFrame(function(){document.getElementById('nakToastErr').classList.add('nak-toast--visible');});</script>
<?php endif; ?>
<?php endif; ?>
<?php if (isset($_GET["success"]) && $_GET["success"] == 1): ?>
<div class="nak-toast nak-toast--success nak-toast--inline" id="nakToastOk">
<div class="nak-toast__icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter"><path d="M20 6L9 17l-5-5"/></svg></div>
<span class="nak-toast__text">Ihre Registrierungsanfrage wurde gesendet. Sie erhalten eine Benachrichtigung, sobald Ihr Konto freigeschaltet wurde.</span>
</div>
<script>requestAnimationFrame(function(){document.getElementById('nakToastOk').classList.add('nak-toast--visible');});</script>
<?php endif; ?>
<div class="login_content">
<div class="nak-logo-link" style="display:flex;align-items:flex-end;gap:12px;text-decoration:none;margin-bottom:24px;">
<img src="/intranet_repo/projects/_central/img/nak-logo.svg" alt="NAK Logo" style="width:52px;height:52px;flex-shrink:0;">
<div style="display:flex;flex-direction:column;justify-content:flex-end;line-height:1.2;">
<div style="font-family:var(--font-primary);font-weight:500;font-size:0.875rem;color:var(--gray-900);white-space:nowrap;">Neuapostolische Kirche</div>
<div style="font-family:var(--font-primary);font-weight:300;font-size:0.75rem;color:var(--gray-600);white-space:nowrap;">Berlin-Reinickendorf</div>
</div>
</div>
<h2>Registrierung beantragen</h2>
<h3 class="no_margins" align="center">Ihr Antrag wird von einem Administrator geprüft und freigeschaltet.</h3>
<form action="self-signup-process.php" method="post" id="signup" novalidate>
<label for="first_name"><br>Vorname</label>
<div class="item">
<input class="login_field" type="text" id="first_name" name="first_name" required>
</div>
<label for="last_name">Nachname</label>
<div class="item">
<input class="login_field" type="text" id="last_name" name="last_name" required>
</div>
<label for="email">E-Mailadresse</label>
<div class="item">
<input class="login_field" type="email" id="email" name="email" required>
</div>
<label for="password">Passwort</label>
<div class="item">
<input class="login_field" type="password" id="password" name="password" required>
</div>
<label for="password_confirmation">Passwort wiederholen</label>
<div class="item">
<input class="login_field" type="password" id="password_confirmation" name="password_confirmation" required>
</div>
<input class="login_button" type="submit" value="Registrierung beantragen">
</form>
<div class="small_text" align="center">
Sie haben bereits einen Account?
<br>
<a href="login.php">Anmelden</a>
<br><br>
Sie wurden bereits vom Admin vorregistriert?
<br>
<a href="signup.php">Direkt registrieren</a>
</div>
</div><!-- EOD: login_content -->
</div><!-- EOD: login_panel -->
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/footer.php'; ?>
</body>
</html>
@@ -1,118 +0,0 @@
<?php /* Name: signup-process.php
Beschreibung: Vereinte Registrierungs-Verarbeitung
- Prüft ob Nutzer vorregistriert ist → direkte Registrierung
- Wenn nicht → automatisch Antrag in pending_registrations + Mail an Admin */
$pdo = require_once($_SERVER['DOCUMENT_ROOT']."/intranet_repo/general_files/ini/database.php");
require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/ini/send_mail.php';
// === Validierung ===
if (empty($_POST["first_name"])) {
die("Vorname ist erforderlich.");
}
if (empty($_POST["last_name"])) {
die("Nachname ist erforderlich.");
}
if (!filter_var($_POST["email"], FILTER_VALIDATE_EMAIL)) {
die("Gültige E-Mail-Adresse erforderlich.");
}
if (empty($_POST["phone"])) {
die("Telefonnummer ist erforderlich.");
}
if (strlen($_POST["password"]) < 8) {
die("Passwort muss mindestens 8 Zeichen lang sein.");
}
if (!preg_match("/[a-z]/i", $_POST["password"])) {
die("Passwort muss mindestens einen Buchstaben enthalten.");
}
if (!preg_match("/[0-9]/", $_POST["password"])) {
die("Passwort muss mindestens eine Zahl enthalten.");
}
if ($_POST["password"] !== $_POST["password_confirmation"]) {
die("Passwörter stimmen nicht überein.");
}
$email = trim($_POST["email"]);
$first_name = trim($_POST["first_name"]);
$last_name = trim($_POST["last_name"]);
$phone = preg_replace('/[^0-9]/', '', trim($_POST["phone"]));
$password_hash = password_hash($_POST["password"], PASSWORD_DEFAULT);
// === Prüfung 1: Ist der Nutzer vom Admin vorregistriert? ===
$stmt = $pdo->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;
?>
@@ -1,111 +1,136 @@
<?php /* Name: signup.php
Beschreibung: Registrierungsformular (vereint)
- Wenn Nutzer vorregistriert: direkte Registrierung
- Wenn nicht: Antrag wird automatisch gestellt
- Verwendet signup-process.php */
<?php
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/parameters.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/functions.php';
require_once __DIR__ . '/util/authorization_helpers.php';
if (session_status() == PHP_SESSION_NONE) {
session_start();
}
require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/ini/parameters.php';
require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/ini/functions.php';
authorization_start_session();
authorization_send_security_headers();
if (isset($_SESSION['user_id'])) {
authorization_redirect($g_httpHomePath, 302);
}
$form = authorization_registration_input([]);
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$form = authorization_registration_input($_POST);
if (!authorization_csrf_is_valid($_POST['csrf_token'] ?? null)) {
$error = 'Die Anfrage konnte nicht bestätigt werden. Bitte laden Sie die Seite neu und versuchen Sie es erneut.';
} else {
$error = authorization_registration_error($form);
}
if ($error === null) {
try {
$pdo = require $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/database.php';
$result = authorization_register($pdo, $form);
if ($result['status'] === 'pending') {
try {
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/send_mail.php';
$mailSubject = "Neue Registrierungsanfrage von {$form['first_name']} {$form['last_name']}";
$mailMessage = "Es liegt eine neue Registrierungsanfrage vor:\n\n"
. "Name: {$form['first_name']} {$form['last_name']}\n"
. "E-Mail: {$form['email']}\n"
. "Telefon: {$form['phone']}\n\n"
. 'Bitte melden Sie sich im Adminpanel an, um die Anfrage zu bearbeiten.';
send_admin_team_mail(
$pdo,
$mailSubject,
$mailMessage,
'Im Adminpanel bearbeiten',
authorization_public_url('/intranet_repo/projects/adminpanel/admin_inbox.php')
);
} catch (Throwable $e) {
// Die Anfrage ist bereits atomar gespeichert; Mailfehler dürfen keine Doppelregistrierung provozieren.
error_log('authorization signup notification failed: ' . $e->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();
?>
<!DOCTYPE html>
<html lang="de" dir="ltr">
<head>
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/html_head.php'; ?>
<!-- Quelle für just-validate.js: https://unpkg.com/just-validate@latest/dist/just-validate.production.min.js -->
<script src="just-validate.js" defer></script>
<script src="validation.js" defer></script>
<?php require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/projects/_central/php/html_head.php'; ?>
<link rel="stylesheet" href="/intranet_repo/projects/_central/layouts/authorization.css">
<script src="just-validate.js" defer></script>
<script src="/intranet_repo/projects/_central/scripts/authorization.js" defer></script>
</head>
<body>
<div class="login_panel">
<div class="login_panel">
<?php if ($error !== null): ?>
<div class="nak-toast nak-toast--error nak-toast--inline" data-auth-toast>
<div class="nak-toast__icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
<span class="nak-toast__text"><?= authorization_html($error) ?></span>
</div>
<?php endif; ?>
<?php if (isset($_GET["error"])): ?>
<?php if ($_GET["error"] == 1): ?>
<div class="nak-toast nak-toast--error nak-toast--inline" id="nakToastErr">
<div class="nak-toast__icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
<span class="nak-toast__text">Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.</span>
</div>
<script>requestAnimationFrame(function(){document.getElementById('nakToastErr').classList.add('nak-toast--visible');});</script>
<?php elseif ($_GET["error"] == 2): ?>
<div class="nak-toast nak-toast--error nak-toast--inline" id="nakToastErr">
<div class="nak-toast__icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
<span class="nak-toast__text">Diese E-Mailadresse ist bereits registriert oder es liegt bereits eine Anfrage vor.</span>
</div>
<script>requestAnimationFrame(function(){document.getElementById('nakToastErr').classList.add('nak-toast--visible');});</script>
<?php endif; ?>
<?php endif; ?>
<?php if (($_GET['success'] ?? '') === '1'): ?>
<div class="nak-toast nak-toast--success nak-toast--inline" data-auth-toast>
<div class="nak-toast__icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter"><path d="M20 6L9 17l-5-5"/></svg></div>
<span class="nak-toast__text">Ihre Angaben wurden verarbeitet. Falls Ihr Konto direkt aktiviert werden konnte, können Sie sich anmelden. Andernfalls erhalten Sie nach der Prüfung eine Benachrichtigung.</span>
</div>
<?php endif; ?>
<?php if (isset($_GET["success"]) && $_GET["success"] == 1): ?>
<div class="nak-toast nak-toast--success nak-toast--inline" id="nakToastOk">
<div class="nak-toast__icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter"><path d="M20 6L9 17l-5-5"/></svg></div>
<span class="nak-toast__text">Ihre Registrierung wurde beantragt. Sie erhalten eine Benachrichtigung, sobald Ihr Konto freigeschaltet wurde.</span>
</div>
<script>requestAnimationFrame(function(){document.getElementById('nakToastOk').classList.add('nak-toast--visible');});</script>
<?php endif; ?>
<div class="login_content">
<div class="nak-logo-link auth-logo">
<img class="auth-logo__icon" src="/intranet_repo/projects/_central/img/nak-logo.svg" alt="NAK Logo">
<div class="auth-logo__text">
<div class="auth-logo__name">Neuapostolische Kirche</div>
<div class="auth-logo__gemeinde">Berlin-Reinickendorf</div>
</div>
</div>
<h2>Registrieren</h2>
<p style="text-align:center;color:var(--gray-600);font-size:var(--font-size-sm);margin-bottom:var(--space-sm);">Erstellen Sie Ihren persönlichen Zugang zum Intranet.</p>
<div class="login_content">
<div class="nak-logo-link auth-logo">
<img class="auth-logo__icon" src="/intranet_repo/projects/_central/img/nak-logo.svg" alt="NAK Logo">
<div class="auth-logo__text">
<div class="auth-logo__name">Neuapostolische Kirche</div>
<div class="auth-logo__gemeinde">Berlin-Reinickendorf</div>
</div>
</div>
<h2>Registrieren</h2>
<p style="text-align:center;color:var(--gray-600);font-size:var(--font-size-sm);margin-bottom:var(--space-sm);">Erstellen Sie Ihren persönlichen Zugang zum Intranet.</p>
<form action="signup-process.php" method="post" id="signup" novalidate>
<label for="first_name">Vorname</label>
<div class="item">
<input class="login_field" type="text" id="first_name" name="first_name">
</div>
<label for="last_name">Nachname</label>
<div class="item">
<input class="login_field" type="text" id="last_name" name="last_name">
</div>
<label for="email">E-Mailadresse</label>
<div class="item">
<input class="login_field" type="email" id="email" name="email">
</div>
<form method="post" id="signup" novalidate autocomplete="on">
<input type="hidden" name="csrf_token" value="<?= authorization_html($csrfToken) ?>">
<label for="phone">Telefonnummer</label>
<div class="item">
<div style="display:flex;align-items:stretch;width:100%;max-width:100%;margin-bottom:10px;box-sizing:border-box;">
<div class="input-prefix" style="border-radius:0;border:1px solid var(--gray-300);border-right:none;margin:0;padding:14px 12px;font-weight:500;box-shadow:none;width:auto;background:var(--gray-200);color:var(--gray-700);display:flex;align-items:center;flex-shrink:0;">+49</div>
<input class="login_field" type="tel" id="phone" name="phone" style="border-radius:0;border-left:0;margin:0;flex:1;width:auto;min-width:0;">
</div>
</div>
<label for="password">Passwort</label>
<div class="item">
<input class="login_field" type="password" id="password" name="password">
</div>
<label for="password_confirmation">Passwort wiederholen</label>
<div class="item">
<input class="login_field" type="password" id="password_confirmation" name="password_confirmation">
</div>
<input class="login_button" type="submit" value="Registrieren">
</form>
<div class="small_text" align="center">
Sie haben bereits einen Account?
<br>
<a href="login.php">Anmelden</a><br>
<br>
Probleme bei der Registrierung?<br>
<a href="#" onclick="openContactModal(); return false;">Admin kontaktieren</a>
</div>
</div><!-- EOD: login_content -->
</div><!-- EOD: login_panel -->
<label for="first_name">Vorname</label>
<div class="item"><input class="login_field" type="text" id="first_name" name="first_name" value="<?= authorization_html($form['first_name']) ?>" maxlength="100" required autocomplete="given-name"></div>
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/footer.php'; ?>
<label for="last_name">Nachname</label>
<div class="item"><input class="login_field" type="text" id="last_name" name="last_name" value="<?= authorization_html($form['last_name']) ?>" maxlength="100" required autocomplete="family-name"></div>
<label for="email">E-Mailadresse</label>
<div class="item"><input class="login_field" type="email" id="email" name="email" value="<?= authorization_html($form['email']) ?>" maxlength="254" required autocomplete="email"></div>
<label for="phone">Telefonnummer</label>
<div class="item">
<div style="display:flex;align-items:stretch;width:100%;max-width:100%;margin-bottom:10px;box-sizing:border-box;">
<div class="input-prefix" style="border-radius:0;border:1px solid var(--gray-300);border-right:none;margin:0;padding:14px 12px;font-weight:500;box-shadow:none;width:auto;background:var(--gray-200);color:var(--gray-700);display:flex;align-items:center;flex-shrink:0;">+49</div>
<input class="login_field" type="tel" id="phone" name="phone" value="<?= authorization_html($form['phone']) ?>" maxlength="20" style="border-radius:0;border-left:0;margin:0;flex:1;width:auto;min-width:0;" required autocomplete="tel-national">
</div>
</div>
<label for="password">Passwort</label>
<div class="item"><input class="login_field" type="password" id="password" name="password" required autocomplete="new-password"></div>
<label for="password_confirmation">Passwort wiederholen</label>
<div class="item"><input class="login_field" type="password" id="password_confirmation" name="password_confirmation" required autocomplete="new-password"></div>
<input class="login_button" type="submit" value="Registrieren">
</form>
<div class="small_text" align="center">
Sie haben bereits einen Account?<br>
<a href="login.php">Anmelden</a><br><br>
Probleme bei der Registrierung?<br>
<a href="#" onclick="openContactModal(); return false;">Admin kontaktieren</a>
</div>
</div>
</div>
<?php require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/projects/_central/php/footer.php'; ?>
</body>
</html>
</html>
@@ -0,0 +1,313 @@
<?php
const AUTH_RESET_TTL_SECONDS = 600;
const AUTH_RESET_MAX_ATTEMPTS = 5;
const AUTH_DUMMY_PASSWORD_HASH = '$2y$12$7v2tTTu3R6UkMcX7N9FvJeetK1LEkkkFe/qbj./2EdWVKKT0LS7RS';
function authorization_start_session(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}
function authorization_send_security_headers(): void
{
header('Cache-Control: no-store, max-age=0');
header('Pragma: no-cache');
header('Referrer-Policy: no-referrer');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
}
function authorization_html($value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function authorization_csrf_token(): string
{
authorization_start_session();
if (empty($_SESSION['authorization_csrf'])) {
$_SESSION['authorization_csrf'] = bin2hex(random_bytes(32));
}
return (string) $_SESSION['authorization_csrf'];
}
function authorization_csrf_is_valid($postedToken): bool
{
authorization_start_session();
$sessionToken = $_SESSION['authorization_csrf'] ?? '';
return is_string($postedToken)
&& is_string($sessionToken)
&& $sessionToken !== ''
&& hash_equals($sessionToken, $postedToken);
}
function authorization_redirect(string $location, int $status = 303): never
{
header('Location: ' . $location, true, $status);
exit;
}
function authorization_password_error(string $password, ?string $confirmation = null): ?string
{
if (strlen($password) < 8 || !preg_match('/[a-z]/i', $password) || !preg_match('/[0-9]/', $password)) {
return 'Passwort muss mindestens 8 Zeichen, einen Buchstaben und eine Zahl enthalten.';
}
if ($confirmation !== null && !hash_equals($password, $confirmation)) {
return 'Passwörter stimmen nicht überein.';
}
return null;
}
function authorization_normalize_phone(string $phone): string
{
return preg_replace('/\D+/', '', trim($phone)) ?? '';
}
function authorization_registration_input(array $input): array
{
return [
'first_name' => 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;
}
@@ -1,40 +0,0 @@
<?php /* Name: validate-email.php
Beschreibung: Validierung der E-Mailadresse
- Wird innerhalb von validation.js aufgerufen
- Prüft ob E-Mail bereits vollständig registriert oder
ein offener Antrag vorliegt */
$pdo = require_once($_SERVER['DOCUMENT_ROOT']."/intranet_repo/general_files/ini/database.php");
header("Content-Type: application/json");
if (!isset($_GET["email"]) || empty(trim($_GET["email"]))) {
echo json_encode(["available" => 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;
?>
@@ -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();
});
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

@@ -1,16 +1,7 @@
<?php
/* Name: brandschutz_cron.php
Beschreibung: Monatlicher Cron-Job (am 1. des Monats) für Brandschutz-Schulungserinnerungen
Aufgaben:
1. Einen Monat vor Ablauf: Erste Erinnerungsmail an den Nutzer
2. Nach Ablauf der Frist: Monatliche Erinnerungsmail an den Nutzer
3. 4+ Monate nach Ablauf: Eskalation an Admins (aktive Systemnachricht + Mail)
Aufruf: php brandschutz_cron.php (via Cron: 0 8 1 * *)
*/
/* Name: brandschutz_cron.php
Beschreibung: Monatlicher Job für Brandschutz-Schulungserinnerungen */
// Initialisierung
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('Forbidden');
@@ -21,128 +12,131 @@ $_SERVER['HTTP_HOST'] = getenv('INTRANET_FQDN') ?: 'localhost';
$pdo = require $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/database.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/send_mail.php';
require_once __DIR__ . '/util/brandschutz_helpers.php';
$today = date('Y-m-d');
$today = brandschutz_today();
$log = [];
$brandschutzUrl = brandschutz_training_url();
// Alle Nutzer mit Brandschutz-Rolle 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
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
WHERE up.\"Brandschutz\" = 1";
$result = $pdo->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";
@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
const BRANDSCHUTZ_VALIDITY_DAYS = 365;
function brandschutz_csrf_token(): string
{
if (empty($_SESSION['brandschutz_csrf'])) {
$_SESSION['brandschutz_csrf'] = bin2hex(random_bytes(32));
}
return (string) $_SESSION['brandschutz_csrf'];
}
function brandschutz_verify_csrf($postedToken): void
{
$sessionToken = $_SESSION['brandschutz_csrf'] ?? '';
if (!is_string($postedToken) || $sessionToken === '' || !hash_equals((string) $sessionToken, $postedToken)) {
throw new InvalidArgumentException('Ungültige Anfrage. Bitte laden Sie die Seite neu.');
}
}
function brandschutz_is_valid_date(string $value): bool
{
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
$errors = DateTimeImmutable::getLastErrors();
return $date !== false
&& ($errors === false || ($errors['warning_count'] === 0 && $errors['error_count'] === 0))
&& $date->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]);
}
File diff suppressed because it is too large Load Diff
@@ -1,49 +0,0 @@
<?php
const NEWS_PNG_DIRECTORY = __DIR__ . '/news/png';
function isValidNewsFileBase(string $fileBase): bool
{
return preg_match('/^\d{4}-\d{2}-\d{2}-[A-Za-z0-9_-]+$/D', $fileBase) === 1;
}
function deleteNewsFiles(string $fileBase): bool
{
if (!isValidNewsFileBase($fileBase)) {
return false;
}
$success = true;
foreach (glob(NEWS_PNG_DIRECTORY . '/' . $fileBase . '_page_*.png') ?: [] as $file) {
if (!unlink($file)) {
$success = false;
}
}
return $success;
}
function cleanupExpiredNews(): void
{
$today = new DateTimeImmutable('today');
foreach (glob(NEWS_PNG_DIRECTORY . '/*_page_1.png') ?: [] as $file) {
$fileBase = basename($file, '_page_1.png');
if (!isValidNewsFileBase($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
) {
deleteNewsFiles($fileBase);
}
}
}
@@ -0,0 +1,574 @@
<?php
const CONTENTPANEL_NEWS_PNG_DIRECTORY = __DIR__ . '/../news/png';
function contentpanel_html($value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function contentpanel_csrf_token(): string
{
if (empty($_SESSION['contentpanel_csrf_token'])) {
$_SESSION['contentpanel_csrf_token'] = bin2hex(random_bytes(32));
}
return (string) $_SESSION['contentpanel_csrf_token'];
}
function contentpanel_verify_csrf($postedToken): void
{
$sessionToken = $_SESSION['contentpanel_csrf_token'] ?? '';
if (
!is_string($postedToken)
|| !is_string($sessionToken)
|| $sessionToken === ''
|| !hash_equals($sessionToken, $postedToken)
) {
throw new InvalidArgumentException('Ungültige Anfrage. Bitte laden Sie die Seite neu.');
}
}
function contentpanel_action_tab(string $action): string
{
return match ($action) {
'create_announcement', 'delete_announcement' => '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<prefix>.*?)(?P<d1>\d{1,2})(?:\.\s*\/\s*(?P<d2>\d{1,2}))?\.(?P<m>\d{1,2})\.(?P<y>\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<ref>(?:[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;
}
@@ -1,4 +0,0 @@
<?php
require_once __DIR__ . '/../../general_files/ini/runtime_env.php';
define('HA_BASE_URL', rtrim(intranet_env('HA_BASE_URL'), '/'));
define('HA_TOKEN', intranet_env('HA_TOKEN'));
@@ -0,0 +1,236 @@
(() => {
'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: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><defs><filter id="weather-blur" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur in="SourceAlpha" stdDeviation="2.5"/><feOffset dx="0" dy="2" result="off"/><feComponentTransfer><feFuncA type="linear" slope="0.15"/></feComponentTransfer><feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><g filter="url(#weather-blur)"><path d="M22 46h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 20 29a9 9 0 0 0 2 17z" fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><path d="M18 46h22a9 9 0 0 0 0-18c-.7 0-1.4.1-2 .2A11 11 0 0 0 17 31a8 8 0 0 0 1 15z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round" opacity="0.95"/></g></svg>`,
sun: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"><circle cx="32" cy="32" r="10" fill="#FFD34D" stroke="#fff"/><path d="M32 6v8M32 50v8M6 32h8M50 32h8M13.5 13.5l5.7 5.7M44.8 44.8l5.7 5.7M50.5 13.5l-5.7 5.7M19.2 44.8l-5.7 5.7"/></g></svg>`,
moon: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><path d="M41 8a22 22 0 1 0 15 35A18 18 0 0 1 41 8z" fill="#DDE7FF" stroke="#fff" stroke-width="2" stroke-linejoin="round"/></svg>`,
cloudy_day: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><g transform="translate(4,0)"><circle cx="22" cy="22" r="9" fill="#FFD34D" stroke="#fff" stroke-width="2"/><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"><path d="M22 6v6M22 32v6M6 22h6M32 22h6M10.5 10.5l4.2 4.2M29.3 29.3l4.2 4.2M33.5 10.5l-4.2 4.2M14.7 29.3l-4.2 4.2"/></g></g><g transform="translate(8,10)"><path d="M18 36h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 16 19a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><path d="M22 36h22a9 9 0 0 0 0-18c-.7 0-1.4.1-2 .2A11 11 0 0 0 21 21a8 8 0 0 0 1 15z" fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round" opacity="0.95"/></g></g></svg>`,
cloudy_night: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M38 10a16 16 0 1 0 11 26A13 13 0 0 1 38 10z" fill="#DDE7FF" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><g transform="translate(10,18)"><path d="M14 28h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 12 11a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><path d="M18 28h22a9 9 0 0 0 0-18c-.7 0-1.4.1-2 .2A11 11 0 0 0 17 13a8 8 0 0 0 1 15z" fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round" opacity="0.95"/></g></g></svg>`,
rain_shower: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M20 36h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 18 19a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><g stroke="#fff" stroke-width="2" stroke-linecap="round"><path d="M24 42l-3 6"/><path d="M34 42l-3 6"/><path d="M44 42l-3 6"/></g></g></svg>`,
rain: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M20 34h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 18 17a9 9 0 0 0 2 17z" fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><g stroke="#fff" stroke-width="2" stroke-linecap="round"><path d="M22 40l-3 8"/><path d="M32 40l-3 8"/><path d="M42 40l-3 8"/><path d="M52 40l-3 8"/></g></g></svg>`,
thunder: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M18 34h28a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 16 17a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><path d="M32 36l-6 12h7l-3 10 12-16h-7l3-6z" fill="#FFD34D" stroke="#fff" stroke-width="2" stroke-linejoin="round"/></g></svg>`,
snowy: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M20 34h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 18 17a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><g fill="#fff"><circle cx="24" cy="44" r="2"/><circle cx="34" cy="44" r="2"/><circle cx="44" cy="44" r="2"/><circle cx="29" cy="52" r="2"/><circle cx="39" cy="52" r="2"/></g></g></svg>`,
mist: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" opacity="0.95"><path d="M14 24h36"/><path d="M10 32h44"/><path d="M14 40h36"/><path d="M18 48h28"/></g></svg>`,
};
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));
})();
@@ -1,58 +1,20 @@
<?php
declare(strict_types=1);
date_default_timezone_set('Europe/Berlin');
require_once __DIR__ . '/../contentpanel/insertion/news_cleanup.php';
require_once __DIR__ . '/util/dashboard_helpers.php';
// DB-Verbindung zentral laden (liefert PDO-Objekt zurück)
$pdo = require __DIR__ . '/../../general_files/ini/database.php';
$openWeatherApiKey = intranet_env('OPENWEATHER_API_KEY');
$heute = date('Y-m-d');
// --- Gottesdienst (Bibelwort & Eingangslied) ---
$sql = "SELECT bibelwort, bibelvers, eingang_nummer, eingang_titel
FROM gottesdienst
WHERE bibeldatum = ?";
$stmt = $pdo->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');
?>
<!DOCTYPE html>
<html lang="de">
@@ -62,10 +24,9 @@ $pngFiles = ($pngDirectoryFs && is_dir($pngDirectoryFs))
<title>NAK Reinickendorf</title>
<link rel="icon" type="image/svg+xml" href="/intranet_repo/projects/_central/img/nak-logo.svg">
<link rel="shortcut icon" href="/intranet_repo/projects/_central/img/favicon.ico">
<link rel="stylesheet" href="stylo.css?v=<?php echo time(); ?>">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="stylo.css?v=<?= $stylesheetVersion ?>">
<link href="https://fonts.googleapis.com/css2?family=SUSE:wght@400;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Anton&display=swap" rel="stylesheet">
<script src="dashboard.js?v=<?= $scriptVersion ?>" defer></script>
</head>
<body>
<div class="content-board">
@@ -73,63 +34,29 @@ $pngFiles = ($pngDirectoryFs && is_dir($pngDirectoryFs))
<div class="content-column-right-upper-box">
<div class="content-column-right-upper-box-right">
<div class="content-item-time">
<div class="time">
<div id="uhrzeit"></div>
</div>
<div class="date">
<center><?php
$months = ["UNDEFINED", "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"];
echo date("d. ") . $months[intval(date("m"))];
?></center>
</div>
<div class="time"><div id="uhrzeit"></div></div>
<div class="date" style="text-align: center" data-dashboard-date><?= dashboard_html($content['date_label']) ?></div>
</div>
<div class="content-item-daily">
<div class="header-sticky-note">
<div>Heutige Ansagen</div>
</div>
<div class="header-sticky-note"><div>Heutige Ansagen</div></div>
<div class="news-container">
<div class="news-scroll">
<?php foreach ($ansagenResult as $ansage) : ?>
<?php foreach ($announcements as $announcement): ?>
<div class="news-box">
<div class="news-date">
<?php
$wochentage = [
'Mon' => '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 . '&nbsp;' . $datum_von;
}
if (!empty($ansage['ansage_datum_bis'])) {
$datum_bis = date("d.m.", strtotime($ansage['ansage_datum_bis']));
echo "<br> bis " . $datum_bis;
}
?>
<?= dashboard_html($announcement['date_label']) ?>
<?php if ($announcement['end_label'] !== ''): ?>
<br><?= dashboard_html($announcement['end_label']) ?>
<?php endif; ?>
</div>
<div>
<?php echo htmlspecialchars($ansage['ansage_titel']); ?>
<?php if (!empty($ansage['ansage_untertitel'])) : ?>
<div class="news-subtitle"><?php echo htmlspecialchars($ansage['ansage_untertitel']); ?></div>
<?= dashboard_html($announcement['title']) ?>
<?php if ($announcement['subtitle'] !== ''): ?>
<div class="news-subtitle"><?= dashboard_html($announcement['subtitle']) ?></div>
<?php endif; ?>
<?php if (!empty($ansage['ansage_uhrzeit']) && !empty($ansage['ansage_ort'])) : ?>
<div class="news-subtitle"><i><?php echo date("H:i", strtotime($ansage['ansage_uhrzeit'])) . ' Uhr'; ?>, <?php echo htmlspecialchars($ansage['ansage_ort']); ?></i></div>
<?php elseif (!empty($ansage['ansage_uhrzeit'])) : ?>
<div class="news-subtitle"><i><?php echo date("H:i", strtotime($ansage['ansage_uhrzeit'])) . ' Uhr'; ?></i></div>
<?php elseif (!empty($ansage['ansage_ort'])) : ?>
<div class="news-subtitle"><i><?php echo htmlspecialchars($ansage['ansage_ort']); ?></i></div>
<?php if ($announcement['meta'] !== ''): ?>
<div class="news-subtitle"><i><?= dashboard_html($announcement['meta']) ?></i></div>
<?php endif; ?>
</div>
</div>
@@ -141,32 +68,24 @@ $pngFiles = ($pngDirectoryFs && is_dir($pngDirectoryFs))
<div class="content-column-right-upper-box-left">
<div class="content-item-middle-top">
<div class="header-sticky-note">
<div>Bibelwort</div>
</div>
<div class="bible"><?php echo $bibelwort; ?></div>
<div class="verse"><?php echo $bibelvers; ?></div>
<div class="header-sticky-note"><div>Bibelwort</div></div>
<div class="bible"><?= dashboard_html($service['bibelwort']) ?></div>
<div class="verse"><?= dashboard_html($service['bibelvers']) ?></div>
</div>
<div class="content-column-double-item">
<div class="content-item">
<div class="header-sticky-note">
<div>Eingangslied</div>
</div>
<div class="GB"><?php echo $eingangNummer; ?></div>
<div class="GB-title"><?php echo $eingangTitel; ?></div>
<div class="header-sticky-note"><div>Eingangslied</div></div>
<div class="GB"><?= dashboard_html($service['eingang_nummer']) ?></div>
<div class="GB-title"><?= dashboard_html($service['eingang_titel']) ?></div>
</div>
<div class="content-item">
<div class="header-sticky-note">
<div>Temperatur</div>
</div>
<div class="header-sticky-note"><div>Temperatur</div></div>
<div class="temperature">
<div class="container"><div id="wetter"></div></div>
<div class="container">
<div id="wetter"></div>
</div>
<div class="container">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -5 60 60" width="64px" height="64px" baseProfile="basic">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -5 60 60" width="64px" height="64px" baseProfile="basic" aria-hidden="true">
<polygon fill="#66e1ff" points="46,22.11 46,24.42 43,26.15 43,34.24 25,44.63 14,38.28 14,35.97 12,34.81 10,35.97 7,34.24 7,20.38 4,20.96 4,18.65 22,8.26 34,5.95"/>
<polygon fill="#4292a6" points="25,30.774 28,34.815 43,26.155 43,34.238 25,44.63"/>
<polygon fill="#ff5757" points="16,18.649 4,20.959 4,18.649 16,16.34 28,32.506 28,34.815"/>
@@ -180,333 +99,42 @@ $pngFiles = ($pngDirectoryFs && is_dir($pngDirectoryFs))
<polygon fill="#52b1cc" points="17,34.238 22,37.124 22,34.815 19,33.083"/>
<polygon fill="#ff5757" points="12,34.815 14,35.97 14,29.041 12,27.887"/>
</svg>
<div class="text-indoor" id='temperature_txt'></div>
<div class="text-indoor" id="temperature_txt"></div>
</div>
</div>
</div>
</div>
</div> <!-- /left upper box -->
</div> <!-- /right upper -->
<div class="content-item-bottom-box">
<div class="header-sticky-note">
<div>Aushänge</div>
</div>
<div class="aushang-flex">
<?php
$hasPng = false;
foreach ($pngFiles as $file) {
if (pathinfo($file, PATHINFO_EXTENSION) === 'png') {
$hasPng = true;
echo '<img src="' . $pngDirectoryUrl . $file . '" alt="Aushang" height="95%">';
}
}
if (!$hasPng) {
echo '<p style="text-align: left;">Keine Aushänge vorgeplant.</p>';
}
?>
</div>
</div>
</div> <!-- /right column -->
<div class="content-item-bottom-box">
<div class="header-sticky-note"><div>Aushänge</div></div>
<div class="aushang-flex">
<?php if ($posters === []): ?>
<p style="text-align: left">Keine Aushänge vorgeplant.</p>
<?php else: ?>
<?php foreach ($posters as $poster): ?>
<img src="<?= dashboard_html($poster['url']) ?>" alt="<?= dashboard_html($poster['alt']) ?>" height="95%">
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
<div class="content-column-left">
<div class="content-item-video">
<!-- leerer Container, kein Video/HLS -->
<div class="video-container" aria-label="Leer"></div>
</div>
<div class="content-item-month">
<div class="header-sticky-note">
<div>Monatsplan</div>
</div>
<div class="header-sticky-note"><div>Monatsplan</div></div>
<div class="content-item-month-cell">
<img src="month/terminuebersicht-1.png?v=<?php echo time(); ?>" alt="image">
<img src="month/terminuebersicht-2.png?v=<?php echo time(); ?>" alt="image">
<?php foreach ($monthImages as $image): ?>
<img src="<?= dashboard_html($image['url']) ?>" data-month-page="<?= (int) $image['page'] ?>" alt="Monatsplan Seite <?= (int) $image['page'] ?>">
<?php endforeach; ?>
</div>
</div>
</div> <!-- /left column -->
</div> <!-- /content-board -->
<script>
function updateClock() {
var now = new Date();
var uhrzeitElement = document.getElementById("uhrzeit");
var stunden = now.getHours();
var minuten = now.getMinutes();
uhrzeitElement.innerHTML = + padZero(stunden) + ':' + padZero(minuten);
}
function padZero(number) { return number < 10 ? '0' + number : number; }
updateClock();
setInterval(updateClock, 1000);
// OpenWeather (Außentemperatur+Icon)
const apiUrl = `https://api.openweathermap.org/data/2.5/weather?lat=52.571291&lon=13.310422&appid=<?= rawurlencode($openWeatherApiKey) ?>&units=metric&language=german`;
// Wetter-Icons
const cloudy = `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true">
<defs>
<filter id="blur" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur in="SourceAlpha" stdDeviation="2.5"/>
<feOffset dx="0" dy="2" result="off"/>
<feComponentTransfer><feFuncA type="linear" slope="0.15"/></feComponentTransfer>
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<g filter="url(#blur)">
<path d="M22 46h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 20 29a9 9 0 0 0 2 17z" fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round"/>
<path d="M18 46h22a9 9 0 0 0 0-18c-.7 0-1.4.1-2 .2A11 11 0 0 0 17 31a8 8 0 0 0 1 15z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round" opacity="0.95"/>
</g>
</svg>`;
const sun = `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true">
<g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round">
<circle cx="32" cy="32" r="10" fill="#FFD34D" stroke="#fff"/>
<path d="M32 6v8M32 50v8M6 32h8M50 32h8M13.5 13.5l5.7 5.7M44.8 44.8l5.7 5.7M50.5 13.5l-5.7 5.7M19.2 44.8l-5.7 5.7"/>
</g>
</svg>`;
const moon = `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true">
<path d="M41 8a22 22 0 1 0 15 35A18 18 0 0 1 41 8z" fill="#DDE7FF" stroke="#fff" stroke-width="2" stroke-linejoin="round"/>
</svg>`;
const cloudy_day = `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true">
<g>
<g transform="translate(4,0)">
<circle cx="22" cy="22" r="9" fill="#FFD34D" stroke="#fff" stroke-width="2"/>
<g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round">
<path d="M22 6v6M22 32v6M6 22h6M32 22h6M10.5 10.5l4.2 4.2M29.3 29.3l4.2 4.2M33.5 10.5l-4.2 4.2M14.7 29.3l-4.2 4.2"/>
</g>
</g>
<g transform="translate(8,10)">
<path d="M18 36h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 16 19a9 9 0 0 0 2 17z"
fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/>
<path d="M22 36h22a9 9 0 0 0 0-18c-.7 0-1.4.1-2 .2A11 11 0 0 0 21 21a8 8 0 0 0 1 15z"
fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round" opacity="0.95"/>
</g>
</g>
</svg>`;
const cloudy_night = `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true">
<g>
<path d="M38 10a16 16 0 1 0 11 26A13 13 0 0 1 38 10z" fill="#DDE7FF" stroke="#fff" stroke-width="2" stroke-linejoin="round"/>
<g transform="translate(10,18)">
<path d="M14 28h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 12 11a9 9 0 0 0 2 17z"
fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/>
<path d="M18 28h22a9 9 0 0 0 0-18c-.7 0-1.4.1-2 .2A11 11 0 0 0 17 13a8 8 0 0 0 1 15z"
fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round" opacity="0.95"/>
</g>
</g>
</svg>`;
const rain_shower = `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true">
<g>
<path d="M20 36h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 18 19a9 9 0 0 0 2 17z"
fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/>
<g stroke="#fff" stroke-width="2" stroke-linecap="round">
<path d="M24 42l-3 6" />
<path d="M34 42l-3 6" />
<path d="M44 42l-3 6" />
</g>
</g>
</svg>`;
const rain = `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true">
<g>
<path d="M20 34h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 18 17a9 9 0 0 0 2 17z"
fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round"/>
<g stroke="#fff" stroke-width="2" stroke-linecap="round">
<path d="M22 40l-3 8" />
<path d="M32 40l-3 8" />
<path d="M42 40l-3 8" />
<path d="M52 40l-3 8" />
</g>
</g>
</svg>`;
const thunder = `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true">
<g>
<path d="M18 34h28a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 16 17a9 9 0 0 0 2 17z"
fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/>
<path d="M32 36l-6 12h7l-3 10 12-16h-7l3-6z"
fill="#FFD34D" stroke="#fff" stroke-width="2" stroke-linejoin="round"/>
</g>
</svg>`;
const snowy = `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true">
<g>
<path d="M20 34h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 18 17a9 9 0 0 0 2 17z"
fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/>
<g fill="#fff">
<circle cx="24" cy="44" r="2"/>
<circle cx="34" cy="44" r="2"/>
<circle cx="44" cy="44" r="2"/>
<circle cx="29" cy="52" r="2"/>
<circle cx="39" cy="52" r="2"/>
</g>
</g>
</svg>`;
const mist = `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true">
<g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" opacity="0.95">
<path d="M14 24h36"/>
<path d="M10 32h44"/>
<path d="M14 40h36"/>
<path d="M18 48h28"/>
</g>
</svg>`;
async function getWeather() {
try {
var response = await fetch(apiUrl);
var data = await response.json();
if (data.cod === 200) {
var weatherIcon = data.weather[0].icon;
var temperature = data.main.temp;
var weatherElement = document.getElementById('wetter');
if (["04d","04n","03d","03n"].includes(weatherIcon)) {
weatherElement.innerHTML = '<div>' + cloudy + `</div><div class="text-outdoor"><center>${Math.round(temperature)}°C</center></div>`;
} else if (weatherIcon === "02d") {
weatherElement.innerHTML = '<div>' + cloudy_day + `</div><div class="text-outdoor"><center>${Math.round(temperature)}°C</center></div>`;
} else if (weatherIcon === "02n") {
weatherElement.innerHTML = '<div>' + cloudy_night + `</div><div class="text-outdoor"><center>${Math.round(temperature)}°C</center></div>`;
} else if (weatherIcon === "01d") {
weatherElement.innerHTML = '<div>' + sun + `</div><div class="text-outdoor"><center>${Math.round(temperature)}°C</center></div>`;
} else if (weatherIcon === "01n") {
weatherElement.innerHTML = '<div>' + moon + `</div><div class="text-outdoor"><center>${Math.round(temperature)}°C</center></div>`;
} else if (["09d","09n","10n"].includes(weatherIcon)) {
weatherElement.innerHTML = '<div>' + rain_shower + `</div><div class="text-outdoor"><center>${Math.round(temperature)}°C</center></div>`;
} else if (weatherIcon === "10d") {
weatherElement.innerHTML = '<div>' + rain + `</div><div class="text-outdoor"><center>${Math.round(temperature)}°C</center></div>`;
} else if (["11d","11n"].includes(weatherIcon)) {
weatherElement.innerHTML = '<div>' + thunder + `</div><div class="text-outdoor"><center>${Math.round(temperature)}°C</center></div>`;
} else if (["13d","13n"].includes(weatherIcon)) {
weatherElement.innerHTML = '<div>' + snowy + `</div><div class="text-outdoor"><center>${Math.round(temperature)}°C</center></div>`;
} else if (["50d","50n"].includes(weatherIcon)) {
weatherElement.innerHTML = '<div>' + mist + `</div><div class="text-outdoor"><center>${Math.round(temperature)}°C</center></div>`;
}
} else {
console.error('Fehler beim Abrufen des Wetters:', data.message);
}
} catch (error) {
console.error('Fehler beim Abrufen des Wetters:', error.message);
}
}
getWeather();
setInterval(getWeather, 30000);
// INNENTEMPERATUR: jetzt über Home Assistant (Server-Proxy)
function updateTemperature() {
const entityId = 'sensor.temp_temperatur';
fetch('ha_temperature.php?entity_id=' + encodeURIComponent(entityId))
.then(r => r.json())
.then(data => {
if (data.error) {
console.error('HA Fehler:', data);
return;
}
document.getElementById('temperature_txt').innerText = `${data.value}${data.unit || '°C'}`;
})
.catch(err => console.error('Fetch Fehler:', err));
}
updateTemperature();
setInterval(updateTemperature, 10000);
function updateContent() {
fetch('update_content.php')
.then(response => response.json())
.then(data => {
if (data.error) {
console.error('Fehler:', data.error);
return;
}
document.querySelector('.bible').innerText = data.bibelwort;
document.querySelector('.verse').innerText = data.bibelvers;
document.querySelector('.GB').innerText = data.eingang_nummer;
document.querySelector('.GB-title').innerText = data.eingang_titel;
setupScrolling();
})
.catch(error => {
console.error('Fehler beim Abrufen der Daten:', error);
});
}
updateContent();
setInterval(updateContent, 30000);
function setupScrolling() {
const SPEED_V = 7; // News vertikal
const SPEED_H = 10; // Aushänge horizontal
const containerFlex = document.querySelector('.aushang-flex');
if (containerFlex) {
const images = containerFlex.querySelectorAll('img');
const measureAndApplyH = () => {
let totalWidth = 0;
images.forEach(img => {
const styles = window.getComputedStyle(img);
const mr = parseFloat(styles.marginRight) || 0;
totalWidth += (img.offsetWidth || 0) + mr;
});
const containerWidth = containerFlex.offsetWidth || 0;
const scrollDistanceH = Math.max(0, totalWidth - containerWidth);
containerFlex.style.setProperty('--scroll-distance-h', `-${scrollDistanceH}px`);
const durationH = Math.max(1, scrollDistanceH / SPEED_H);
if (scrollDistanceH === 0) {
containerFlex.style.animation = 'none';
} else {
containerFlex.style.animationName = 'scroll-h';
containerFlex.style.animationDuration = `${durationH}s`;
containerFlex.style.animationTimingFunction = 'linear';
containerFlex.style.animationIterationCount = 'infinite';
}
};
let pending = 0;
images.forEach(img => {
if (!img.complete || img.naturalWidth === 0) {
pending++;
img.addEventListener('load', () => {
pending--;
if (pending === 0) measureAndApplyH();
}, { once: true });
}
});
if (pending === 0) measureAndApplyH();
}
const containerNews = document.querySelector('.news-container');
const scrollBox = document.querySelector('.news-scroll');
if (containerNews && scrollBox) {
const totalHeight = scrollBox.scrollHeight || 0;
const visibleHeight = containerNews.offsetHeight || 0;
const scrollDistanceV = Math.max(0, totalHeight - visibleHeight);
scrollBox.style.setProperty('--scroll-distance-v', `-${scrollDistanceV}px`);
const durationV = Math.max(1, scrollDistanceV / SPEED_V);
if (scrollDistanceV === 0) {
scrollBox.style.animation = 'none';
} else {
scrollBox.style.animationName = 'scroll-v';
scrollBox.style.animationDuration = `${durationV}s`;
scrollBox.style.animationTimingFunction = 'linear';
scrollBox.style.animationIterationCount = 'infinite';
}
}
}
window.addEventListener('load', () => {
const news = document.querySelector('.news-scroll');
const aushang = document.querySelector('.aushang-flex');
if (news) news.style.animation = 'none';
if (aushang) aushang.style.animation = 'none';
setTimeout(setupScrolling, 50);
});
window.addEventListener('resize', () => {
const news = document.querySelector('.news-scroll');
const aushang = document.querySelector('.aushang-flex');
if (news) news.style.animation = 'none';
if (aushang) aushang.style.animation = 'none';
setTimeout(setupScrolling, 100);
});
</script>
</div>
</div>
</body>
</html>
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate');
date_default_timezone_set('Europe/Berlin');
require_once __DIR__ . '/util/dashboard_helpers.php';
$resource = (string) ($_GET['resource'] ?? 'content');
$pdo = null;
try {
switch ($resource) {
case 'content':
$pdo = require __DIR__ . '/../../general_files/ini/database.php';
$payload = dashboard_fetch_content($pdo, dashboard_now());
break;
case 'indoor':
$payload = ['indoor' => 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;
}
@@ -1,60 +0,0 @@
<?php
header('Content-Type: application/json; charset=utf-8');
// Token/URL aus externer Config laden
require_once __DIR__ . '/config.local.php';
// Entity-ID (Standard: dein Sensor aus der Liste)
$entityId = isset($_GET['entity_id']) ? $_GET['entity_id'] : 'sensor.temp_temperatur';
$url = rtrim(HA_BASE_URL, '/') . '/api/states/' . urlencode($entityId);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => 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,
]);
@@ -1,54 +0,0 @@
<?php
header('Content-Type: application/json; charset=utf-8');
date_default_timezone_set('Europe/Berlin');
// DB-Verbindung zentral laden
$pdo = require __DIR__ . '/../../general_files/ini/database.php';
$heute = date('Y-m-d');
try {
// --- Bibelwort, Bibelvers und Eingangslied ---
$sql = "SELECT bibelwort, bibelvers, eingang_nummer, eingang_titel
FROM gottesdienst
WHERE bibeldatum = ?";
$stmt = $pdo->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;
}
@@ -0,0 +1,257 @@
<?php
require_once __DIR__ . '/../../contentpanel/insertion/util/contentpanel_helpers.php';
require_once __DIR__ . '/../../../general_files/ini/runtime_env.php';
const DASHBOARD_TIMEZONE = 'Europe/Berlin';
const DASHBOARD_HA_ENTITY_ID = 'sensor.temp_temperatur';
const DASHBOARD_OPENWEATHER_LATITUDE = '52.571291';
const DASHBOARD_OPENWEATHER_LONGITUDE = '13.310422';
const DASHBOARD_NEWS_URL = '/intranet_repo/projects/contentpanel/insertion/news/png/';
function dashboard_now(): DateTimeImmutable
{
return new DateTimeImmutable('now', new DateTimeZone(DASHBOARD_TIMEZONE));
}
function dashboard_html($value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function dashboard_fetch_content(PDO $pdo, DateTimeImmutable $now): array
{
$today = $now->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;
}
@@ -34,10 +34,15 @@
<div class="text">Postgres-Datenbank</div>
</button>
<button type="button" class="button_mainpage unselectable bg_green" onclick="window.location.href='/intranet_repo/projects/intranet/index.php';">
<button type="button" class="button_mainpage unselectable bg_green" onclick="window.open('https://git.pelpino.de/NAK-Reinickendorf/intranet/projects/2', '_blank', 'noopener,noreferrer');">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="32" height="32" aria-hidden="true" focusable="false"><path d="M3 3h5v18H3V3Zm6.5 0h5v11h-5V3ZM16 3h5v7h-5V3Zm0 8.5h5V21h-5v-9.5ZM9.5 15.5h5V21h-5v-5.5Z"/></svg>
<div class="text">Kanban-Board</div>
</button>
<button type="button" class="button_mainpage unselectable bg_green" onclick="window.open('https://git.pelpino.de/NAK-Reinickendorf/intranet/wiki', '_blank', 'noopener,noreferrer');">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="32" height="32" aria-hidden="true" focusable="false"><path d="M4 3a3 3 0 0 0-3 3v13.5A1.5 1.5 0 0 0 2.5 21H11V5.5A2.5 2.5 0 0 0 8.5 3H4Zm16 0h-4.5A2.5 2.5 0 0 0 13 5.5V21h8.5a1.5 1.5 0 0 0 1.5-1.5V6a3 3 0 0 0-3-3Z"/></svg>
<div class="text">Wiki</div>
</button>
</div>
</div>
+4 -10
View File
@@ -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';
?>
<!DOCTYPE html>
<html lang="de" dir="ltr">
@@ -56,24 +57,17 @@
<?php endforeach; ?>
<?php if (isset($groups["Brandschutz"]) && $groups["Brandschutz"] == 1) {
// Prüfen ob Schulung überfällig ist
$bsOverdue = false;
$bsStmt = $pdo->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'];
?>
<?php if ($bsOverdue): ?>
<div class="mainpage_notice mainpage_notice--warning" role="status">
<div class="mainpage_notice_title">Brandschutzunterweisung fällig!</div>
<div class="mainpage_notice_text">Bitte führen Sie zeitnah die <strong><a href="window.location.href='/intranet_repo/projects/brandschutz/brandschutz.php'">Online-Brandschutzunterweisung</a></strong> durch. Die Schulung dauert nur ca. 15 bis 20 Minuten. Eine regelmäßige Durchführung ist gesetzlich vorgeschrieben. Vielen Dank!</div>
<div class="mainpage_notice_text">Bitte führen Sie zeitnah die <strong><a href="/intranet_repo/projects/brandschutz/brandschutz.php">Online-Brandschutzunterweisung</a></strong> durch. Die Schulung dauert nur ca. 15 bis 20 Minuten. Eine regelmäßige Durchführung ist gesetzlich vorgeschrieben. Vielen Dank!</div>
</div>
<?php endif; ?>
+194 -759
View File
@@ -1,782 +1,217 @@
<?php /* Name: kalli.php
Beschreibung: Kalli: Hauptseite */
require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/initialise.php';
<?php
/* Name: kalli.php
Beschreibung: Kalli: Eintragungsassistent */
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/initialise.php';
require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/util/kalli_helpers.php';
$csrfToken = kalliCsrfToken();
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !kalliCsrfIsValid($_POST['csrf_token'] ?? null)) {
http_response_code(400);
exit('Ungültige Anfrage. Bitte laden Sie die Seite neu.');
}
$permissionGroups = is_array($groups ?? null) ? $groups : [];
$catalog = kalliBuildCatalog(kalliFetchCatalogRows($pdo), $permissionGroups);
$formValues = kalliFormValues($_SERVER['REQUEST_METHOD'] === 'POST' ? $_POST : []);
$formError = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$entry = kalliValidateEntry($_POST, $catalog);
$xlsxDirectory = intranet_env('CKAL_XLSX_DIR', '/data/ckal-xlsx');
kalliWriteCalendarFile($entry, is_array($user) ? $user : [], $xlsxDirectory);
header('Location: /intranet_repo/projects/intranet/index.php?kalli_success=1', true, 303);
exit();
} catch (InvalidArgumentException $exception) {
$formError = $exception->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.';
}
}
?>
<!DOCTYPE html>
<html lang="de" dir="ltr">
<head>
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/html_head.php'; ?>
<?php require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/projects/_central/php/html_head.php'; ?>
<link rel="stylesheet" href="/intranet_repo/projects/_central/layouts/kalli.css">
<script src="/intranet_repo/projects/_central/scripts/kalli.js" defer></script>
</head>
<body class="theme-blue">
<?php // Stelle sicher, dass die Autoload-Datei von PHPSpreadsheet geladen wird
require 'vendor/autoload.php'; ?>
<?php // Durchführung der Aktionen
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
if(isset($_POST['submit'])) {
// if no errors, carry on
if(!isset($error)) {
$name = htmlspecialchars($user["first_name"]) . " " . htmlspecialchars($user["last_name"]);
$info = "Von " . $name . " eingetragen (Eintragungsassistent)";
// Create a new PHPExcel object
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->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 "<script language='javascript'>window.location.href='/intranet_repo/projects/intranet/index.php?kalli_success=1';</script>";
//header("Location: kalli.php");
//exit();
}
}
// ACHTUNG: Die PHP-Header Weiterleitungen müssen hier abgeschlossen sein.
?>
<?php
// Es folgen ab hier die HTML-Ausgaben beginnend mit dem HTML-Header:
?>
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/header.php'; ?>
<?php include $g_phpSnippetsPath.'overlay_message.php'; ?>
<?php require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/projects/_central/php/header.php'; ?>
<?php include $g_phpSnippetsPath . 'overlay_message.php'; ?>
<div class="header_title ht_kalli text_white" id="header_title">
<span class="header_title_text">Kalli: Eintragungsassistent</span>
<span class="header_title_text">Kalli: Eintragungsassistent</span>
</div>
<div class="content" id="base">
<div class="kalli-wizard">
<div class="kalli-progress" aria-label="Fortschritt">
<?php foreach (['Terminart', 'Zeitraum', 'Informationen', 'Übersicht'] as $index => $label): ?>
<?php if ($index > 0): ?><div class="kalli-progress-line"></div><?php endif; ?>
<div class="kalli-progress-step<?= $index === 0 ? ' active' : '' ?>" data-step="<?= $index + 1 ?>">
<div class="kalli-progress-circle"><?= $index + 1 ?></div>
<span class="kalli-progress-label"><?= kalliHtml($label) ?></span>
</div>
<?php endforeach; ?>
</div>
<?php if (isset($_SESSION["user_id"])): ?>
<?php if ($formError !== null): ?>
<div class="kalli-validation-error" role="alert"><?= kalliHtml($formError) ?></div>
<?php endif; ?>
<?php
if (isset($_GET["error"])) {
$error = $_GET["error"];
$isSuccess = in_array($error, [3]);
$toastType = $isSuccess ? 'success' : 'error';
$toastIcon = $isSuccess
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter"><path d="M20 6L9 17l-5-5"/></svg>'
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>';
$toastText = ($error == 3) ? 'Eintragung erfolgreich! Kalli wird Ihren Termin direkt vermerken.' : 'Es gab einen Fehler. Bitte kontaktieren Sie uns!';
echo "<div class='nak-toast nak-toast--{$toastType}' id='nakToast'>";
echo "<div class='nak-toast__icon'>{$toastIcon}</div>";
echo "<span class='nak-toast__text'>" . htmlspecialchars($toastText) . "</span>";
echo "<button class='nak-toast__close' onclick='dismissNakToast()' aria-label='Schließen'><svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='square' stroke-linejoin='miter'><line x1='18' y1='6' x2='6' y2='18'/><line x1='6' y1='6' x2='18' y2='18'/></svg></button>";
echo "</div>";
echo "<script>
requestAnimationFrame(function(){document.getElementById('nakToast').classList.add('nak-toast--visible');});
setTimeout(function(){dismissNakToast();},4000);
function dismissNakToast(){var t=document.getElementById('nakToast');if(!t||t.classList.contains('nak-toast--hiding'))return;t.classList.add('nak-toast--hiding');t.classList.remove('nak-toast--visible');setTimeout(function(){t.remove();},350);}
</script>";
}
?>
<form id="kalliForm" action="/intranet_repo/projects/kalli/kalli.php" method="post">
<input type="hidden" name="csrf_token" value="<?= kalliHtml($csrfToken) ?>">
<style>
/* ── Kalli Page Wizard ── */
.kalli-wizard {
max-width: 1400px;
width: 90%;
margin: var(--space-xl) auto;
background: #ffffff;
box-shadow: var(--shadow-md);
overflow: hidden;
}
<section class="kalli-step" id="kalliStep1">
<div class="kalli-modal-body">
<div class="kalli-section-title">Terminart auswählen</div>
<div class="kalli-category-grid">
<?php foreach ($catalog['categories'] as $category): ?>
<div class="kalli-category">
<div class="kalli-category-label"><?= kalliHtml($category['name']) ?></div>
<?php foreach ($category['types'] as $type): ?>
<?php $displayLabel = $type['display_label'] !== '' ? $type['display_label'] : $type['name']; ?>
<label class="kalli-radio-item">
<input type="radio" name="group" value="<?= kalliHtml($type['name']) ?>"<?= $formValues['group'] === $type['name'] ? ' checked' : '' ?>>
<span><?= kalliHtml($displayLabel) ?></span>
</label>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
/* ── Progress Bar ── */
.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-direction: column;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.kalli-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;
}
.kalli-progress-step.active .kalli-progress-circle {
background: var(--nak-blue);
border-color: var(--nak-blue);
color: #ffffff;
box-shadow: 0 2px 8px rgba(39,134,207,0.35);
}
.kalli-progress-step.done .kalli-progress-circle {
background: #ffffff;
border-color: var(--nak-blue);
color: var(--nak-blue);
}
.kalli-progress-label {
font-size: var(--font-size-xs);
font-weight: 500;
color: var(--gray-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;
height: 2px;
min-width: 20px;
max-width: 60px;
background: var(--gray-300);
margin: 0 6px;
margin-bottom: 20px;
transition: background 250ms ease;
}
.kalli-progress-line.done {
background: var(--nak-blue);
}
<?php if ($catalog['allow_custom']): ?>
<div class="kalli-custom-section">
<div class="kalli-category-label kalli-category-label--custom">Terminart nicht aufgelistet?</div>
<p class="kalli-custom-hint">Nur ausfüllen, wenn die Terminart oben nicht vorhanden ist.</p>
<div class="kalli-custom-options">
<label class="kalli-radio-item">
<input type="radio" name="group" value="<?= KALLI_CUSTOM_PUBLIC ?>" data-kalli-custom="1"<?= $formValues['group'] === KALLI_CUSTOM_PUBLIC ? ' checked' : '' ?>>
<span>Öffentlich</span>
</label>
<label class="kalli-radio-item">
<input type="radio" name="group" value="<?= KALLI_CUSTOM_INTERNAL ?>" data-kalli-custom="1"<?= $formValues['group'] === KALLI_CUSTOM_INTERNAL ? ' checked' : '' ?>>
<span>Intern</span>
</label>
</div>
<input class="kalli-field" type="text" name="userinput" id="kalliUserInput" value="<?= kalliHtml($formValues['userinput']) ?>" placeholder="Titel (nur für benutzerdefinierte Termine)">
</div>
<?php endif; ?>
</div>
/* ── Body ── */
.kalli-modal-body {
padding: 22px 28px 16px;
}
.kalli-section-title {
margin: 0 0 16px 0;
font-size: var(--font-size-sm);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--gray-600);
}
<div class="kalli-modal-actions">
<div></div>
<div class="kalli-modal-actions-right">
<button type="button" class="kalli-btn kalli-btn-primary" data-kalli-next="2">Weiter</button>
</div>
</div>
</section>
/* ── Category Grid (Step 1) ── */
.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 {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--gray-700);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
label.kalli-radio-item {
display: flex;
align-items: center;
gap:8px;
padding: 0px 5px;
cursor: pointer;
font-size: var(--font-size-sm);
font-weight: 300;
color: var(--gray-600);
border: 1px solid transparent;
transition: background 150ms ease, border-color 150ms ease;
text-transform: none;
letter-spacing: normal;
}
label.kalli-radio-item input[type="radio"],
label.kalli-radio-item input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--nak-blue);
margin: 0;
flex-shrink: 0;
align-self: center;
}
label.kalli-radio-item span {
hyphens: auto;
-webkit-hyphens: auto;
-ms-hyphens: auto;
word-break: break-word;
overflow-wrap: break-word;
line-height: 1.4;
}
label.kalli-radio-item:hover {
background: var(--gray-50);
border-color: var(--gray-200);
}
.kalli-custom-section {
margin-top: var(--space-lg);
padding: var(--space-md);
background: hsl(0,60%,98%);
border: 1px solid hsl(0,60%,90%);
}
<section class="kalli-step" id="kalliStep2" hidden>
<div class="kalli-modal-body">
<div class="kalli-section-title">Zeitraum festlegen</div>
<div class="kalli-field-grid">
<div class="kalli-field-group">
<label class="kalli-label" for="kalliDateS">Datum <span class="kalli-required">*</span></label>
<input class="kalli-field" type="date" name="date_s" id="kalliDateS" value="<?= kalliHtml($formValues['date_s']) ?>" aria-required="true">
</div>
<div class="kalli-field-group">
<label class="kalli-label" for="kalliDateE">Enddatum <span class="kalli-label-note">(falls mehrtägig)</span></label>
<input class="kalli-field" type="date" name="date_e" id="kalliDateE" value="<?= kalliHtml($formValues['date_e']) ?>">
</div>
<div class="kalli-field-group">
<label class="kalli-label" for="kalliTimeS">Zeit von <span class="kalli-label-note">(optional)</span></label>
<input class="kalli-field" type="time" name="time_s" id="kalliTimeS" value="<?= kalliHtml($formValues['time_s']) ?>">
</div>
<div class="kalli-field-group">
<label class="kalli-label" for="kalliTimeE">Zeit bis <span class="kalli-label-note">(optional)</span></label>
<input class="kalli-field" type="time" name="time_e" id="kalliTimeE" value="<?= kalliHtml($formValues['time_e']) ?>">
</div>
</div>
</div>
<div class="kalli-modal-actions">
<div class="kalli-modal-actions-left">
<button type="button" class="kalli-btn" data-kalli-prev="1">Zurück</button>
</div>
<div class="kalli-modal-actions-right">
<button type="button" class="kalli-btn kalli-btn-primary" data-kalli-next="3">Weiter</button>
</div>
</div>
</section>
/* ── Field Grid (Steps 2 & 3) ── */
.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 {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--gray-700);
}
.kalli-field {
padding: 12px 14px;
font-size: var(--font-size-base);
font-family: var(--font-primary);
font-weight: 300;
border: 1px solid var(--gray-300);
background: var(--gray-50);
outline: none;
transition: border-color 150ms ease, box-shadow 150ms ease;
width: 100%;
box-sizing: border-box;
}
.kalli-field:focus {
border-color: var(--nak-blue);
box-shadow: 0 0 0 3px rgba(39,134,207,0.2);
background: #ffffff;
}
<section class="kalli-step" id="kalliStep3" hidden>
<div class="kalli-modal-body">
<div class="kalli-section-title">Zusätzliche Informationen</div>
<div class="kalli-field-grid kalli-field-grid-single">
<div class="kalli-field-group">
<label class="kalli-label" for="kalliFurtherText">Textfeld <span class="kalli-label-note">(optional)</span></label>
<input class="kalli-field" type="text" name="further_text" id="kalliFurtherText" value="<?= kalliHtml($formValues['further_text']) ?>" placeholder="z.B. betreffende Person, etc.">
</div>
<div class="kalli-field-group">
<label class="kalli-label" for="kalliLocation">Ort <span class="kalli-label-note">(optional)</span></label>
<input class="kalli-field" type="text" name="location" id="kalliLocation" value="<?= kalliHtml($formValues['location']) ?>">
</div>
<div class="kalli-field-group">
<label class="kalli-label" for="kalliResponsibility">Verantwortliche/r <span class="kalli-label-note">(optional)</span></label>
<input class="kalli-field" type="text" name="responsibility" id="kalliResponsibility" value="<?= kalliHtml($formValues['responsibility']) ?>">
</div>
</div>
</div>
<div class="kalli-modal-actions">
<div class="kalli-modal-actions-left">
<button type="button" class="kalli-btn" data-kalli-prev="2">Zurück</button>
</div>
<div class="kalli-modal-actions-right">
<button type="button" class="kalli-btn kalli-btn-primary" data-kalli-next="4">Weiter</button>
</div>
</div>
</section>
/* ── Summary (Step 4) ── */
.kalli-summary {
display: flex;
flex-direction: column;
gap: 0;
border: 1px solid var(--gray-200);
}
.kalli-summary-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--gray-100);
}
.kalli-summary-row:last-child { border-bottom: none; }
.kalli-summary-label {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--gray-600);
}
.kalli-summary-value {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--gray-900);
text-align: right;
}
.kalli-validation-error {
margin-top: var(--space-md);
padding: 10px 14px;
background: hsl(0,78%,95%);
color: hsl(0,78%,40%);
font-weight: 600;
font-size: var(--font-size-sm);
}
/* ── Actions ── */
.kalli-modal-actions {
display: flex;
justify-content: space-between;
align-items: center;
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;
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;
}
.kalli-btn:hover {
background: var(--gray-100);
transform: translateY(-1px);
}
.kalli-btn-primary {
background: var(--nak-blue);
border-color: var(--nak-blue);
color: #ffffff;
}
.kalli-btn-primary:hover {
background: var(--nak-blue-hover);
border-color: var(--nak-blue-hover);
}
.kalli-btn-submit {
background: linear-gradient(135deg, var(--nak-blue), var(--nak-blue-hover));
border-color: var(--nak-blue-hover);
color: #ffffff;
padding: 10px 24px;
font-weight: 700;
}
.kalli-btn-submit:hover {
background: linear-gradient(135deg, var(--nak-blue-hover), #185d8f);
}
/* ── Responsive ── */
@media (max-width: 600px) {
.kalli-wizard { margin: 0; width: 100%; }
.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 { grid-template-columns: 1fr; }
.kalli-field-grid { grid-template-columns: 1fr; }
.kalli-modal-actions { padding: 12px 16px 16px; flex-wrap: wrap; }
}
</style>
<div class="kalli-wizard">
<!-- Progress Bar -->
<div class="kalli-progress">
<div class="kalli-progress-step active" data-step="1">
<div class="kalli-progress-circle">1</div>
<span class="kalli-progress-label">Terminart</span>
</div>
<div class="kalli-progress-line"></div>
<div class="kalli-progress-step" data-step="2">
<div class="kalli-progress-circle">2</div>
<span class="kalli-progress-label">Zeitraum</span>
</div>
<div class="kalli-progress-line"></div>
<div class="kalli-progress-step" data-step="3">
<div class="kalli-progress-circle">3</div>
<span class="kalli-progress-label">Informationen</span>
</div>
<div class="kalli-progress-line"></div>
<div class="kalli-progress-step" data-step="4">
<div class="kalli-progress-circle">4</div>
<span class="kalli-progress-label">Übersicht</span>
</div>
<section class="kalli-step" id="kalliStep4" hidden>
<div class="kalli-modal-body">
<div class="kalli-section-title">Übersicht – Bitte prüfen Sie Ihre Eingaben</div>
<div class="kalli-summary">
<div class="kalli-summary-row">
<span class="kalli-summary-label">Terminart</span>
<span class="kalli-summary-value" id="sumGroup">–</span>
</div>
<div class="kalli-summary-row">
<span class="kalli-summary-label">Datum</span>
<span class="kalli-summary-value" id="sumDateS">–</span>
</div>
<?php foreach ([
['sumDateERow', 'Enddatum', 'sumDateE'],
['sumTimeSRow', 'Zeit von', 'sumTimeS'],
['sumTimeERow', 'Zeit bis', 'sumTimeE'],
['sumTextRow', 'Textfeld', 'sumText'],
['sumLocRow', 'Ort', 'sumLoc'],
['sumRespRow', 'Verantwortlich', 'sumResp'],
] as [$rowId, $label, $valueId]): ?>
<div class="kalli-summary-row" id="<?= $rowId ?>" hidden>
<span class="kalli-summary-label"><?= kalliHtml($label) ?></span>
<span class="kalli-summary-value" id="<?= $valueId ?>">–</span>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="kalli-modal-actions">
<div class="kalli-modal-actions-left">
<button type="button" class="kalli-btn" data-kalli-prev="3">Zurück</button>
</div>
<div class="kalli-modal-actions-right">
<button type="submit" name="submit" value="1" class="kalli-btn kalli-btn-submit">Eintragung absenden</button>
</div>
</div>
</section>
</form>
</div>
</div>
<!-- Form wraps all steps -->
<form id="kalliForm" action="" method="post">
<!-- STEP 1: Terminart -->
<div class="kalli-step" id="kalliStep1">
<div class="kalli-modal-body">
<div class="kalli-section-title">Terminart auswählen</div>
<div class="kalli-category-grid">
<?php
// Kategorien und Terminarten aus der Datenbank laden
$catQuery = $pdo->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;
?>
<div class="kalli-category">
<div class="kalli-category-label"><?= htmlspecialchars($cat['name']) ?></div>
<?php foreach ($types as $type):
$displayLabel = !empty($type['display_label']) ? $type['display_label'] : $type['name'];
?>
<label class="kalli-radio-item"><input type="radio" name="group" value="<?= htmlspecialchars($type['name']) ?>"><span><?= htmlspecialchars($displayLabel) ?></span></label>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<?php
$hasAnyKalliRole = $isAdminUser || count(array_intersect_key($groups, $kalliAllRoles)) > 0;
if ($hasAnyKalliRole): ?>
<div class="kalli-custom-section">
<div class="kalli-category-label" style="color:var(--nak-red);">Terminart nicht aufgelistet?</div>
<p style="margin:0 0 var(--space-sm) 0;font-size:var(--font-size-sm);color:var(--gray-600);">Nur ausfüllen, wenn die Terminart oben nicht vorhanden ist.</p>
<div style="display:flex;gap:var(--space-lg);flex-wrap:wrap;margin-bottom:var(--space-sm);">
<label class="kalli-radio-item"><input type="radio" name="group" value="oeffentlich"><span>Öffentlich</span></label>
<label class="kalli-radio-item"><input type="radio" name="group" value="intern"><span>Intern</span></label>
</div>
<input class="kalli-field" type="text" name="userinput" id="kalliUserInput" placeholder="Titel (nur für benutzerdefinierte Termine)">
</div>
<?php endif; ?>
</div>
<div class="kalli-modal-actions">
<div></div>
<div class="kalli-modal-actions-right">
<button type="button" class="kalli-btn kalli-btn-primary" onclick="kalliNextStep(2)">Weiter</button>
</div>
</div>
</div>
<!-- STEP 2: Zeitraum -->
<div class="kalli-step" id="kalliStep2" style="display:none;">
<div class="kalli-modal-body">
<div class="kalli-section-title">Zeitraum festlegen</div>
<div class="kalli-field-grid">
<div class="kalli-field-group">
<label class="kalli-label">Datum <span style="color:var(--nak-red);">*</span></label>
<input class="kalli-field" type="date" name="date_s" id="kalliDateS" required>
</div>
<div class="kalli-field-group">
<label class="kalli-label">Enddatum <span style="font-weight:300;color:var(--gray-500);">(falls mehrtägig)</span></label>
<input class="kalli-field" type="date" name="date_e" id="kalliDateE">
</div>
<div class="kalli-field-group">
<label class="kalli-label">Zeit von <span style="font-weight:300;color:var(--gray-500);">(optional)</span></label>
<input class="kalli-field" type="time" name="time_s" id="kalliTimeS">
</div>
<div class="kalli-field-group">
<label class="kalli-label">Zeit bis <span style="font-weight:300;color:var(--gray-500);">(optional)</span></label>
<input class="kalli-field" type="time" name="time_e" id="kalliTimeE">
</div>
</div>
</div>
<div class="kalli-modal-actions">
<div class="kalli-modal-actions-left">
<button type="button" class="kalli-btn" onclick="kalliPrevStep(1)">Zurück</button>
</div>
<div class="kalli-modal-actions-right">
<button type="button" class="kalli-btn kalli-btn-primary" onclick="kalliNextStep(3)">Weiter</button>
</div>
</div>
</div>
<!-- STEP 3: Informationen -->
<div class="kalli-step" id="kalliStep3" style="display:none;">
<div class="kalli-modal-body">
<div class="kalli-section-title">Zusätzliche Informationen</div>
<div class="kalli-field-grid kalli-field-grid-single">
<div class="kalli-field-group">
<label class="kalli-label">Textfeld <span style="font-weight:300;color:var(--gray-500);">(optional)</span></label>
<input class="kalli-field" type="text" name="further_text" id="kalliFurtherText" placeholder="z.B. betreffende Person, etc.">
</div>
<div class="kalli-field-group">
<label class="kalli-label">Ort <span style="font-weight:300;color:var(--gray-500);">(optional)</span></label>
<input class="kalli-field" type="text" name="location" id="kalliLocation">
</div>
<div class="kalli-field-group">
<label class="kalli-label">Verantwortliche/r <span style="font-weight:300;color:var(--gray-500);">(optional)</span></label>
<input class="kalli-field" type="text" name="responsibility" id="kalliResponsibility">
</div>
</div>
</div>
<div class="kalli-modal-actions">
<div class="kalli-modal-actions-left">
<button type="button" class="kalli-btn" onclick="kalliPrevStep(2)">Zurück</button>
</div>
<div class="kalli-modal-actions-right">
<button type="button" class="kalli-btn kalli-btn-primary" onclick="kalliNextStep(4)">Weiter</button>
</div>
</div>
</div>
<!-- STEP 4: Übersicht -->
<div class="kalli-step" id="kalliStep4" style="display:none;">
<div class="kalli-modal-body">
<div class="kalli-section-title">Übersicht – Bitte prüfen Sie Ihre Eingaben</div>
<div class="kalli-summary">
<div class="kalli-summary-row">
<span class="kalli-summary-label">Terminart</span>
<span class="kalli-summary-value" id="sumGroup">–</span>
</div>
<div class="kalli-summary-row">
<span class="kalli-summary-label">Datum</span>
<span class="kalli-summary-value" id="sumDateS">–</span>
</div>
<div class="kalli-summary-row" id="sumDateERow" style="display:none;">
<span class="kalli-summary-label">Enddatum</span>
<span class="kalli-summary-value" id="sumDateE">–</span>
</div>
<div class="kalli-summary-row" id="sumTimeSRow" style="display:none;">
<span class="kalli-summary-label">Zeit von</span>
<span class="kalli-summary-value" id="sumTimeS">–</span>
</div>
<div class="kalli-summary-row" id="sumTimeERow" style="display:none;">
<span class="kalli-summary-label">Zeit bis</span>
<span class="kalli-summary-value" id="sumTimeE">–</span>
</div>
<div class="kalli-summary-row" id="sumTextRow" style="display:none;">
<span class="kalli-summary-label">Textfeld</span>
<span class="kalli-summary-value" id="sumText">–</span>
</div>
<div class="kalli-summary-row" id="sumLocRow" style="display:none;">
<span class="kalli-summary-label">Ort</span>
<span class="kalli-summary-value" id="sumLoc">–</span>
</div>
<div class="kalli-summary-row" id="sumRespRow" style="display:none;">
<span class="kalli-summary-label">Verantwortlich</span>
<span class="kalli-summary-value" id="sumResp">–</span>
</div>
</div>
<div id="kalliValidationError" class="kalli-validation-error" style="display:none;"></div>
</div>
<div class="kalli-modal-actions">
<div class="kalli-modal-actions-left">
<button type="button" class="kalli-btn" onclick="kalliPrevStep(3)">Zurück</button>
</div>
<div class="kalli-modal-actions-right">
<button type="submit" name="submit" class="kalli-btn kalli-btn-submit">Eintragung absenden</button>
</div>
</div>
</div>
</form>
</div>
<script>
var kalliCurrentStep = 1;
function kalliGoToStep(step) {
for (var i = 1; i <= 4; i++) {
document.getElementById('kalliStep' + i).style.display = (i === step) ? '' : 'none';
}
kalliCurrentStep = step;
kalliUpdateProgress(step);
window.scrollTo({top: 0, behavior: 'smooth'});
// Header wieder einblenden, falls er durch Scrollen ausgeblendet war
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'; }
}
function kalliUpdateProgress(step) {
var steps = document.querySelectorAll('.kalli-progress-step');
var lines = document.querySelectorAll('.kalli-progress-line');
steps.forEach(function(el, idx) {
var s = idx + 1;
el.classList.remove('active', 'done');
if (s === step) el.classList.add('active');
else if (s < step) el.classList.add('done');
});
lines.forEach(function(el, idx) {
el.classList.toggle('done', idx < step - 1);
});
}
function kalliNextStep(step) {
var errEl = document.getElementById('kalliValidationError');
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
if (step === 2) {
var sel = document.querySelector('#kalliForm input[name="group"]:checked');
if (!sel) { alert('Bitte wählen Sie eine Terminart aus.'); return; }
if ((sel.value === 'oeffentlich' || sel.value === 'intern') && !document.getElementById('kalliUserInput').value.trim()) {
alert('Bitte geben Sie einen Titel für den benutzerdefinierten Termin ein.'); return;
}
}
if (step === 3) {
if (!document.getElementById('kalliDateS').value) { alert('Bitte geben Sie ein Datum ein.'); return; }
}
if (step === 4) { kalliBuildSummary(); }
kalliGoToStep(step);
}
function kalliPrevStep(step) { kalliGoToStep(step); }
function kalliBuildSummary() {
var grp = document.querySelector('#kalliForm input[name="group"]:checked');
var grpVal = grp ? grp.value : '–';
if ((grpVal === 'oeffentlich' || grpVal === 'intern') && document.getElementById('kalliUserInput')) {
var custom = document.getElementById('kalliUserInput').value.trim();
grpVal = custom ? custom + ' (' + (grpVal === 'oeffentlich' ? 'Öffentlich' : 'Intern') + ')' : grpVal;
}
document.getElementById('sumGroup').textContent = grpVal;
document.getElementById('sumDateS').textContent = kalliFormatDate(document.getElementById('kalliDateS').value);
kalliShowSummaryRow('sumDateERow', document.getElementById('kalliDateE').value, 'sumDateE', kalliFormatDate(document.getElementById('kalliDateE').value));
kalliShowSummaryRow('sumTimeSRow', document.getElementById('kalliTimeS').value, 'sumTimeS', document.getElementById('kalliTimeS').value);
kalliShowSummaryRow('sumTimeERow', document.getElementById('kalliTimeE').value, 'sumTimeE', document.getElementById('kalliTimeE').value);
kalliShowSummaryRow('sumTextRow', document.getElementById('kalliFurtherText').value.trim(), 'sumText', document.getElementById('kalliFurtherText').value.trim());
kalliShowSummaryRow('sumLocRow', document.getElementById('kalliLocation').value.trim(), 'sumLoc', document.getElementById('kalliLocation').value.trim());
kalliShowSummaryRow('sumRespRow', document.getElementById('kalliResponsibility').value.trim(), 'sumResp', document.getElementById('kalliResponsibility').value.trim());
}
function kalliShowSummaryRow(rowId, value, valId, displayVal) {
var row = document.getElementById(rowId);
if (value) { row.style.display = ''; document.getElementById(valId).textContent = displayVal; }
else { row.style.display = 'none'; }
}
function kalliFormatDate(iso) {
if (!iso) return '–';
var parts = iso.split('-');
return parts.length === 3 ? parts[2] + '.' + parts[1] + '.' + parts[0] : iso;
}
</script>
<?php endif; ?>
</div><!-- content -->
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/footer.php'; ?>
<?php require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/projects/_central/php/footer.php'; ?>
</body>
</html>
@@ -1,746 +0,0 @@
<!-- Kalli Eintragungsassistent – Mehrstufiges Modal -->
<div class="standard_overlay" id="kalliModal" onclick="if(event.target.id==='kalliModal')closeKalliModal();" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;justify-content:center;align-items:center;background-color:hsla(210,57%,25%,0.8);z-index:9999;">
<div class="kalli-modal-card">
<!-- Close Button -->
<button type="button" class="kalli-modal-exit" onclick="closeKalliModal()" aria-label="Schließen">&times;</button>
<!-- Hero Header -->
<div class="kalli-modal-hero">
<div>
<div class="kalli-modal-title">Kalli – Eintragungsassistent</div>
<div class="kalli-modal-subtitle">Neuen Termin im CKalender eintragen</div>
</div>
</div>
<!-- Progress Bar -->
<div class="kalli-progress">
<div class="kalli-progress-step active" data-step="1">
<div class="kalli-progress-circle">1</div>
<span class="kalli-progress-label">Terminart</span>
</div>
<div class="kalli-progress-line"></div>
<div class="kalli-progress-step" data-step="2">
<div class="kalli-progress-circle">2</div>
<span class="kalli-progress-label">Zeitraum</span>
</div>
<div class="kalli-progress-line"></div>
<div class="kalli-progress-step" data-step="3">
<div class="kalli-progress-circle">3</div>
<span class="kalli-progress-label">Informationen</span>
</div>
<div class="kalli-progress-line"></div>
<div class="kalli-progress-step" data-step="4">
<div class="kalli-progress-circle">4</div>
<span class="kalli-progress-label">Übersicht</span>
</div>
</div>
<!-- Form wraps all steps -->
<form id="kalliForm" action="/intranet_repo/projects/kalli/kalli.php" method="post">
<!-- STEP 1: Terminart -->
<div class="kalli-step" id="kalliStep1">
<div class="kalli-modal-body">
<div class="kalli-section-title">Terminart auswählen</div>
<div class="kalli-category-grid">
<?php
$isAdminUser = (isset($groups["Admin"]) && $groups["Admin"] == 1);
// Kategorien und Terminarten aus der Datenbank laden
$catQueryModal = $pdo->query("SELECT * FROM kalli_categories ORDER BY sort_order, name");
$kalliCategoriesModal = [];
while ($catM = $catQueryModal->fetch(PDO::FETCH_ASSOC)) {
$kalliCategoriesModal[] = $catM;
}
$typeQueryModal = $pdo->query("SELECT * FROM kalli_appointment_types ORDER BY sort_order, name");
$kalliTypesByCategoryModal = [];
while ($typeM = $typeQueryModal->fetch(PDO::FETCH_ASSOC)) {
$kalliTypesByCategoryModal[$typeM['category_id']][] = $typeM;
}
$kalliAllRolesModal = [];
foreach ($kalliCategoriesModal as $catM):
$roleM = $catM['permission_role'];
$kalliAllRolesModal[$roleM] = 1;
if (!$isAdminUser && !(isset($groups[$roleM]) && $groups[$roleM] == 1)) continue;
$typesM = $kalliTypesByCategoryModal[$catM['id']] ?? [];
if (empty($typesM)) continue;
?>
<div class="kalli-category">
<div class="kalli-category-label"><?= htmlspecialchars($catM['name']) ?></div>
<?php foreach ($typesM as $typeM):
$displayLabelM = !empty($typeM['display_label']) ? $typeM['display_label'] : $typeM['name'];
?>
<label class="kalli-radio-item"><input type="radio" name="group" value="<?= htmlspecialchars($typeM['name']) ?>"><span><?= htmlspecialchars($displayLabelM) ?></span></label>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<?php
$hasAnyKalliRoleModal = $isAdminUser || count(array_intersect_key($groups, $kalliAllRolesModal)) > 0;
if ($hasAnyKalliRoleModal): ?>
<div class="kalli-custom-section">
<div class="kalli-category-label" style="color:var(--nak-red);">Terminart nicht aufgelistet?</div>
<p style="margin:0 0 var(--space-sm) 0;font-size:var(--font-size-sm);color:var(--gray-600);">Nur ausfüllen, wenn die Terminart oben nicht vorhanden ist.</p>
<div style="display:flex;gap:var(--space-lg);flex-wrap:wrap;margin-bottom:var(--space-sm);">
<label class="kalli-radio-item"><input type="radio" name="group" value="oeffentlich"><span>Öffentlich</span></label>
<label class="kalli-radio-item"><input type="radio" name="group" value="intern"><span>Intern</span></label>
</div>
<input class="kalli-field" type="text" name="userinput" id="kalliUserInput" placeholder="Titel (nur für benutzerdefinierte Termine)">
</div>
<?php endif; ?>
</div>
<div class="kalli-modal-actions">
<div></div>
<div class="kalli-modal-actions-right">
<button type="button" class="kalli-btn" onclick="closeKalliModal()">Abbrechen</button>
<button type="button" class="kalli-btn kalli-btn-primary" onclick="kalliNextStep(2)">Weiter</button>
</div>
</div>
</div>
<!-- STEP 2: Zeitraum -->
<div class="kalli-step" id="kalliStep2" style="display:none;">
<div class="kalli-modal-body">
<div class="kalli-section-title">Zeitraum festlegen</div>
<div class="kalli-field-grid">
<div class="kalli-field-group">
<label class="kalli-label">Datum <span style="color:var(--nak-red);">*</span></label>
<input class="kalli-field" type="date" name="date_s" id="kalliDateS" required>
</div>
<div class="kalli-field-group">
<label class="kalli-label">Enddatum <span style="font-weight:300;color:var(--gray-500);">(falls mehrtägig)</span></label>
<input class="kalli-field" type="date" name="date_e" id="kalliDateE">
</div>
<div class="kalli-field-group">
<label class="kalli-label">Zeit von <span style="font-weight:300;color:var(--gray-500);">(optional)</span></label>
<input class="kalli-field" type="time" name="time_s" id="kalliTimeS">
</div>
<div class="kalli-field-group">
<label class="kalli-label">Zeit bis <span style="font-weight:300;color:var(--gray-500);">(optional)</span></label>
<input class="kalli-field" type="time" name="time_e" id="kalliTimeE">
</div>
</div>
</div>
<div class="kalli-modal-actions">
<div class="kalli-modal-actions-left">
<button type="button" class="kalli-btn" onclick="kalliPrevStep(1)">Zurück</button>
</div>
<div class="kalli-modal-actions-right">
<button type="button" class="kalli-btn kalli-btn-primary" onclick="kalliNextStep(3)">Weiter</button>
</div>
</div>
</div>
<!-- STEP 3: Informationen -->
<div class="kalli-step" id="kalliStep3" style="display:none;">
<div class="kalli-modal-body">
<div class="kalli-section-title">Zusätzliche Informationen</div>
<div class="kalli-field-grid kalli-field-grid-single">
<div class="kalli-field-group">
<label class="kalli-label">Textfeld <span style="font-weight:300;color:var(--gray-500);">(optional)</span></label>
<input class="kalli-field" type="text" name="further_text" id="kalliFurtherText" placeholder="z.B. betreffende Person, etc.">
</div>
<div class="kalli-field-group">
<label class="kalli-label">Ort <span style="font-weight:300;color:var(--gray-500);">(optional)</span></label>
<input class="kalli-field" type="text" name="location" id="kalliLocation">
</div>
<div class="kalli-field-group">
<label class="kalli-label">Verantwortliche/r <span style="font-weight:300;color:var(--gray-500);">(optional)</span></label>
<input class="kalli-field" type="text" name="responsibility" id="kalliResponsibility">
</div>
</div>
</div>
<div class="kalli-modal-actions">
<div class="kalli-modal-actions-left">
<button type="button" class="kalli-btn" onclick="kalliPrevStep(2)">Zurück</button>
</div>
<div class="kalli-modal-actions-right">
<button type="button" class="kalli-btn kalli-btn-primary" onclick="kalliNextStep(4)">Weiter</button>
</div>
</div>
</div>
<!-- STEP 4: Übersicht -->
<div class="kalli-step" id="kalliStep4" style="display:none;">
<div class="kalli-modal-body">
<div class="kalli-section-title">Übersicht – Bitte prüfen Sie Ihre Eingaben</div>
<div class="kalli-summary">
<div class="kalli-summary-row">
<span class="kalli-summary-label">Terminart</span>
<span class="kalli-summary-value" id="sumGroup">–</span>
</div>
<div class="kalli-summary-row">
<span class="kalli-summary-label">Datum</span>
<span class="kalli-summary-value" id="sumDateS">–</span>
</div>
<div class="kalli-summary-row" id="sumDateERow" style="display:none;">
<span class="kalli-summary-label">Enddatum</span>
<span class="kalli-summary-value" id="sumDateE">–</span>
</div>
<div class="kalli-summary-row" id="sumTimeSRow" style="display:none;">
<span class="kalli-summary-label">Zeit von</span>
<span class="kalli-summary-value" id="sumTimeS">–</span>
</div>
<div class="kalli-summary-row" id="sumTimeERow" style="display:none;">
<span class="kalli-summary-label">Zeit bis</span>
<span class="kalli-summary-value" id="sumTimeE">–</span>
</div>
<div class="kalli-summary-row" id="sumTextRow" style="display:none;">
<span class="kalli-summary-label">Textfeld</span>
<span class="kalli-summary-value" id="sumText">–</span>
</div>
<div class="kalli-summary-row" id="sumLocRow" style="display:none;">
<span class="kalli-summary-label">Ort</span>
<span class="kalli-summary-value" id="sumLoc">–</span>
</div>
<div class="kalli-summary-row" id="sumRespRow" style="display:none;">
<span class="kalli-summary-label">Verantwortlich</span>
<span class="kalli-summary-value" id="sumResp">–</span>
</div>
</div>
<div id="kalliValidationError" class="kalli-validation-error" style="display:none;"></div>
</div>
<div class="kalli-modal-actions">
<div class="kalli-modal-actions-left">
<button type="button" class="kalli-btn" onclick="kalliPrevStep(3)">Zurück</button>
</div>
<div class="kalli-modal-actions-right">
<button type="submit" name="submit" class="kalli-btn kalli-btn-submit">Eintragung absenden</button>
</div>
</div>
</div>
</form>
</div>
</div>
<style>
/* ── Kalli Modal ── */
.kalli-modal-card {
position: relative;
width: min(720px, calc(100vw - 24px));
max-height: min(90vh, calc(100vh - 24px));
overflow-y: auto;
background: #ffffff;
box-shadow: 0 24px 70px rgba(0,0,0,0.36);
}
.kalli-modal-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;
z-index: 1;
}
.kalli-modal-hero {
padding: 24px 28px;
color: #ffffff;
background:
radial-gradient(circle at 10% 0%, rgba(255,255,255,0.24), transparent 32%),
linear-gradient(135deg, var(--nak-blue), var(--nak-blue-hover));
}
.kalli-modal-title {
font-size: 1.35em;
font-weight: 700;
margin-bottom: 4px;
}
.kalli-modal-subtitle {
font-size: var(--font-size-sm);
font-weight: 300;
color: rgba(255,255,255,0.8);
}
/* ── Progress Bar ── */
.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-direction: column;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.kalli-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;
}
.kalli-progress-step.active .kalli-progress-circle {
background: var(--nak-blue);
border-color: var(--nak-blue);
color: #ffffff;
box-shadow: 0 2px 8px rgba(39,134,207,0.35);
}
.kalli-progress-step.done .kalli-progress-circle {
background: #ffffff;
border-color: var(--nak-blue);
color: var(--nak-blue);
}
.kalli-progress-label {
font-size: var(--font-size-xs);
font-weight: 500;
color: var(--gray-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;
height: 2px;
min-width: 20px;
max-width: 60px;
background: var(--gray-300);
margin: 0 6px;
margin-bottom: 20px;
transition: background 250ms ease;
}
.kalli-progress-line.done {
background: var(--nak-blue);
}
/* ── Modal Body ── */
.kalli-modal-body {
padding: 22px 28px 16px;
}
.kalli-section-title {
margin: 0 0 16px 0;
font-size: var(--font-size-sm);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--gray-600);
}
/* ── Category Grid (Step 1) ── */
.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 {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--gray-700);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
label.kalli-radio-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
cursor: pointer;
font-size: var(--font-size-sm);
font-weight: 400;
color: var(--gray-800);
border: 1px solid transparent;
transition: background 150ms ease, border-color 150ms ease;
text-transform: none;
letter-spacing: normal;
}
label.kalli-radio-item input[type="radio"],
label.kalli-radio-item input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--nak-blue);
margin: 0;
flex-shrink: 0;
align-self: center;
}
label.kalli-radio-item span {
hyphens: auto;
-webkit-hyphens: auto;
-ms-hyphens: auto;
word-break: break-word;
overflow-wrap: break-word;
line-height: 1.4;
}
label.kalli-radio-item:hover {
background: var(--gray-50);
border-color: var(--gray-200);
}
.kalli-custom-section {
margin-top: var(--space-lg);
padding: var(--space-md);
background: hsl(0,60%,98%);
border: 1px solid hsl(0,60%,90%);
}
/* ── Field Grid (Steps 2 & 3) ── */
.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 {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--gray-700);
}
.kalli-field {
padding: 12px 14px;
font-size: var(--font-size-base);
font-family: var(--font-primary);
font-weight: 300;
border: 1px solid var(--gray-300);
background: var(--gray-50);
outline: none;
transition: border-color 150ms ease, box-shadow 150ms ease;
width: 100%;
}
.kalli-field:focus {
border-color: var(--nak-blue);
box-shadow: 0 0 0 3px var(--nak-blue-20);
background: #ffffff;
}
/* ── Summary (Step 4) ── */
.kalli-summary {
display: flex;
flex-direction: column;
gap: 0;
border: 1px solid var(--gray-200);
}
.kalli-summary-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--gray-100);
}
.kalli-summary-row:last-child {
border-bottom: none;
}
.kalli-summary-label {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--gray-600);
}
.kalli-summary-value {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--gray-900);
text-align: right;
}
.kalli-validation-error {
margin-top: var(--space-md);
padding: 10px 14px;
background: hsl(0,78%,95%);
color: hsl(0,78%,40%);
font-weight: 600;
font-size: var(--font-size-sm);
}
/* ── Actions ── */
.kalli-modal-actions {
display: flex;
justify-content: space-between;
align-items: center;
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;
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;
}
.kalli-btn:hover {
background: var(--gray-100);
transform: translateY(-1px);
}
.kalli-btn-primary {
background: var(--nak-blue);
border-color: var(--nak-blue);
color: #ffffff;
}
.kalli-btn-primary:hover {
background: var(--nak-blue-hover);
border-color: var(--nak-blue-hover);
}
.kalli-btn-submit {
background: linear-gradient(135deg, var(--nak-blue), var(--nak-blue-hover));
border-color: var(--nak-blue-hover);
color: #ffffff;
padding: 10px 24px;
font-weight: 700;
}
.kalli-btn-submit:hover {
background: linear-gradient(135deg, var(--nak-blue-hover), #185d8f);
}
/* ── Responsive ── */
@media (max-width: 600px) {
.kalli-modal-card {
width: 100vw;
max-height: 100vh;
max-height: 100dvh;
}
.kalli-modal-hero {
padding: 18px 16px;
}
.kalli-modal-title {
font-size: 1.15em;
}
.kalli-progress {
padding: 14px 12px 10px;
gap: 0;
}
.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 {
grid-template-columns: 1fr;
}
.kalli-field-grid {
grid-template-columns: 1fr;
}
.kalli-modal-actions {
padding: 12px 16px 16px;
flex-wrap: wrap;
}
}
</style>
<script>
var kalliCurrentStep = 1;
function openKalliModal() {
document.getElementById('kalliModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
kalliGoToStep(1);
}
function closeKalliModal() {
document.getElementById('kalliModal').style.display = 'none';
document.body.style.overflow = '';
}
function kalliGoToStep(step) {
for (var i = 1; i <= 4; i++) {
document.getElementById('kalliStep' + i).style.display = (i === step) ? '' : 'none';
}
kalliCurrentStep = step;
kalliUpdateProgress(step);
// Scroll modal body to top
document.querySelector('.kalli-modal-card').scrollTop = 0;
}
function kalliUpdateProgress(step) {
var steps = document.querySelectorAll('.kalli-progress-step');
var lines = document.querySelectorAll('.kalli-progress-line');
steps.forEach(function(el, idx) {
var s = idx + 1;
el.classList.remove('active', 'done');
if (s === step) el.classList.add('active');
else if (s < step) el.classList.add('done');
});
lines.forEach(function(el, idx) {
el.classList.toggle('done', idx < step - 1);
});
}
function kalliNextStep(step) {
var errEl = document.getElementById('kalliValidationError');
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
if (step === 2) {
// Validate step 1: a group must be selected
var sel = document.querySelector('#kalliForm input[name="group"]:checked');
if (!sel) {
alert('Bitte wählen Sie eine Terminart aus.');
return;
}
// If custom group, require title
if ((sel.value === 'oeffentlich' || sel.value === 'intern') && !document.getElementById('kalliUserInput').value.trim()) {
alert('Bitte geben Sie einen Titel für den benutzerdefinierten Termin ein.');
return;
}
}
if (step === 3) {
// Validate step 2: date is required
if (!document.getElementById('kalliDateS').value) {
alert('Bitte geben Sie ein Datum ein.');
return;
}
}
if (step === 4) {
kalliBuildSummary();
}
kalliGoToStep(step);
}
function kalliPrevStep(step) {
kalliGoToStep(step);
}
function kalliBuildSummary() {
var grp = document.querySelector('#kalliForm input[name="group"]:checked');
var grpVal = grp ? grp.value : '–';
if ((grpVal === 'oeffentlich' || grpVal === 'intern') && document.getElementById('kalliUserInput')) {
var custom = document.getElementById('kalliUserInput').value.trim();
grpVal = custom ? custom + ' (' + (grpVal === 'oeffentlich' ? 'Öffentlich' : 'Intern') + ')' : grpVal;
}
document.getElementById('sumGroup').textContent = grpVal;
var dateS = document.getElementById('kalliDateS').value;
document.getElementById('sumDateS').textContent = dateS ? kalliFormatDate(dateS) : '–';
var dateE = document.getElementById('kalliDateE').value;
kalliShowSummaryRow('sumDateERow', dateE, 'sumDateE', kalliFormatDate(dateE));
var timeS = document.getElementById('kalliTimeS').value;
kalliShowSummaryRow('sumTimeSRow', timeS, 'sumTimeS', timeS);
var timeE = document.getElementById('kalliTimeE').value;
kalliShowSummaryRow('sumTimeERow', timeE, 'sumTimeE', timeE);
var text = document.getElementById('kalliFurtherText').value.trim();
kalliShowSummaryRow('sumTextRow', text, 'sumText', text);
var loc = document.getElementById('kalliLocation').value.trim();
kalliShowSummaryRow('sumLocRow', loc, 'sumLoc', loc);
var resp = document.getElementById('kalliResponsibility').value.trim();
kalliShowSummaryRow('sumRespRow', resp, 'sumResp', resp);
}
function kalliShowSummaryRow(rowId, value, valId, displayVal) {
var row = document.getElementById(rowId);
if (value) {
row.style.display = '';
document.getElementById(valId).textContent = displayVal;
} else {
row.style.display = 'none';
}
}
function kalliFormatDate(iso) {
if (!iso) return '–';
var parts = iso.split('-');
if (parts.length === 3) return parts[2] + '.' + parts[1] + '.' + parts[0];
return iso;
}
</script>
@@ -0,0 +1,405 @@
<?php
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
const KALLI_CKAL_HEADERS = [
'ID',
'Lang-ID',
'Termin-Typ',
'Von-Datum',
'Von-Zeit',
'Bis-Datum',
'Bis-Zeit',
'Zeitzusatz',
'Titel',
'Text',
'Ort',
'Planungsstatus',
'Anzeigeziel',
'Gruppenzuordnung',
'Zyklusart',
'Zyklusintervall',
'Zyklusdetail',
'Notiz',
'Verantwortlich',
'Hintergrundfarbe',
'BucherID',
'Benutzerdefinierte Felder',
'Dokument-Titel',
'Anzeigeziel Ort',
'Anzeigeziel Notiz',
'Anzeigeziel Verantwortlicher',
'Erzeugt',
'Aktualisiert',
];
const KALLI_CUSTOM_PUBLIC = 'oeffentlich';
const KALLI_CUSTOM_INTERNAL = 'intern';
function kalliHtml($value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}
function kalliPermissionEnabled($value): bool
{
return $value === true || $value === 1 || $value === '1' || $value === 't' || $value === 'true';
}
function kalliCsrfToken(): string
{
if (empty($_SESSION['kalli_csrf'])) {
$_SESSION['kalli_csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['kalli_csrf'];
}
function kalliCsrfIsValid($postedToken): bool
{
$sessionToken = $_SESSION['kalli_csrf'] ?? '';
return is_string($postedToken)
&& is_string($sessionToken)
&& $sessionToken !== ''
&& hash_equals($sessionToken, $postedToken);
}
/**
* Lädt Kategorien und Terminarten in einer Abfrage. Die Berechtigungsfilterung
* erfolgt anschließend zentral in kalliBuildCatalog().
*/
function kalliFetchCatalogRows(PDO $pdo): array
{
$sql = <<<'SQL'
SELECT
c.id AS category_id,
c.name AS category_name,
c.permission_role,
t.id AS type_id,
t.name AS type_name,
t.display_label,
t.planungsstatus,
t.anzeigeziel,
t.gruppenzuordnung,
t.anzeigeziel_ort,
t.anzeigeziel_notiz,
t.anzeigeziel_verantwortlicher,
t.default_text
FROM kalli_categories c
LEFT JOIN kalli_appointment_types t ON t.category_id = c.id
ORDER BY c.sort_order, c.name, t.sort_order, t.name
SQL;
return $pdo->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;
}
@@ -1,79 +0,0 @@
<?php
header('Content-Type: application/json; charset=utf-8');
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
try {
if (!isset($_SESSION['user_id'])) {
http_response_code(401);
echo json_encode(['status' => '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);
}
@@ -4,6 +4,7 @@
?>
<?php
require_once('util/seelsorge_helpers.php');
require_once('util/connector_feiertage.php');
$connector_feiertage = lib_Feiertage_Connector::getInstance();
@@ -16,9 +17,3 @@
</div>
<?php include "content/calendar_help.php"; ?>
<?php
// Profiloverlay für Kalenderbereich
$isPageCalendar = true;
include "content/profile.php";
?>
@@ -1,98 +1,113 @@
<?php
/* Name: content/calendar_day.php
Beschreibung: AJAX-Endpoint: Liefert Inhalt fuer das Tages-Modal
Aufgerufen per fetch() aus seelsorge.js */
/* AJAX-Fragment für Tagesdetails und Änderungen der eigenen Verfügbarkeit. */
// CWD auf seelsorge/ setzen (Connector nutzt relative Pfade wie data/)
chdir(dirname(__DIR__));
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/initialise.php';
require_once __DIR__ . '/../util/seelsorge_helpers.php';
require_once __DIR__ . '/../util/connector_feiertage.php';
require_once __DIR__ . '/../util/connector_kalendertage.php';
// Initialisierung
require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/initialise.php';
$selectedDate = trim((string) ($_POST['date'] ?? $_GET['date'] ?? ''));
$dateObject = DateTime::createFromFormat('!Y-m-d', $selectedDate);
if (!$dateObject || $dateObject->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 '<script type="application/json" id="modal-meta">';
echo json_encode([
'title' => $p_dateTitle,
'kw' => $p_kw,
'feiertag' => $feiertagsname,
'selfAvailable' => $selfAvailable,
'date' => $p_selectedDate
], JSON_UNESCAPED_UNICODE);
echo '</script>';
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());
}
}
?>
<script type="application/json" id="modal-meta"><?= json_encode([
'title' => $dateTitle,
'kw' => $dateObject->format('W'),
'feiertag' => $holidayName,
'selfAvailable' => $selfAvailable,
'date' => $selectedDate,
'availCount' => count($availabilityRows),
], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) ?></script>
<!-- Tagesinformationen -->
<?php if ($holidayName): ?>
<div class="seelsorge-modal-section">
<?php if ($feiertagsname): ?>
<div class="seelsorge-modal-field">
<div class="seelsorge-modal-field-label">Feiertag</div>
<div class="seelsorge-modal-field-value"><?= htmlspecialchars($feiertagsname) ?></div>
<div class="seelsorge-modal-field-value"><?= htmlspecialchars($holidayName) ?></div>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Verfuegbare Diakon*innen -->
<div class="seelsorge-modal-section">
<div class="seelsorge-section-title">Verf&uuml;gbare Personen</div>
<?php if (count($availableUsers) > 0): ?>
<?php if ($availableUsers): ?>
<div class="diakon-grid" id="modal-galery">
<?php foreach ($availableUsers as $userId => $name):
$profileImg = glob($_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/profile_pictures/' . $userId . '-*.jpg');
if (!empty($profileImg)) {
usort($profileImg, function($a, $b) { return filemtime($b) - filemtime($a); });
$imgPath = str_replace($_SERVER['DOCUMENT_ROOT'], '', $profileImg[0]);
} else {
$imgPath = '/intranet_repo/general_files/profile_pictures/default.jpg';
}
?>
<div class="diakon-card" onclick="openCalendarProfile(<?= (int)$userId ?>)" title="Profil anzeigen">
<img class="diakon-card-img" src="<?= htmlspecialchars($imgPath) ?>" alt="<?= htmlspecialchars($name) ?>">
<?php foreach ($availableUsers as $userId => $name): ?>
<div class="diakon-card" onclick="openCalendarProfile(<?= $userId ?>)" title="Profil anzeigen">
<img class="diakon-card-img" src="<?= htmlspecialchars(seelsorge_profile_image_url($userId)) ?>" alt="<?= htmlspecialchars($name) ?>">
<div class="diakon-card-info">
<div class="diakon-card-name"><?= htmlspecialchars($name) ?></div>
<div class="diakon-card-role">Verfügbar</div>
@@ -108,17 +123,11 @@ if (isset($_POST['calendarDate'])):
<?php endif; ?>
</div>
<!-- Eintragen / Austragen -->
<div class="seelsorge-modal-actions">
<button type="button"
class="seelsorge-action-button <?= $selfAvailable ? 'seelsorge-remove-button' : 'seelsorge-add-button' ?>"
id="modal-toggle-btn"
onclick="toggleAvailabilityModal('<?= $p_selectedDate ?>')">
onclick="toggleAvailabilityModal('<?= $selectedDate ?>')">
<?= $selfAvailable ? 'Verf&uuml;gbarkeit entfernen' : 'Als verf&uuml;gbar eintragen' ?>
</button>
</div>
<?php
$pdo = null;
endif;
?>
@@ -1,138 +0,0 @@
<?php /* Name: content/calendar_edit.php
Beschreibung: Kalenderblatt zum Bearbeiten der Verfügbarkeit des jeweiligen Users
Aufgerufen über calendar.php
*/
?>
<div class="calendar_title_modus">Bearbeitung der eigenen Verfügbarkeit</div>
<div id="toggleViewEdit" class="button tooltip borderradius_round" onclick="toggleViewEdit()">
<?php display_button('calendar', null, 'right'); ?>
<span class="tooltiptext">Kalender anzeigen</span>
</div>
<?php
// 2 Kalendermonate laden
for ($t = 1; $t <= 2; $t++) {
// Startdatum und Stile festlegen
$p_startDate = date("Y-m-d", strtotime($t === 1 ? "first day of this month" : "first day of next month"));
$p_style = $t === 1 ? "display:flex" : "display:none";
$p_class_arrow_left = $t === 1 ? "arrow_light" : "arrow_dark";
$p_class_arrow_right = $t === 1 ? "arrow_dark" : "arrow_light";
// Monatsnamen und Titel holen
$p_calendarMonthName = $connector_kalendertage->get_KalenderMonatsname($p_startDate);
$p_calendarTitle = $p_calendarMonthName . " " . date("Y", strtotime($p_startDate));
// Kalenderkopf ausgeben
?>
<div class="calendar_header" style="<?= $p_style ?>" id="calendar_edit_header_<?= $t ?>">
<div class="arrow_item" onclick="switchCalendarEdit('left')">
<div class="<?= $p_class_arrow_left ?>" id="left">
<?php display_button('arrow_left', null, 'left'); ?>
</div>
</div>
<div class="calendar_title"><?= $p_calendarTitle ?></div>
<div class="arrow_item" onclick="switchCalendarEdit('right')">
<div class="<?= $p_class_arrow_right ?>" id="right">
<?php display_button('arrow_right', null, 'right'); ?>
</div>
</div>
</div>
<?php
}
?>
<div class="calendar_content">
<table>
<thead>
<tr>
<th class="calendar_head">MO</th>
<th class="calendar_head">DI</th>
<th class="calendar_head">MI</th>
<th class="calendar_head">DO</th>
<th class="calendar_head">FR</th>
<th class="calendar_head_sat">SA</th>
<th class="calendar_head_sun">SO</th>
</tr>
</thead>
<tbody>
<?php
for ($c = 1; $c <= 2; $c++) { // Zwei Monatsansichten laden
$p_startDate = $c === 1 ? date("Y-m-d", strtotime("first day of this month")) : date("Y-m-d", strtotime("first day of next month"));
$p_endDate = $c === 1 ? date("Y-m-d", strtotime("last day of this month")) : date("Y-m-d", strtotime("last day of next month"));
$p_style = $c === 1 ? "visibility:visible" : "visibility:collapse";
$p_calendarStartDate = $connector_kalendertage->get_KalenderStart($p_startDate);
// Kalender: 6 Wochen in Zeilen
for ($i = 0; $i < 42; $i++) {
$datum = date('Y-m-d', strtotime("$p_calendarStartDate +$i days"));
$tag = date('j', strtotime("$p_calendarStartDate +$i days"));
$wochentag = date("w", strtotime("$p_calendarStartDate +$i days"));
$wochennr = (int)date("W o", strtotime("$p_calendarStartDate +$i days"));
$isFeiertagInBerlin = $connector_feiertage->isFeiertagInBerlin($datum); // Nur Berlin
$feiertagsname = $connector_feiertage->get_feiertagsname($datum); // (alle Bundesländer)
if ($i % 7 === 0) { // Beginn der Tabellenzeile
echo "<tr name='calendar_edit_$c' style='$p_style'>";
}
// Sichtbarkeit
if ($datum < date("Y-m-d")) { // vor Heute - nicht anklickbar
echo "<td class='calendar_day_area' style='filter:brightness(60%);opacity:0.3;'>";
echo "<div class='calendar_day'>$tag</div>";
} elseif ($datum < $p_startDate || $datum > $p_endDate) { // außerhalb des Monats
echo "<td class='calendar_day_area' style='filter:brightness(100%);opacity:0.5;'>";
echo "<div class='calendar_day'>$tag</div>";
} else {
echo "<td class='calendar_day_area'>";
echo "<div class='calendar_day'>";
// Verfügbarkeit des Users nur für zukünftige Tage
$sql = "SELECT COUNT(datum) AS count FROM verfuegbarkeiten WHERE datum=? and user_id=?";
$stmt = $pdo->prepare($sql);
if ($stmt) {
$stmt->execute([$datum, $_SESSION["user_id"]]);
$row = $stmt->fetch();
if ($row) {
$count = $row['count'];
} else {
echo "Error retrieving result";
}
} else {
echo "Error preparing statement";
}
// Falls das Datum heute oder in der Zukunft liegt
$Id_cDayItem = "id='calendar_day_item'";
if ($count > 0) { // User ist schon eingetragen
echo "<div $Id_cDayItem class='calendar_day_item availableday editmode' title='Verfügbar' onclick='toggleAvailability(\"$datum\", this)'>";
} else { // User ist noch nicht eingetragen
echo "<div $Id_cDayItem class='calendar_day_item normalday editmode' onclick='toggleAvailability(\"$datum\", this)'>";
}
echo $tag;
if ($feiertagsname != null) {
echo "<div class='feiertagsname'>$feiertagsname</div>";
}
echo "</div>"; // END: calendar_day
}
echo "</td>"; // END: calendar_day_area
if ($i % 7 === 6) { // Ende der Tabellenzeile
echo "</tr>";
}
// Reset der Variablen
$feiertagsname = null;
}
} // Schleifenende: Zwei Monatsansichten laden
?>
</tbody>
</table> <!-- END: calendar -->
</div> <!-- END: calendar_content -->
@@ -59,7 +59,7 @@
<div class="seelsorge-section-title">Benachrichtigungen</div>
<p style="font-size:0.9em;line-height:1.6;color:#374151;margin:0;">
Sobald sich zwei oder mehr Personen am selben Tag eintragen, werden alle
Beteiligten automatisch per SMS und E-Mail benachrichtigt.
Beteiligten automatisch per E-Mail benachrichtigt.
</p>
</div>
</div>
@@ -1,139 +1,115 @@
<?php /* Name: content/calendar_view.php
Beschreibung: Kalenderblatt mit Übersicht und Interaktion per Modal
Aufgerufen über calendar.php */
?>
<?php
// 2 Kalendermonate laden
for ($t = 1; $t <= 2; $t++) {
$p_startDate = date("Y-m-d", strtotime($t === 1 ? "first day of this month" : "first day of next month"));
$p_style = $t === 1 ? "display:flex" : "display:none";
$p_class_arrow_left = $t === 1 ? "arrow_light" : "arrow_dark";
$p_class_arrow_right = $t === 1 ? "arrow_dark" : "arrow_light";
$p_calendarMonthName = $connector_kalendertage->get_KalenderMonatsname($p_startDate);
$p_calendarTitle = $p_calendarMonthName . " " . date("Y", strtotime($p_startDate));
?>
/* Kalenderblatt mit Übersicht und Interaktion per Modal. */
<div class="calendar_header" style="<?= $p_style ?>" id="calendar_header_<?= $t ?>">
$today = date('Y-m-d');
$monthStarts = [
1 => date('Y-m-d', strtotime('first day of this month')),
2 => date('Y-m-d', strtotime('first day of next month')),
];
$monthEnds = [
1 => date('Y-m-d', strtotime('last day of this month')),
2 => date('Y-m-d', strtotime('last day of next month')),
];
$calendarStarts = [
1 => $connector_kalendertage->get_KalenderStart($monthStarts[1]),
2 => $connector_kalendertage->get_KalenderStart($monthStarts[2]),
];
$availabilityEnd = date('Y-m-d', strtotime($calendarStarts[2] . ' +41 days'));
$availability = seelsorge_calendar_availability_map($pdo, $calendarStarts[1], $availabilityEnd, (int) $_SESSION['user_id']);
for ($month = 1; $month <= 2; $month++):
$startDate = $monthStarts[$month];
$isFirst = $month === 1;
$title = $connector_kalendertage->get_KalenderMonatsname($startDate) . ' ' . date('Y', strtotime($startDate));
?>
<div class="calendar_header" style="display:<?= $isFirst ? 'flex' : 'none' ?>" id="calendar_header_<?= $month ?>">
<div class="arrow_item" onclick="switchCalendar('left')">
<div class="<?= $p_class_arrow_left ?>" id="left">
<div class="<?= $isFirst ? 'arrow_light' : 'arrow_dark' ?>" id="left">
<?php display_button('arrow_left', null, 'left'); ?>
</div>
</div>
<div class="calendar_title"><?= $p_calendarTitle ?></div>
<div class="calendar_title"><?= htmlspecialchars($title) ?></div>
<div class="arrow_item" onclick="switchCalendar('right')">
<div class="<?= $p_class_arrow_right ?>" id="right">
<div class="<?= $isFirst ? 'arrow_dark' : 'arrow_light' ?>" id="right">
<?php display_button('arrow_right', null, 'right'); ?>
</div>
</div>
<button class="calendar-help-btn" onclick="openCalendarHelp()" aria-label="Hilfe" title="Hilfe">?</button>
</div>
<?php } ?>
<?php endfor; ?>
<div class="calendar_content">
<table>
<thead>
<tr>
<td class="calendar_head">MO</td>
<td class="calendar_head">DI</td>
<td class="calendar_head">MI</td>
<td class="calendar_head">DO</td>
<td class="calendar_head">FR</td>
<td class="calendar_head_sat">SA</td>
<td class="calendar_head_sun">SO</td>
</tr>
</thead>
<thead><tr>
<td class="calendar_head">MO</td><td class="calendar_head">DI</td><td class="calendar_head">MI</td>
<td class="calendar_head">DO</td><td class="calendar_head">FR</td>
<td class="calendar_head_sat">SA</td><td class="calendar_head_sun">SO</td>
</tr></thead>
<tbody>
<?php for ($calendar = 1; $calendar <= 2; $calendar++):
$startDate = $monthStarts[$calendar];
$endDate = $monthEnds[$calendar];
$calendarStart = $calendarStarts[$calendar];
$rowStyle = $calendar === 1 ? 'visibility:visible' : 'visibility:collapse';
<?php
// Zwei Monatsansichten laden
for ($c = 1; $c <= 2; $c++) {
$p_startDate = date("Y-m-d", strtotime($c === 1 ? "first day of this month" : "first day of next month"));
$p_endDate = date("Y-m-d", strtotime($c === 1 ? "last day of this month" : "last day of next month"));
$p_style = $c === 1 ? "visibility:visible" : "visibility:collapse";
$p_calendarStartDate = $connector_kalendertage->get_KalenderStart($p_startDate);
for ($i = 0; $i < 42; $i++):
$date = date('Y-m-d', strtotime($calendarStart . " +{$i} days"));
$day = date('j', strtotime($date));
$weekday = (int) date('w', strtotime($date));
$isBerlinHoliday = $connector_feiertage->isFeiertagInBerlin($date);
$holidayName = $connector_feiertage->get_feiertagsname($date);
$dayAvailability = $availability[$date] ?? ['count' => 0, 'self' => false];
$count = $dayAvailability['count'];
$userAvailable = $dayAvailability['self'];
//Kalender: 6 Wochen in Zeilen
for ($i = 0; $i < 42; $i++) {
$datum = date('Y-m-d', strtotime("$p_calendarStartDate +$i days"));
$tag = date('j', strtotime("$p_calendarStartDate +$i days"));
$wochentag = date("w", strtotime("$p_calendarStartDate +$i days"));
$isFeiertagInBerlin = $connector_feiertage->isFeiertagInBerlin($datum);
$feiertagsname = $connector_feiertage->get_feiertagsname($datum);
if ($i % 7 === 0): ?>
<tr name="calendar_<?= $calendar ?>" style="<?= $rowStyle ?>">
<?php endif;
if ($i % 7 === 0) echo "<tr name='calendar_$c' style='$p_style'>";
$class = 'calendar_day_area';
$style = '';
if ($datum < $p_startDate || $datum > $p_endDate) {
$style = 'filter:brightness(100%);opacity:0.5;';
}
echo "<td class='$class' style='$style'><div class='calendar_day'>";
$Id_cDayItem = $datum >= date("Y-m-d") ? 'id="calendar_day_item" onclick="openDayModal(\'' . $datum . '\')"' : '';
$sql_count = "SELECT COUNT(datum) AS count FROM verfuegbarkeiten WHERE datum=?";
$stmt_count = $pdo->prepare($sql_count);
$count = 0;
if ($stmt_count) {
$stmt_count->execute([$datum]);
$row_count = $stmt_count->fetch();
if ($row_count) {
$count = $row_count['count'];
} else {
echo "Fehler beim Abrufen des Ergebnisses";
}
} else {
echo "Fehler beim Vorbereiten der Anweisung";
}
$sql_user_check = "SELECT user_id FROM verfuegbarkeiten WHERE datum=? AND user_id=?";
$stmt_user_check = $pdo->prepare($sql_user_check);
$userAvailable = false;
if ($stmt_user_check) {
$stmt_user_check->execute([$datum, $_SESSION["user_id"]]);
if ($stmt_user_check->fetch()) {
$userAvailable = true;
}
}
$class = 'calendar_day_item';
$outsideMonth = $date < $startDate || $date > $endDate;
$classes = ['calendar_day_item'];
$title = '';
if ($datum < date("Y-m-d")) {
$class .= ' past';
} elseif ($count >= 2 && $datum >= date("Y-m-d")) {
$class .= ' available_multiple';
$title = $userAvailable ? "It&acute;s a Match!" : "$count Personen verfügbar";
} elseif ($count == 1 && $datum >= date("Y-m-d")) {
$class .= ' available_single';
$title = 'Eine Person verfügbar';
} elseif ($datum == date("Y-m-d")) {
$class .= ' today';
$title = 'Heute';
} elseif ($isFeiertagInBerlin) {
$class .= ' sunday';
} elseif ($feiertagsname != null) {
$class .= ' localholiday';
$title = 'Regionaler Feiertag';
} elseif ($wochentag == 0) {
$class .= ' sunday';
} elseif ($wochentag == 6) {
$class .= ' saturday';
if ($date < $today) {
$emptyClass = 'past';
} elseif ($date === $today) {
$emptyClass = 'today';
} elseif ($isBerlinHoliday) {
$emptyClass = 'sunday';
} elseif ($holidayName !== null) {
$emptyClass = 'localholiday';
} elseif ($weekday === 0) {
$emptyClass = 'sunday';
} elseif ($weekday === 6) {
$emptyClass = 'saturday';
} else {
$class .= ' normalday';
$emptyClass = 'normalday';
}
echo "<div $Id_cDayItem class='$class' title='$title'>$tag</div>";
// Feiertagsbezeichnungen im Kalenderblatt ausgeblendet
echo "</div></td>";
if ($i % 7 === 6) echo "</tr>";
}
}
//Schleifenende: Zwei Monatsansichten laden
?>
if ($date < $today) {
$classes[] = 'past';
} elseif ($count >= 2) {
$classes[] = 'available_multiple';
$title = $userAvailable ? "It's a Match!" : "{$count} Personen verfügbar";
} elseif ($count === 1) {
$classes[] = 'available_single';
$title = 'Eine Person verfügbar';
} else {
$classes[] = $emptyClass;
if ($emptyClass === 'today') $title = 'Heute';
if ($emptyClass === 'localholiday') $title = 'Regionaler Feiertag';
}
?>
<td class="calendar_day_area"<?= $outsideMonth ? ' style="filter:brightness(100%);opacity:0.5;"' : '' ?>>
<div class="calendar_day">
<div class="<?= implode(' ', $classes) ?>"
<?= $date >= $today ? 'data-calendar-date="' . $date . '" data-empty-class="' . $emptyClass . '" onclick="openDayModal(\'' . $date . '\')"' : '' ?>
title="<?= htmlspecialchars($title, ENT_QUOTES) ?>"><?= $day ?></div>
</div>
</td>
<?php if ($i % 7 === 6): ?></tr><?php endif; ?>
<?php endfor;
endfor; ?>
</tbody>
</table> <!-- END: calendar -->
</div> <!-- END: calendar_content -->
</table>
</div>
@@ -1,28 +0,0 @@
<?php
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
header('Content-Type: application/json; charset=utf-8');
if (!isset($_SESSION['user_id'])) {
http_response_code(401);
echo json_encode(['status' => 'error', 'message' => 'Nicht angemeldet.']);
exit;
}
$pdo = require($_SERVER['DOCUMENT_ROOT']."/intranet_repo/general_files/ini/database.php");
$besuch_id = isset($_POST['besuch_id']) ? (int)$_POST['besuch_id'] : 0;
if ($besuch_id <= 0) {
echo json_encode(['status' => 'error', 'message' => 'Ungültige Besuchs-ID.']);
exit;
}
try {
$stmt = $pdo->prepare("DELETE FROM seelsorge_besuchsstellen WHERE besuch_id = ?");
$stmt->execute([$besuch_id]);
echo json_encode(['status' => 'success']);
} catch (PDOException $e) {
echo json_encode(['status' => 'error', 'message' => 'Löschen fehlgeschlagen: ' . $e->getMessage()]);
}
@@ -1,161 +0,0 @@
<?php
/* Name: manage_visits.php
Beschreibung: Verwaltung der Seelsorge-Besuchsstellen
- Hinzufügen, Bearbeiten und Löschen von Einträgen
*/
// Initialisierung
require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/initialise.php';
// Eintrag hinzufügen
if ($_SERVER["REQUEST_METHOD"] == "POST" && isset($_POST['add_entry'])) {
$last_name = $_POST['last_name'];
$first_name = $_POST['first_name'];
$birth_date = $_POST['birth_date'];
$address_street = $_POST['address_street'];
$address_plz = $_POST['address_plz'];
$address_link = $_POST['address_link'];
$phone = $_POST['phone'];
$comment = $_POST['comment'];
$letzter_besuch = $_POST['letzter_besuch'];
$priority = $_POST['priority'];
$pr_bezirk = $_POST['pr_bezirk'];
$reservation = $_POST['reservation'];
$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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $pdo->prepare($sql);
if ($stmt->execute([$last_name, $first_name, $birth_date, $address_street, $address_plz, $address_link, $phone, $comment, $letzter_besuch, $priority, $pr_bezirk, $reservation])) {
echo "Neuer Eintrag hinzugefügt.";
} else {
echo "Fehler: " . implode(', ', $stmt->errorInfo());
}
}
// Eintrag löschen
if (isset($_GET['delete_id'])) {
$delete_id = $_GET['delete_id'];
$sql = "DELETE FROM seelsorge_besuchsstellen WHERE besuch_id = ?";
$stmt = $pdo->prepare($sql);
if ($stmt->execute([$delete_id])) {
echo "Eintrag gelöscht.";
} else {
echo "Fehler beim Löschen des Eintrags: " . implode(', ', $stmt->errorInfo());
}
}
// Daten abrufen
$sql = "SELECT * FROM seelsorge_besuchsstellen";
$result = $pdo->query($sql);
?>
<!DOCTYPE html>
<html lang="de" dir="ltr">
<head>
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/html_head.php'; ?>
<script src="/intranet_repo/projects/_central/scripts/seelsorge.js"></script>
<link rel="stylesheet" href="/intranet_repo/projects/_central/layouts/seelsorge.css">
</head>
<body class="theme-purple">
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/header.php'; ?>
<div class="header_title ht_pastoral text_white" id="header_title">
<span class="header_title_text">Besuchsstellen Verwaltung</span>
</div>
<h1>Seelsorge Besuchsstellen Verwaltung</h1>
<!-- Formular zum Hinzufügen eines neuen Eintrags -->
<h2>Neuen Eintrag hinzufügen</h2>
<form method="POST" action="">
<label>Nachname:</label>
<input type="text" name="last_name" required><br>
<label>Vorname:</label>
<input type="text" name="first_name" required><br>
<label>Geburtsdatum:</label>
<input type="date" name="birth_date" required><br>
<label>Straße:</label>
<input type="text" name="address_street" required><br>
<label>PLZ:</label>
<input type="text" name="address_plz" required><br>
<label>Link zur Adresse:</label>
<input type="text" name="address_link"><br>
<label>Telefonnummer:</label>
<input type="number" name="phone"><br>
<label>Kommentar:</label>
<input type="text" name="comment"><br>
<label>Letzter Besuch:</label>
<input type="date" name="letzter_besuch"><br>
<label>Priorität:</label>
<input type="number" name="priority" required><br>
<label>Bezirk:</label>
<input type="text" name="pr_bezirk"><br>
<label>Reservierung:</label>
<input type="number" name="reservation"><br>
<input type="submit" name="add_entry" value="Eintrag hinzufügen">
</form>
<!-- Tabelle der vorhandenen Einträge -->
<h2>Vorhandene Einträge</h2>
<table border="1">
<tr>
<th>ID</th>
<th>Nachname</th>
<th>Vorname</th>
<th>Geburtsdatum</th>
<th>Adresse</th>
<th>PLZ</th>
<th>Link zur Adresse</th>
<th>Telefonnummer</th>
<th>Kommentar</th>
<th>Letzter Besuch</th>
<th>Priorität</th>
<th>Bezirk</th>
<th>Reservierung</th>
<th>Aktionen</th>
</tr>
<?php while ($row = $result->fetch()) { ?>
<tr>
<td><?php echo $row['besuch_id']; ?></td>
<td><?php echo $row['last_name']; ?></td>
<td><?php echo $row['first_name']; ?></td>
<td><?php echo $row['birth_date']; ?></td>
<td><?php echo $row['address_street']; ?></td>
<td><?php echo $row['address_plz']; ?></td>
<td><?php echo $row['address_link']; ?></td>
<td><?php echo $row['phone']; ?></td>
<td><?php echo $row['comment']; ?></td>
<td><?php echo $row['letzter_besuch']; ?></td>
<td><?php echo $row['priority']; ?></td>
<td><?php echo $row['pr_bezirk']; ?></td>
<td><?php echo $row['reservation']; ?></td>
<td>
<a href="manage_visits.php?delete_id=<?php echo $row['besuch_id']; ?>" onclick="return confirm('Eintrag wirklich löschen?');">Löschen</a>
</td>
</tr>
<?php } ?>
</table>
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/footer.php'; ?>
</body>
</html>
<?php
$pdo = null;
?>
@@ -1,13 +0,0 @@
<?php /* Name: menue.php
Beschreibung: Seitenmenü des Seelsorgeplaners */
?>
<div class="menue" id="seelsorge">
<div class="list">
<div class="menu-item unselectable active" data-target="content_calendar">Kalendarium</div>
<div class="menu-item unselectable" data-target="content_visit">Besuchsstellen</div>
<div class="indicator" id="indicator"></div>
</div>
</div>
@@ -1,33 +0,0 @@
<?php
$pdo = require($_SERVER['DOCUMENT_ROOT']."/intranet_repo/general_files/ini/database.php");
// Get the besuch_id from the GET request
$besuch_id = $_GET['besuch_id'];
// SQL query to update priority to 2
$stmt = $pdo->prepare("UPDATE seelsorge_besuchsstellen SET priority = 2 WHERE besuch_id = ?");
$stmt->execute([$besuch_id]);
if ($stmt->rowCount() >= 0) {
// Berechne die Monate seit dem letzten Besuch, um sie in der Antwort zurückzugeben
$stmtQ = $pdo->prepare("SELECT CURRENT_DATE - letzter_besuch AS days_since_last_visit FROM seelsorge_besuchsstellen WHERE besuch_id = ?");
$stmtQ->execute([$besuch_id]);
$row = $stmtQ->fetch();
$monthsSinceLastVisit = floor($row['days_since_last_visit'] / 30);
// Gib die aktualisierten Daten als JSON zurück
echo json_encode([
'status' => 'success',
'priority' => 2,
'months_since_last_visit' => $monthsSinceLastVisit
]);
} else {
// Fehlerbehandlung
echo json_encode([
'status' => 'error',
'message' => "Fehler beim Setzen der Priorität"
]);
}
$pdo = null;
?>
@@ -1,38 +1,62 @@
<?php
/*
Name: content/profile.php
Beschreibung: Popup innerhalb von Kalendertag bzw. Besuchsstellen: Profilanzeige
Zur Verwendung ist die nachfolgende Unterscheidung zu treffen/erweitern
*/
/* Profil-Overlay für Kalender und Besuchsstellen. */
require_once dirname(__DIR__) . '/util/seelsorge_helpers.php';
// Unterscheidung zwischen den aufrufenden Seitenbereichen
// --> Name ist außerdem beim Aufruf der JS-Funktion openCloseProfile(id, name, overlay) zu bilden!
// --> Bei einem neuen Seitenbereich ist zusätzlich der Listener (Escape-Taste) zu erweitern!
// --> In overlay_profile.php wird bei Escape-Taste bzw. Klick auf Exit mit id=0 sowie den entsprechenden name und overlay weggeblendet!
if (($isPageVisit ?? false) === true) {
$profile_container_name = "profile_visit";
$profile_overlay_id = "profile_visit_overlay";
$isPageVisit = false;
} elseif (($isPageCalendar ?? false) === true) {
$profile_container_name = "profile_calendar";
$profile_overlay_id = "profile_calendar_overlay";
$isPageCalendar = false;
}
$profile_container_name = 'profile_seelsorge';
$profile_overlay_id = 'profile_seelsorge_overlay';
// Statement für alle Profile vorbereiten
$stmt_all_profiles = $pdo->prepare("SELECT * FROM user_general_information");
$stmt_all_profiles->execute();
$profileRecords = seelsorge_profile_records($pdo);
?>
<div class="standard_overlay" id="<?= $profile_overlay_id ?>"> <!-- Overlay: Profile -->
<div class="bg_blur"></div>
<?php
while ($profile_all = $stmt_all_profiles->fetch()) {
?>
<div class="profile_container" name="<?= $profile_container_name ?>" id="<?= $profile_all['user_id'] ?>">
<?php $_SESSION["profile_id"] = $profile_all['user_id'];
include $g_phpSnippetsPath. "overlay_profile.php"; ?>
</div>
<?php } ?>
</div>
<div class="standard_overlay" id="<?= $profile_overlay_id ?>">
<div class="bg_blur"></div>
<?php foreach ($profileRecords as $record):
$partner = $record['user'];
$userId = (int) $partner['user_id'];
$fullName = trim(($partner['first_name'] ?? '') . ' ' . ($partner['last_name'] ?? ''));
$isRegistered = !empty($partner['first_name']);
$groups = $record['permissions'];
?>
<div class="profile_container" name="<?= $profile_container_name ?>" id="<?= $userId ?>">
<div class="profile-modal-card">
<button type="button" class="exit" aria-label="Schließen" onclick="openCloseProfile(0, '<?= $profile_container_name ?>', '<?= $profile_overlay_id ?>');">×</button>
<div class="profile-modal-hero">
<img class="profile-modal-img" src="<?= htmlspecialchars($record['image']) ?>" alt="Profilbild">
<div>
<?php if ($isRegistered): ?>
<div class="profile-modal-title"><?= htmlspecialchars($fullName) ?></div>
<?php else: ?>
<div class="profile-modal-title" style="color:rgba(255,255,255,0.7);">Nicht registriert</div>
<?php endif; ?>
<div class="profile-modal-subtitle">
<span><?= htmlspecialchars($partner['mail'] ?? '') ?></span>
</div>
</div>
</div>
<div class="profile-modal-body">
<?php if (!empty($partner['phone'])): ?>
<div class="profile-modal-field">
<div class="profile-modal-field-label">Telefon</div>
<div class="profile-modal-field-value">
<a href="tel:0<?= htmlspecialchars($partner['phone']) ?>">0<?= htmlspecialchars($partner['phone']) ?></a>
</div>
</div>
<?php endif; ?>
<?php $activeGroups = array_filter($groups, static fn($value, $key) => $key !== 'user_id' && (int) $value === 1, ARRAY_FILTER_USE_BOTH); ?>
<?php if ($activeGroups): ?>
<div class="profile-modal-field">
<div class="profile-modal-field-label">Gruppen</div>
<div class="profile-modal-chips">
<?php foreach (array_keys($activeGroups) as $groupName): ?>
<span class="profile-modal-chip"><?= htmlspecialchars($groupName) ?></span>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
@@ -1,48 +0,0 @@
<?php
session_start();
$pdo = require($_SERVER['DOCUMENT_ROOT']."/intranet_repo/general_files/ini/database.php");
// Hole die Besuchs-ID aus dem GET-Request
if (isset($_GET['besuch_id']) && is_numeric($_GET['besuch_id'])) {
$besuch_id = (int)$_GET['besuch_id'];
// Adresse laden für Haushalt-Tracking
$stmt = $pdo->prepare("SELECT address_street, address_plz FROM seelsorge_besuchsstellen WHERE besuch_id = ?");
$stmt->execute([$besuch_id]);
$current = $stmt->fetch();
if ($current) {
// Reservation für alle im Haushalt entfernen
$sql = "UPDATE seelsorge_besuchsstellen SET reservation = NULL WHERE address_street = ? AND address_plz = ?";
$stmt = $pdo->prepare($sql);
if ($stmt->execute([$current['address_street'], $current['address_plz']])) {
$stmtH = $pdo->prepare("SELECT besuch_id FROM seelsorge_besuchsstellen WHERE address_street = ? AND address_plz = ?");
$stmtH->execute([$current['address_street'], $current['address_plz']]);
$householdIds = [];
while ($r = $stmtH->fetch()) {
$householdIds[] = (int)$r['besuch_id'];
}
echo json_encode(['status' => 'success', 'household_ids' => $householdIds]);
} else {
echo json_encode([
'status' => 'error',
'message' => "Fehler beim Entfernen der Reservation"
]);
}
} else {
echo json_encode([
'status' => 'error',
'message' => 'Eintrag nicht gefunden.'
]);
}
} else {
echo json_encode([
'status' => 'error',
'message' => 'Ungültige oder fehlende Besuchs-ID.'
]);
}
$pdo = null;
?>
@@ -1,56 +0,0 @@
<?php
session_start();
$pdo = require($_SERVER['DOCUMENT_ROOT']."/intranet_repo/general_files/ini/database.php");
// Hole die Besuchs-ID aus dem GET-Request
if (isset($_GET['besuch_id']) && is_numeric($_GET['besuch_id'])) {
$besuch_id = (int)$_GET['besuch_id'];
// Hole die ID des aktuell angemeldeten Benutzers aus der Session
$user_id = $_SESSION['user_id'];
// Adresse laden für Haushalt-Tracking
$stmt = $pdo->prepare("SELECT address_street, address_plz FROM seelsorge_besuchsstellen WHERE besuch_id = ?");
$stmt->execute([$besuch_id]);
$current = $stmt->fetch();
if ($current) {
// Alle Personen im Haushalt reservieren
$sql = "UPDATE seelsorge_besuchsstellen SET reservation = ? WHERE address_street = ? AND address_plz = ?";
$stmt = $pdo->prepare($sql);
if ($stmt->execute([$user_id, $current['address_street'], $current['address_plz']])) {
// Alle betroffenen IDs zurückgeben
$stmtH = $pdo->prepare("SELECT besuch_id FROM seelsorge_besuchsstellen WHERE address_street = ? AND address_plz = ?");
$stmtH->execute([$current['address_street'], $current['address_plz']]);
$householdIds = [];
while ($r = $stmtH->fetch()) {
$householdIds[] = (int)$r['besuch_id'];
}
echo json_encode([
'status' => 'success',
'reservation_user_id' => $user_id,
'household_ids' => $householdIds
]);
} else {
echo json_encode([
'status' => 'error',
'message' => "Fehler beim Setzen der Reservation"
]);
}
} else {
echo json_encode([
'status' => 'error',
'message' => 'Eintrag nicht gefunden.'
]);
}
} else {
echo json_encode([
'status' => 'error',
'message' => 'Ungültige oder fehlende Besuchs-ID.'
]);
}
$pdo = null;
?>
@@ -1,24 +0,0 @@
<?php
$pdo = require($_SERVER['DOCUMENT_ROOT']."/intranet_repo/general_files/ini/database.php");
// Überprüfen, ob die erforderlichen Parameter übergeben wurden
if (isset($_GET['besuch_id']) && isset($_GET['comment'])) {
$besuch_id = $_GET['besuch_id'];
$comment = $_GET['comment'];
// SQL-Update-Statement vorbereiten und ausführen
$stmt = $pdo->prepare("UPDATE seelsorge_besuchsstellen SET comment = ? WHERE besuch_id = ?");
if ($stmt->execute([$comment, $besuch_id])) {
// Erfolgreich aktualisiert
echo "Hinweis erfolgreich aktualisiert";
} else {
// Fehler beim Aktualisieren
echo "Fehler beim Aktualisieren des Hinweises";
}
} else {
// Fehler: Fehlende Parameter
echo "Fehler: Nicht alle erforderlichen Parameter wurden übergeben";
}
$pdo = null;
?>
@@ -1,57 +0,0 @@
<?php
session_start();
$pdo = require($_SERVER['DOCUMENT_ROOT']."/intranet_repo/general_files/ini/database.php");
// Get the besuch_id from the GET request
$besuch_id = (int)($_GET['besuch_id'] ?? 0);
if ($besuch_id <= 0) {
echo json_encode(['status' => 'error', 'message' => 'Ungültige Besuchs-ID.']);
exit;
}
// Adresse der aktuellen Besuchsstelle laden
$stmt = $pdo->prepare("SELECT address_street, address_plz FROM seelsorge_besuchsstellen WHERE besuch_id = ?");
$stmt->execute([$besuch_id]);
$current = $stmt->fetch();
if (!$current) {
echo json_encode(['status' => 'error', 'message' => 'Eintrag nicht gefunden.']);
exit;
}
// Alle Personen im gleichen Haushalt (gleiche Adresse) aktualisieren
$sql = "UPDATE seelsorge_besuchsstellen SET letzter_besuch = CURRENT_DATE, priority = 0 WHERE address_street = ? AND address_plz = ?";
$stmt = $pdo->prepare($sql);
if ($stmt->execute([$current['address_street'], $current['address_plz']])) {
// Monate seit letztem Besuch berechnen (für den angefragten Eintrag)
$query = "SELECT CURRENT_DATE - letzter_besuch AS days_since_last_visit FROM seelsorge_besuchsstellen WHERE besuch_id = ?";
$stmtQ = $pdo->prepare($query);
$stmtQ->execute([$besuch_id]);
$row = $stmtQ->fetch();
$monthsSinceLastVisit = floor(($row['days_since_last_visit'] ?? 0) / 30);
// Alle IDs im Haushalt zurückgeben für Frontend-Aktualisierung
$stmtH = $pdo->prepare("SELECT besuch_id FROM seelsorge_besuchsstellen WHERE address_street = ? AND address_plz = ?");
$stmtH->execute([$current['address_street'], $current['address_plz']]);
$householdIds = [];
while ($r = $stmtH->fetch()) {
$householdIds[] = (int)$r['besuch_id'];
}
echo json_encode([
'status' => 'success',
'last_visit' => date('Y-m-d'),
'months_since_last_visit' => $monthsSinceLastVisit,
'household_ids' => $householdIds
]);
} else {
echo json_encode([
'status' => 'error',
'message' => "Fehler beim Aktualisieren des Besuchsdatums"
]);
}
$pdo = null;
?>
@@ -1,132 +0,0 @@
<?php
header('Content-Type: application/json; charset=utf-8');
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
try {
if (!isset($_SESSION['user_id'])) {
http_response_code(401);
echo json_encode(['status' => 'error', 'message' => 'Nicht angemeldet.'], JSON_UNESCAPED_UNICODE);
exit;
}
$pdo = require($_SERVER['DOCUMENT_ROOT']."/intranet_repo/general_files/ini/database.php");
$besuch_id = isset($_POST['besuch_id']) ? (int)$_POST['besuch_id'] : 0;
$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'] ?? '');
// einfache Validierung
if ($besuch_id <= 0 || $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
$phone = preg_replace('/\D+/', '', $phone_raw);
if ($phone === '') $phone = null; // phone darf NULL sein
// NOT NULL Spalten: 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 = '';
// Alten Eintrag laden, um Änderungen zu erkennen
$stmtOld = $pdo->prepare("SELECT last_name, first_name, birth_date, address_street, address_plz, address_link, phone, comment, letzter_besuch, priority, pr_bezirk FROM seelsorge_besuchsstellen WHERE besuch_id = ?");
$stmtOld->execute([$besuch_id]);
$oldEntry = $stmtOld->fetch();
$sql = "UPDATE seelsorge_besuchsstellen
SET last_name = ?, first_name = ?, birth_date = ?, address_street = ?, address_plz = ?,
address_link = ?, phone = ?, comment = ?, letzter_besuch = ?, priority = ?, pr_bezirk = ?
WHERE besuch_id = ?";
$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,
$besuch_id
]);
// Vermerk in erledigte Tasks wenn sich irgendein Feld geändert hat
if ($oldEntry) {
$newValues = [
'last_name' => $last_name,
'first_name' => $first_name,
'birth_date' => $birth_date,
'address_street' => $address_street,
'address_plz' => $address_plz,
'address_link' => $address_link,
'phone' => $phone,
'comment' => $comment,
'letzter_besuch' => $letzter_besuch,
'priority' => $priority,
'pr_bezirk' => $pr_bezirk_raw,
];
$fieldLabels = [
'last_name' => 'Nachname',
'first_name' => 'Vorname',
'birth_date' => 'Geburtsdatum',
'address_street' => 'Adresse',
'address_plz' => 'PLZ/Ort',
'address_link' => 'Karten-Link',
'phone' => 'Telefon',
'comment' => 'Kommentar',
'letzter_besuch' => 'Letzter Besuch',
'priority' => 'Priorität',
'pr_bezirk' => 'Priester-Bezirk',
];
$changedFields = [];
foreach ($newValues as $key => $newVal) {
$oldVal = $oldEntry[$key] ?? '';
if ((string)$oldVal !== (string)$newVal) {
$changedFields[] = $fieldLabels[$key];
}
}
if (!empty($changedFields)) {
$personName = ($oldEntry['last_name'] ?? $last_name) . ", " . ($oldEntry['first_name'] ?? $first_name);
$changedList = implode(', ', $changedFields);
$taskSubject = "Besuchsstelle geändert: {$personName}";
$taskMessage = "Besuchsstelle #{$besuch_id} ({$personName}) wurde geändert. Geänderte Felder: {$changedList}.";
$senderName = "Seelsorgeplaner";
$stmtLog = $pdo->prepare("INSERT INTO admin_messages (type, sender_name, sender_email, subject, message, status, processed_at, created_at) VALUES ('system', ?, '', ?, ?, 'done', NOW(), NOW())");
$stmtLog->execute([$senderName, $taskSubject, $taskMessage]);
}
}
echo json_encode(['status' => 'success', 'besuch_id' => $besuch_id], 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);
}
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,36 @@
// Initialisierung
require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/initialise.php';
require_once __DIR__ . '/util/seelsorge_helpers.php';
$seelsorgeCsrfToken = seelsorge_csrf_token();
$activeTab = ($_GET['tab'] ?? '') === 'visit' ? 'visit' : 'calendar';
$visitFormError = $_SESSION['seelsorge_visit_form_error'] ?? null;
unset($_SESSION['seelsorge_visit_form_error']);
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['visit_command'])) {
$isVisitSave = $_POST['visit_command'] === 'save';
$submittedVisitForm = $isVisitSave ? array_diff_key($_POST, ['visit_command' => true, 'csrf_token' => true]) : null;
try {
seelsorge_verify_csrf($_POST);
seelsorge_process_visit_command($pdo, $_POST, (int) $_SESSION['user_id']);
} catch (InvalidArgumentException | RuntimeException $e) {
$_SESSION['seelsorge_visit_form_error'] = [
'message' => $e->getMessage(),
'form' => $submittedVisitForm,
];
} catch (Throwable $e) {
error_log('Seelsorge visit command failed: ' . $e->getMessage());
$_SESSION['seelsorge_visit_form_error'] = [
'message' => 'Aktion fehlgeschlagen.',
'form' => $submittedVisitForm,
];
}
header('Location: /intranet_repo/projects/seelsorge/seelsorge.php?tab=visit', true, 303);
exit;
}
?>
<!DOCTYPE html>
<html lang="de" dir="ltr">
@@ -27,15 +57,15 @@
<div class="content" id="fullsize">
<div class="content-tab-bar" id="seelsorge-tabs">
<div class="content-tab-bar__item active" data-target="content_calendar">Kalendarium</div>
<div class="content-tab-bar__item" data-target="content_visit">Besuchsstellen</div>
<div class="content-tab-bar__item<?= $activeTab === 'calendar' ? ' active' : '' ?>" data-target="content_calendar">Kalendarium</div>
<div class="content-tab-bar__item<?= $activeTab === 'visit' ? ' active' : '' ?>" data-target="content_visit">Besuchsstellen</div>
</div>
<div class="content_seelsorge active" id="content_calendar">
<div class="content_seelsorge<?= $activeTab === 'calendar' ? ' active' : '' ?>" id="content_calendar">
<?php // Kalendarium:
include 'content/calendar.php'; ?>
</div>
<div class="content_seelsorge" id="content_visit">
<div class="content_seelsorge<?= $activeTab === 'visit' ? ' active' : '' ?>" id="content_visit">
<?php // Besuchsstellen:
include 'content/visit.php'; ?>
@@ -61,6 +91,12 @@
</div>
</div>
<?php include __DIR__ . '/content/profile.php'; ?>
<script type="application/json" id="seelsorge-config"><?= json_encode([
'csrfToken' => $seelsorgeCsrfToken,
], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?></script>
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/footer.php'; ?>
</body>
@@ -1,118 +0,0 @@
<?php
/* Name: toggle_availability.php
Beschreibung: Verfuegbarkeit eintragen/entfernen (AJAX-Endpoint)
Liefert JSON-Response, sendet SMS+Mail bei Match */
header('Content-Type: application/json; charset=utf-8');
session_start();
if (!isset($_SESSION["user_id"]) || !isset($_POST["date"])) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Fehlende Parameter']);
exit;
}
$date = $_POST["date"];
$formattedDate = date("d.m.Y", strtotime($date));
$userId = (int) $_SESSION["user_id"];
$pdo = require($_SERVER['DOCUMENT_ROOT'] . "/intranet_repo/general_files/ini/database.php");
// Pruefen ob Verfuegbarkeit bereits besteht
$stmt = $pdo->prepare("SELECT COUNT(*) AS count FROM verfuegbarkeiten WHERE datum = ? AND user_id = ?");
$stmt->execute([$date, $userId]);
$wasAvailable = $stmt->fetch()['count'] > 0;
// Eintragen oder Austragen
if ($wasAvailable) {
$stmt = $pdo->prepare("DELETE FROM verfuegbarkeiten WHERE datum = ? AND user_id = ?");
} else {
$stmt = $pdo->prepare("INSERT INTO verfuegbarkeiten (datum, user_id) VALUES (?, ?)");
}
if (!$stmt->execute([$date, $userId])) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'Datenbankfehler']);
$pdo = null;
exit;
}
$nowAvailable = !$wasAvailable;
$notifications = [];
// Bei neuem Eintrag: Match pruefen und benachrichtigen
if ($nowAvailable) {
// E-Mail-Spalte dynamisch ermitteln
$emailCol = null;
$emailCandidates = ['email', 'mail', 'email_address', 'e_mail', 'user_email'];
$inList = "'" . implode("','", $emailCandidates) . "'";
$res = $pdo->query("SELECT column_name FROM information_schema.columns WHERE table_name = 'user_general_information' AND column_name IN ($inList) LIMIT 1");
if ($res && $r = $res->fetch()) { $emailCol = $r['column_name']; }
$emailSelect = $emailCol ? ", u.\"{$emailCol}\" AS email" : ", '' AS email";
$sql = "SELECT u.user_id, u.first_name, u.last_name, u.phone{$emailSelect}
FROM verfuegbarkeiten v
JOIN user_general_information u ON v.user_id = u.user_id
WHERE v.datum = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$date]);
$matchUsers = $stmt->fetchAll();
if (count($matchUsers) > 1) {
$nameList = implode(', ', array_map(fn($u) => trim($u['first_name'] . ' ' . $u['last_name']), $matchUsers));
$smsText = mb_convert_encoding(
"Intranet NAK Reinickendorf\n\nIt's a match!\n\n{$nameList} haben am {$formattedDate} fuer einen Seelsorgebesuch Zeit!",
'UTF-8', 'auto'
);
// SMS an alle Beteiligten
foreach ($matchUsers as $u) {
if (!empty($u['phone'])) {
$smsData = json_encode([
'phone' => '+49' . $u['phone'],
'message' => $smsText
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$ctx = stream_context_create(['http' => [
'header' => "Content-Type: application/json; charset=utf-8",
'method' => 'POST',
'content' => $smsData,
'timeout' => 5
]]);
@file_get_contents(rtrim(intranet_env('SMS_GATEWAY_URL'), '/') . '/send_sms', false, $ctx);
$notifications[] = "SMS → " . $u['first_name'];
}
}
// E-Mail an alle Beteiligten
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/send_mail.php';
$mailBetreff = "Seelsorge-Match am {$formattedDate}";
$mailNachricht = "It's a match!\n\n{$nameList} haben am {$formattedDate} für einen Seelsorgebesuch Zeit.\n\nBitte stimmt euch untereinander ab.";
$host = $_SERVER['HTTP_HOST'] ?? intranet_env('INTRANET_FQDN');
$btnUrl = "https://{$host}/intranet_repo/projects/seelsorge/seelsorge.php";
foreach ($matchUsers as $u) {
$email = trim($u['email'] ?? '');
if (!empty($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) {
@send_intranet_mail($email, $mailBetreff, $mailNachricht, null, 'intranet@nak-reinickendorf.de', 'Zum Seelsorgeplaner', $btnUrl);
$notifications[] = "Mail → " . $u['first_name'];
}
}
}
}
// Aktuellen Count der verfuegbaren Diakon*innen fuer diesen Tag zurueckgeben
$stmt = $pdo->prepare("SELECT COUNT(*) AS cnt FROM verfuegbarkeiten WHERE datum = ?");
$stmt->execute([$date]);
$availCount = $stmt->fetch()['cnt'];
$pdo = null;
echo json_encode([
'ok' => true,
'nowAvailable' => $nowAvailable,
'availCount' => (int) $availCount,
'notifications' => $notifications
], JSON_UNESCAPED_UNICODE);
?>
@@ -33,7 +33,7 @@ class lib_Feiertage_Connector
return $wert === $datum;
});
if ($filter !== null) {
if (!empty($filter)) {
$feiertagsname = implode(" ",array_keys($filter));
}
@@ -46,7 +46,7 @@ class lib_Feiertage_Connector
if(isset(self::$cache['c0-'.$jahr]))
return self::$cache['c0-'.$jahr];
$file = "data/feiertage_$jahr.json";
$file = __DIR__ . "/../data/feiertage_$jahr.json";
if (!file_exists($file)) { //Ggf. Datei von externer API speichern
@@ -63,7 +63,7 @@ class lib_Feiertage_Connector
if(isset(self::$cache['c0-'.$jahr.$land]))
return self::$cache['c0-'.$jahr.$land];
$file = "data/feiertage_" . $jahr . "_" . $land . ".json";
$file = __DIR__ . "/../data/feiertage_" . $jahr . "_" . $land . ".json";
if (!file_exists($file)) { //Ggf. Datei von externer API speichern
@@ -99,7 +99,7 @@ class lib_Feiertage_Connector
if(isset(self::$cache['c1-'.$jahr]))
return self::$cache['c1-'.$jahr];
$file = "data/ferientage_berlin_$jahr.json";
$file = __DIR__ . "/../data/ferientage_berlin_$jahr.json";
if (!file_exists($file)) { //Ggf. Datei von externer API speichern
@@ -0,0 +1,446 @@
<?php
/* Gemeinsame, seiteneffektarme Hilfsfunktionen des Seelsorgeplaners. */
if (!defined('SEELSORGE_INITIAL_VISIT_DATE')) {
define('SEELSORGE_INITIAL_VISIT_DATE', '2024-07-11');
}
function seelsorge_csrf_token(): string
{
if (empty($_SESSION['seelsorge_csrf'])) {
$_SESSION['seelsorge_csrf'] = bin2hex(random_bytes(32));
}
return (string) $_SESSION['seelsorge_csrf'];
}
function seelsorge_verify_csrf(array $input): void
{
$postedToken = $input['csrf_token'] ?? '';
$sessionToken = $_SESSION['seelsorge_csrf'] ?? '';
if (!is_string($postedToken) || !is_string($sessionToken) || $sessionToken === '' || !hash_equals($sessionToken, $postedToken)) {
throw new InvalidArgumentException('Ungültige Anfrage. Bitte laden Sie die Seite neu.');
}
}
function seelsorge_profile_image_url(int $userId): string
{
$default = '/intranet_repo/general_files/profile_pictures/default.jpg';
if ($userId <= 0 || empty($_SERVER['DOCUMENT_ROOT'])) {
return $default;
}
$images = glob($_SERVER['DOCUMENT_ROOT'] . "/intranet_repo/general_files/profile_pictures/{$userId}-*.jpg") ?: [];
if (!$images) {
return $default;
}
usort($images, static fn($a, $b) => filemtime($b) <=> filemtime($a));
return str_replace($_SERVER['DOCUMENT_ROOT'], '', $images[0]);
}
function seelsorge_load_priests(PDO $pdo): array
{
$stmt = $pdo->query(
'SELECT u.user_id, u.first_name, u.last_name, u.mail AS email
FROM user_general_information u
JOIN user_permissions p ON p.user_id = u.user_id
WHERE p."PriesterIn" = 1
ORDER BY u.last_name ASC, u.first_name ASC'
);
return $stmt ? $stmt->fetchAll() : [];
}
function seelsorge_priest_options(array $priests): string
{
$options = [];
foreach ($priests as $priest) {
$userId = (int) ($priest['user_id'] ?? 0);
$firstName = trim((string) ($priest['first_name'] ?? ''));
$lastName = trim((string) ($priest['last_name'] ?? ''));
$email = trim((string) ($priest['email'] ?? ''));
if ($firstName !== '' && $lastName !== '') {
$label = $lastName . ', ' . $firstName;
} elseif ($lastName !== '' || $firstName !== '') {
$label = $lastName !== '' ? $lastName : $firstName;
} else {
$label = $email !== '' ? $email : "User #{$userId}";
}
$options[] = sprintf(
'<option value="%d">%s</option>',
$userId,
htmlspecialchars($label, ENT_QUOTES, 'UTF-8')
);
}
return implode('', $options);
}
function seelsorge_parse_visit_entry(array $input): array
{
$priestId = trim((string) ($input['pr_bezirk'] ?? ''));
$entry = [
'besuch_id' => max(0, (int) ($input['besuch_id'] ?? 0)),
'last_name' => trim((string) ($input['last_name'] ?? '')),
'first_name' => trim((string) ($input['first_name'] ?? '')),
'birth_date' => trim((string) ($input['birth_date'] ?? '')),
'address_street' => trim((string) ($input['address_street'] ?? '')),
'address_plz' => trim((string) ($input['address_plz'] ?? '')),
'address_link' => trim((string) ($input['address_link'] ?? '')),
'phone' => preg_replace('/\D+/', '', trim((string) ($input['phone'] ?? ''))) ?: null,
'comment' => trim((string) ($input['comment'] ?? '')),
'letzter_besuch' => trim((string) ($input['letzter_besuch'] ?? '')) ?: SEELSORGE_INITIAL_VISIT_DATE,
'priority' => (int) ($input['priority'] ?? 0),
'pr_bezirk' => ctype_digit($priestId) ? (int) $priestId : 0,
];
if ($entry['last_name'] === '' || $entry['first_name'] === '' || $entry['address_street'] === ''
|| $entry['address_plz'] === '' || $entry['address_link'] === '' || $entry['pr_bezirk'] <= 0) {
throw new InvalidArgumentException('Pflichtfelder fehlen.');
}
foreach (['birth_date', 'letzter_besuch'] as $field) {
$date = DateTime::createFromFormat('!Y-m-d', $entry[$field]);
if (!$date || $date->format('Y-m-d') !== $entry[$field]) {
throw new InvalidArgumentException('Ungültiges Datum.');
}
}
if ($entry['priority'] < 0 || $entry['priority'] > 2) {
throw new InvalidArgumentException('Ungültige Priorität.');
}
return $entry;
}
function seelsorge_visit_entry_client_data(array $row): array
{
return [
'besuch_id' => (int) ($row['besuch_id'] ?? 0),
'last_name' => $row['last_name'] ?? '',
'first_name' => $row['first_name'] ?? '',
'birth_date' => $row['birth_date'] ?? '',
'address_street' => $row['address_street'] ?? '',
'address_plz' => $row['address_plz'] ?? '',
'address_link' => $row['address_link'] ?? '',
'phone' => $row['phone'] ?? '',
'comment' => $row['comment'] ?? '',
'letzter_besuch' => $row['letzter_besuch'] ?? '',
'priority' => (int) ($row['priority'] ?? 0),
'pr_bezirk' => $row['pr_bezirk'] === null ? '' : (int) $row['pr_bezirk'],
'reservation' => $row['reservation'] ?? null,
];
}
function seelsorge_changed_visit_fields(array $oldEntry, array $newEntry): array
{
$labels = [
'last_name' => 'Nachname',
'first_name' => 'Vorname',
'birth_date' => 'Geburtsdatum',
'address_street' => 'Adresse',
'address_plz' => 'PLZ/Ort',
'address_link' => 'Karten-Link',
'phone' => 'Telefon',
'comment' => 'Kommentar',
'letzter_besuch' => 'Letzter Besuch',
'priority' => 'Priorität',
'pr_bezirk' => 'Priester-Bezirk',
];
$changed = [];
foreach ($labels as $field => $label) {
if ((string) ($oldEntry[$field] ?? '') !== (string) ($newEntry[$field] ?? '')) {
$changed[] = $label;
}
}
return $changed;
}
function seelsorge_save_visit_entry(PDO $pdo, array $entry): void
{
$fields = [
'last_name', 'first_name', 'birth_date', 'address_street', 'address_plz',
'address_link', 'phone', 'comment', 'letzter_besuch', 'priority', 'pr_bezirk',
];
$values = array_map(static fn($field) => $entry[$field], $fields);
$visitId = (int) $entry['besuch_id'];
$pdo->beginTransaction();
try {
if ($visitId === 0) {
$stmt = $pdo->prepare(
'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->execute($values);
$pdo->commit();
return;
}
$stmtOld = $pdo->prepare(
'SELECT last_name, first_name, birth_date, address_street, address_plz, address_link, phone, comment, letzter_besuch, priority, pr_bezirk
FROM seelsorge_besuchsstellen WHERE besuch_id = ?'
);
$stmtOld->execute([$visitId]);
$oldEntry = $stmtOld->fetch();
if (!$oldEntry) {
throw new RuntimeException('Eintrag nicht gefunden.');
}
$stmt = $pdo->prepare(
'UPDATE seelsorge_besuchsstellen
SET last_name = ?, first_name = ?, birth_date = ?, address_street = ?, address_plz = ?,
address_link = ?, phone = ?, comment = ?, letzter_besuch = ?, priority = ?, pr_bezirk = ?
WHERE besuch_id = ?'
);
$stmt->execute(array_merge($values, [$visitId]));
$changedFields = seelsorge_changed_visit_fields($oldEntry, $entry);
if ($changedFields) {
$personName = ($oldEntry['last_name'] ?? $entry['last_name']) . ', ' . ($oldEntry['first_name'] ?? $entry['first_name']);
$stmtLog = $pdo->prepare(
"INSERT INTO admin_messages
(type, sender_name, sender_email, subject, message, status, processed_at, created_at)
VALUES ('system', 'Seelsorgeplaner', '', ?, ?, 'done', NOW(), NOW())"
);
$stmtLog->execute([
"Besuchsstelle geändert: {$personName}",
"Besuchsstelle #{$visitId} ({$personName}) wurde geändert. Geänderte Felder: " . implode(', ', $changedFields) . '.',
]);
}
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
function seelsorge_visit_action(PDO $pdo, string $action, int $visitId, int $userId): void
{
if ($visitId <= 0) {
throw new InvalidArgumentException('Ungültige Besuchs-ID.');
}
if ($action === 'delete') {
$stmt = $pdo->prepare('DELETE FROM seelsorge_besuchsstellen WHERE besuch_id = ?');
$stmt->execute([$visitId]);
if ($stmt->rowCount() === 0) {
throw new RuntimeException('Eintrag nicht gefunden.');
}
return;
}
if ($action === 'prioritize') {
$stmt = $pdo->prepare('UPDATE seelsorge_besuchsstellen SET priority = 2 WHERE besuch_id = ?');
$stmt->execute([$visitId]);
if ($stmt->rowCount() === 0) {
throw new RuntimeException('Eintrag nicht gefunden.');
}
return;
}
$stmt = $pdo->prepare('SELECT address_street, address_plz FROM seelsorge_besuchsstellen WHERE besuch_id = ?');
$stmt->execute([$visitId]);
$address = $stmt->fetch();
if (!$address) {
throw new RuntimeException('Eintrag nicht gefunden.');
}
$params = [];
switch ($action) {
case 'reserve':
$sql = 'UPDATE seelsorge_besuchsstellen SET reservation = ? WHERE address_street = ? AND address_plz = ?';
$params[] = $userId;
break;
case 'unreserve':
$sql = 'UPDATE seelsorge_besuchsstellen SET reservation = NULL WHERE address_street = ? AND address_plz = ?';
break;
case 'complete':
$sql = 'UPDATE seelsorge_besuchsstellen SET letzter_besuch = CURRENT_DATE, priority = 0, reservation = NULL WHERE address_street = ? AND address_plz = ?';
break;
case 'update_date':
$sql = 'UPDATE seelsorge_besuchsstellen SET letzter_besuch = CURRENT_DATE, priority = 0 WHERE address_street = ? AND address_plz = ?';
break;
default:
throw new InvalidArgumentException('Unbekannte Aktion.');
}
$stmt = $pdo->prepare($sql);
$stmt->execute(array_merge($params, [$address['address_street'], $address['address_plz']]));
}
function seelsorge_process_visit_command(PDO $pdo, array $input, int $userId): void
{
$command = trim((string) ($input['visit_command'] ?? ''));
if ($command === 'save') {
seelsorge_save_visit_entry($pdo, seelsorge_parse_visit_entry($input));
return;
}
seelsorge_visit_action($pdo, $command, (int) ($input['besuch_id'] ?? 0), $userId);
}
function seelsorge_toggle_availability(PDO $pdo, string $date, int $userId): bool
{
$stmt = $pdo->prepare(
'WITH deleted AS (
DELETE FROM verfuegbarkeiten WHERE datum = ? AND user_id = ? RETURNING user_id
)
INSERT INTO verfuegbarkeiten (datum, user_id)
SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM deleted)
RETURNING user_id'
);
$stmt->execute([$date, $userId, $date, $userId]);
return $stmt->fetchColumn() !== false;
}
function seelsorge_notify_match_by_mail(array $users, DateTimeInterface $date): void
{
if (count($users) < 2) {
return;
}
$names = array_values(array_filter(array_map(
static fn($user) => trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? '')),
$users
)));
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/send_mail.php';
$formattedDate = $date->format('d.m.Y');
$subject = "Seelsorge-Match am {$formattedDate}";
$message = "It's a match!\n\n" . implode(', ', $names) . " haben am {$formattedDate} für einen Seelsorgebesuch Zeit.\n\nBitte stimmt euch untereinander ab.";
$host = $_SERVER['HTTP_HOST'] ?? intranet_env('INTRANET_FQDN');
$plannerUrl = "https://{$host}/intranet_repo/projects/seelsorge/seelsorge.php";
foreach ($users as $user) {
$email = trim((string) ($user['email'] ?? ''));
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
continue;
}
if (!send_intranet_mail($email, $subject, $message, null, 'intranet@nak-reinickendorf.de', 'Zum Seelsorgeplaner', $plannerUrl)) {
error_log("Seelsorge match mail failed for user " . (int) ($user['user_id'] ?? 0));
}
}
}
function seelsorge_calendar_availability_map(PDO $pdo, string $startDate, string $endDate, int $userId): array
{
$stmt = $pdo->prepare(
'SELECT datum, COUNT(*) AS count,
SUM(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS self_count
FROM verfuegbarkeiten
WHERE datum BETWEEN ? AND ?
GROUP BY datum'
);
$stmt->execute([$userId, $startDate, $endDate]);
$map = [];
foreach ($stmt->fetchAll() as $row) {
$date = (string) $row['datum'];
$map[$date] = [
'count' => (int) $row['count'],
'self' => (int) $row['self_count'] > 0,
];
}
return $map;
}
function seelsorge_group_households(array $entries): array
{
$households = [];
foreach ($entries as $entry) {
$key = ($entry['address_street'] ?? '') . '||' . ($entry['address_plz'] ?? '');
if (!isset($households[$key])) {
$households[$key] = [
'members' => [],
'address_street' => $entry['address_street'] ?? '',
'address_plz' => $entry['address_plz'] ?? '',
'address_link' => $entry['address_link'] ?? '',
'phone' => $entry['phone'] ?? '',
'reservation' => null,
];
}
$households[$key]['members'][] = $entry;
if (($entry['reservation'] ?? null) !== null) {
$households[$key]['reservation'] = $entry['reservation'];
}
}
return $households;
}
function seelsorge_household_status(array $members, bool $isReserved, ?int $todayTimestamp = null): array
{
if ($isReserved) {
return ['key' => 'blue', 'label' => 'Geplant'];
}
$todayTimestamp = $todayTimestamp ?? strtotime(date('Y-m-d'));
$status = 'green';
foreach ($members as $member) {
$lastVisit = strtotime((string) ($member['letzter_besuch'] ?? SEELSORGE_INITIAL_VISIT_DATE));
$months = (int) floor(($todayTimestamp - $lastVisit) / (86400 * 30));
$priority = (int) ($member['priority'] ?? 0);
if ($priority === 2 || $months >= 6) {
$status = 'red';
break;
}
if ($priority === 1 || $months >= 2) {
$status = 'orange';
}
}
$labels = ['red' => 'Dringend', 'orange' => 'Fällig', 'green' => 'Aktuell'];
return ['key' => $status, 'label' => $labels[$status]];
}
function seelsorge_relative_visit_text(string $lastVisit, ?int $todayTimestamp = null): string
{
if ($lastVisit === SEELSORGE_INITIAL_VISIT_DATE) {
return 'Initialer Eintrag';
}
$todayTimestamp = $todayTimestamp ?? strtotime(date('Y-m-d'));
$days = max(0, (int) floor(($todayTimestamp - strtotime($lastVisit)) / 86400));
if ($days >= 25) {
$months = (int) floor($days / 30);
return $months === 1 ? 'vor einem Monat' : "vor {$months} Monaten";
}
if ($days >= 7) {
$weeks = (int) floor($days / 7);
return $weeks === 1 ? 'vor einer Woche' : "vor {$weeks} Wochen";
}
return $days === 1 ? 'vor einem Tag' : "vor {$days} Tagen";
}
function seelsorge_profile_records(PDO $pdo): array
{
static $cache = null;
if ($cache !== null) {
return $cache;
}
$users = $pdo->query('SELECT * FROM user_general_information ORDER BY user_id')->fetchAll();
$permissionRows = $pdo->query('SELECT * FROM user_permissions')->fetchAll();
$permissionsByUser = [];
foreach ($permissionRows as $permissions) {
$permissionsByUser[(int) $permissions['user_id']] = $permissions;
}
$cache = [];
foreach ($users as $user) {
$userId = (int) $user['user_id'];
$cache[] = [
'user' => $user,
'permissions' => $permissionsByUser[$userId] ?? [],
'image' => seelsorge_profile_image_url($userId),
];
}
return $cache;
}