web - refactor core modules, harden authorization, simplify endpoints #11
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 . ' ' . $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>
|
||||
|
||||
|
||||
@@ -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; ?>
|
||||
|
||||
|
||||
@@ -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">×</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ü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ügbarkeit entfernen' : 'Als verfü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´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;
|
||||
}
|
||||
Reference in New Issue
Block a user