Compare commits

...
16 Commits
Author SHA1 Message Date
Robert Wartenberg 4eac5bafd5 add logbuch 2026-08-31 14:50:18 +02:00
Robert Wartenberg b58d96cd5e Implementiere echtes sendmail für den Server. Lokal gibts kein mail und keine outbox mehr 2026-08-31 14:27:19 +02:00
Robert Wartenberg 6c16e16354 reorder folders for easy 1:1 upload to boogie server 2026-08-31 11:40:54 +02:00
Robert Wartenberg de5895f558 lösche dumme powershell scripte 2026-08-28 21:37:12 +02:00
Robert Wartenberg 6ef38aa7d2 add: Deploy BAT 2026-08-28 17:30:28 +02:00
Robert Wartenberg a6e6e42a43 place calendear for Produktionsschule in mockup page 2026-08-28 17:08:04 +02:00
Robert Wartenberg 72c032a5ca Weitere Demo Daten und Default settings für DateTimePicker 2026-08-28 17:02:23 +02:00
Robert Wartenberg e122aa8650 feat: add Backend mit allen aktuellen features + Demo Daten 2026-08-28 16:35:59 +02:00
Robert Wartenberg a78e84515a feat: DB backend and API implemementation 2026-08-28 10:13:55 +02:00
Robert Wartenberg 39b891cd63 SOLL Planung nach erster Bedarfsanalyse fertig 2026-08-28 08:53:06 +02:00
Robert Wartenberg 7a735915d1 IST-Stand und erstes Bedarfsgespräch hinzugefügt 2026-08-27 21:11:17 +02:00
Robert Wartenberg 037ea11de5 add: Kalendereinträge, Eingabeformular und Absenden-Button 2026-08-20 14:56:16 +02:00
Robert Wartenberg 6a9d31ca5e Ersters Mockup das in der statischen Seite und in Contao angeschaut werden kann 2026-08-20 13:58:05 +02:00
Robert Wartenberg 6dcc315d25 feat: add deploy script for boogie 2026-08-20 13:56:22 +02:00
Robert Wartenberg 76e45ac9ad Erstellen einer ersten test-Komponente die in der Mockup-HTML-Seite und in Contao eingebunden werden kann 2026-08-20 12:28:57 +02:00
Robert Wartenberg e96c73000d Update Ziele und Fragen 2026-08-20 09:57:07 +02:00
50 changed files with 3066 additions and 132 deletions

No files matched your search

+109 -33
View File
@@ -1,14 +1,99 @@
# AGENTS.md
## Guidlines
## Guidelines
Ich bin Softwareentwickler, Full-Stack Webentwickler und Netzwerkadministrator. Ich möchte bei allen Software und Netzwerk-Design-Entscheidungen und beim Erstellen von Code eingebunden werden.
Die Fragende Person/der Nutzer ist Softwareentwickler, Full-Stack Webentwickler und Netzwerkadministrator.
Der Fragende/der Nutzer wird bei allen Software und Netzwerk-Design-Entscheidungen und beim Erstellen von Code durch Nachfrage eingebunden!
Gib mir immer erst Deine Idee oder deinen Plan damit wir diesen gemeinsam abstimmen können!
Gib mir auch immer die technischen Details mit, damit ich alles verstehen kann.
## Ziel
## Ziel der zu entwicklenden Anwendung
Entwicklung einer abgeschlossenen Komponente für das CMS Contao, um Terminbuchungs-Funktionen in der Website des Perspektivwerks (PW) und der Produktionsschule (PS) zu implementieren.
Ziel ist eine nutzerfreundliche und schöne Buchungsoberfläche, die den genannten Terminarten entspricht, mobil nutzbar ist, sicher umgesetzt wird und später mittels ICS Addresse in Kalenderprogramme integriert werden kann.
### Technik
Contao ist ein in PHP geschriebenes CMS, das ansonsten einfach Webtechnologien verwendet.
In dieses soll eine WebComponente integriert werden, die einen Terminbuchungskalender anzeigt.
Termininformationen kommen aus der Datenbank entweder mittels Contao oder direkt als MySQL Query.
Es gibt zwei Kalender (je für PW/PS) und jeder Kalender enthält eigene Termine die von Besuchern gebucht werden können.
### Auslieferungswege der Terminbuchungskomponente
Die Terminbuchungskomponente muss in zwei gleichwertigen Varianten funktionieren:
- **Apache-Vorschau:** als Bestandteil der statischen Seite im Webpfad `public/perspektivwerk/`, erreichbar unter `http://localhost:8080/perspektivwerk/`.
- **Contao-Einbindung:** als einbettbare Komponente in einem Contao-Inhaltselement vom Typ HTML.
Contao verwendet die browsernativen JavaScript- und CSS-Assets aus `public/booking/`: `booking.js` und `booking.css`, eingebunden über `/booking/booking.js` und `/booking/booking.css`. Für die Apache-Vorschau liegen identische, statisch übertragbare Kopien unter `public/perspektivwerk/js/`, die relativ über `js/booking.js` und `js/booking.css` eingebunden werden. Für Contao wird das kombinierte Snippet aus `integration/contao-html-element.html` in ein neues Inhaltselement vom Typ **HTML** eingefügt. Es muss als Roh-HTML im Feld „HTML-Code“ gespeichert werden, nicht als Textinhalt eines TinyMCE-Text-Elements. Ein zweites Kopieren in den Contao-Dateibereich `files/` ist nicht erforderlich.
### Die Oberfläche / UI
- ein Kalender-view aller Tage des aktuellen Monats
- der Monat kann vor und zurück gewechselt werden
- Tage mit freien Terminen werden farblich dargestellt
- wird auf einen Tag geklickt sind alle slots an diesem Tag zu sehen (farblich unterschieden verfügbar/schon belegt)
- wird auf einen freien Slot geklickt kann dieser durch eine E-Mail-Bestätigung (Datenschutzbedingung akzeptiert, Art. 13 DSGVO) verbindlich gebucht werden
- is barrierefrei. Alle Komponenten haben WAI-ARIA tags, sinnvolle Focus-Reihenfolge, Bildbeschreibungen, gute Kontraste, Farbenblindheit ist berücksichtigt
### Besucher der Webseite können
- in der Komponente die verfügbaren Termine sehen (offen / schon belegt)
- einen der verfügbaren Termine für sich buchen (Angabe einer E-Mail Adresse, Bestätigung des Termins via Email)
- Nach der Bestätigung via E-Mail kommt zweite Mail zur Bestätigung. Diese enthält ein *.ics file mit dem Termin (Kalenderintegration des Klienten)
- Nicht bestätigte Buchungen laufen nach 30 Minuten ab, der Slot wird wieder frei
### Mitarbeiter von PS oder PW können
- alle gebuchten Termine mittels einer Kalenderaddresse abonnieren und in ihren eigenen Kalender (Google & Outlook) integrieren
- wann abonnierte Kalender synchronisiert werden unklar, Mitarbeiter müssen im Zweifel bei kurz bevorstehenden Terminen im tool nachsehen
- soweit vorgesehen: Ihre Verfügbarkeit für Termin-Slots im eintragen (via. Contao-Backend)
- soweit vorgesehen: Ihre Verfügbarkeit für Termin-Slots bearbeiten (via. Contao-Backend)
### Fachliche Fragen
1. Sind die buchbaren Zeiten immer gleich (z.B.. Mo. & Mit. 13, 14 und 15Uhr) oder ständig neu (Feiertage, Schließzeiten, etc.)?
1. Wer darf alles die Zeiten anpassen (müssen leicht anzupassen sein),
2. Mit wieviel Vorlaufzeit sind offene Terminslots bekannt?
3. Wie sollen Terminslots geändert werden?
1.  per Contao-Backend?
2. ein eigenes Backend des Tools?
3. per Mail an das Tool?
4. per eigenem Kalendereintrag?
2. Dürfen Besucher auch belegte (anonyme) Termine  sehen (um über die Terminlage generell informiert zu sein)?
3. Sollen Besucher eine Bestätigungsmail bekommen und in dieser den Termin auch bestätigen?
4. Welche Informationen sollen Besucher der Seite für den Termin eingeben?
1. Name, Telefon, Alter, Nachricht, Ansprechpartner bei AfA/Jobcenter?
2. Wer soll die Informationen bekommen? (eine Koordinierungstelle, die konkreten verschiedenen Sachbarbeitenden, andere?)
5. Wer oder wie viele Menschen/Kontakte werden informiert? Gibt es nur ein Stelle die je für PW/PS involviert , oder müssen wie bei deiner Arztreferenz auch verschiedene Personen/Themen/Schwerpunkte o.ä. koordiniert werden?
6. Gibt es mehrere Räume die z.B. abhängig genutzt und in die Modellierung eingebaut werden müssen?
7. Gibt es irgendwas zu beachten bei Minderjährigen und E-Mails oder Zeitbuchungen?
8. Wenn E-Mail nicht klappt, Fallback auf Telefon (WhatsApp, Signal whatever?)
9. Hinweis: Rücksprache mit Datenschutzbeauftragtem bzgl. E-Mail Validierung!
10. Sollen die Mitarbeiter direkten E-Mail Kontakt zu den Klienten aufnehmen können, z.B. um Termin zu verschieben, stornieren, Rückfragen?
11. Müssen stornierte Termine gespeichert werden? Wegen Kosten oder Rückverfolgung?
12. Soll das tool Klienten einen Tag vor dem Termin noch mal per Mail informieren o.ä.?
### Technische Fragen
1. Die verwendete Contao-Version hat bekannte Sicherheitslücken, wann wird aktualisiert?
2. Zum einbinden der Terminbuchung muss in den Sicherheitseinstellungen von Contao <link> und <script> Element + deren Attribute frei gegeben werden.
3. Ich benötige eigene Datenbanken (oder Speicher direkt in Contao) für die Termine und E-Mails
4. E-Mail Validierung bei Buchung:
1. Terminbuchungstool muss E-Mails schicken können (IMAP-Konto)
2. Wann sollen Nutzerdaten gelöscht werden? (Fachlich und aus DSGVO Sicht)
5. Art.13 DSGVO: festgelegte Rechtsgrundlage:
1. Nutzer die sich anmelden haben das Recht auf Dateneinsicht und Datenlöschung, wie damit am besten umgehen?
2. Ein Löschkonzept sowie geregelte Zugriffe der Mitarbeitenden.
3. Datenminimierung und Speicherbegrenzung verlangen, nur notwendige Daten und nur so lange wie erforderlich zu speichern.
6. Hat der Mailserver SPF, DKIM und DMARC für die Absenderdomain eingerichtet?
7. Serverseitige Input-Validierung, CSRF-Schutz, Rate Limits, Bot-Schutz mit Datenschutzfreundlicher Eskalation und keine preisgebenden Fehlermeldungen?
8. Gibt es Staging und Development Systeme für die Entwicklung?
9. Wer verantwortet Betrieb, Updates, Backups, Monitoring und Datenschutzanfragen?
Diese Aufgabe betrifft die Implementierung einer Terminbuchungs-Funktion für die Website des Perspektivwerks und der Produktionsschule. Ziel ist eine nutzerfreundliche Buchungsoberfläche, die den genannten Terminarten entspricht, mobil nutzbar ist, sicher umgesetzt wird und später in einen Google-Kalender integriert werden kann.
## Projektkontext
@@ -16,7 +101,6 @@ Diese Aufgabe betrifft die Implementierung einer Terminbuchungs-Funktion für di
- Website: https://www.sufw.de/produktionsschule
- Referenzstil: https://webtermin.medatixx.de/#/affe7e7b-bb18-46b6-aaf4-6ff75152606c/search
- Zugangsdaten für die Terminbuchung werden nachgereicht.
- Der aktuelle Stand ist noch unbekannt; deshalb wird zuerst ein Mockup basierend auf der bestehenden Seite erstellt, bevor Backend- und Integrationslogik angebunden wird.
## Technische Bestandsaufnahme der Website
@@ -26,8 +110,7 @@ Die folgenden Punkte sind aus einer passiven, öffentlich sichtbaren Analyse abg
- Als CMS ist sehr wahrscheinlich Contao im Einsatz.
- Der Webserver antwortet mit `Apache`.
- Die Anwendung läuft sehr wahrscheinlich auf einem PHP-Stack; Contao spricht dabei typischerweise über Symfony/Doctrine mit einer MySQL- oder MariaDB-Datenbank.
- Im Frontend sind Bootstrap-5-Elemente sowie jQuery erkennbar.
- Zusätzliche Frontend-Bausteine wie Slick-Slider und eigene CSS-Bundles sind eingebunden.
- Zusätzliche Frontend-Bausteine wie Slick-Slider und eigene CSS-Bundles sind auf der Bestandswebsite eingebunden.
- Ein selbst gehostetes Matomo-Tracking ist sichtbar.
- Der genaue Contao- und PHP-Versionsstand ist von außen nicht belastbar bestimmbar und sollte für die Implementierung als offen behandelt werden.
- Der Host wirkt nach der Infrastruktur sehr wahrscheinlich wie eine IONOS-Umgebung, auch das ist aber nur eine Wahrscheinlichkeitsannahme.
@@ -35,13 +118,13 @@ Die folgenden Punkte sind aus einer passiven, öffentlich sichtbaren Analyse abg
Für die weitere Umsetzung bedeutet das:
- Die Terminbuchung sollte sich sauber in eine klassische CMS-Seite einbetten lassen.
- Ein kleines, eigenständiges TypeScript-Frontend ist hier voraussichtlich passender als eine große neue Framework-Schicht.
- Ein kleines, eigenständiges JavaScript-Frontend ist hier voraussichtlich passender als eine große neue Framework-Schicht.
- Backend- und Kalenderanbindung sollten entkoppelt geplant werden, damit ein späterer Wechsel von Google Kalender zu Exchange möglich bleibt, ohne das UI neu bauen zu müssen.
## Lokales Entwicklungssystem
- Das lokale Setup bildet die vermutete Komponentenfamilie mit Docker Compose nach: Apache/PHP 8.3, Contao Managed Edition 5.3, MariaDB 10.11 und Adminer.
- Frontend-Abhängigkeiten für die angenommene Originalseite sind in `package.json` vorgesehen: Bootstrap 5, jQuery und Slick Carousel; die eigene Logik bleibt in TypeScript.
- Für die Buchungskomponente wird eigene Logik als browsernatives JavaScript ohne Build-Schritt ausgeliefert.
- Start über `docker compose up --build`; CMS auf Port 8080, Adminer auf Port 8081, MariaDB zusätzlich auf Host-Port 3307.
- Das Setup ist eine reproduzierbare Entwicklungsbasis und keine Kopie der Produktivinstallation. Exakte Contao-/PHP-Versionen, Templates, Erweiterungen und Hosting-Einstellungen der Originalseite müssen bei Zugriff auf die Produktivumgebung abgeglichen werden.
@@ -62,29 +145,27 @@ Für die weitere Umsetzung bedeutet das:
## Grundprinzipien
- Mobile-first und barrierearm entwickeln.
- Die Frontend-Logik in TypeScript umsetzen.
- Für die Oberfläche möglichst einfache, weit verbreitete Technologien nutzen: HTML, CSS und TypeScript ohne unnötige Framework-Schicht.
- Eine in Contao CMS funkionierende Lösung sein.
- Die Frontend-Logik in browsernativem JavaScript (ES-Module) umsetzen.
- Für die Oberfläche möglichst einfache, weit verbreitete Technologien nutzen: HTML, CSS und JavaScript ohne unnötige Framework-Schicht oder verpflichtenden Build-Schritt.
- Performance hat Priorität: geringe Dateigrößen, wenig JavaScript, keine unnötigen Abhängigkeiten, schnelle Rendering- und Ladezeiten.
- Die Lösung soll modular, testbar und leicht später mit Backend oder Kalender-API verbunden werden können.
- Die Lösung soll modular, testbar und leicht ins Contao Backend integriert werden können.
## Technische Anforderungen
- Die Lösung muss auf der bestehenden Website sauber einbindbar sein.
- Die Lösung muss im Contao CMS sauber einbindbar sein.
- Die Lösung muss zusätzlich als Apache-Vorschau unter `/perspektivwerk/` funktionieren.
- Die Terminliste soll klar, verständlich und mobil kompatibel sein.
- Das Frontend soll nach einem „mockup-first“-Ansatz entstehen: zunächst ein realistisches, klickbares UI-Prototyping basierend auf der aktuellen Seite, dann später Anbindung an echte Daten und Kalender.
- Zugriffsdaten und Zugangsspezifika sind als Umgebungsvariablen bzw. sichere Konfiguration zu behandeln.
- Die Terminbuchung soll zunächst in einen Google-Kalender integriert werden.
- Ein späterer Wechsel auf Exchange-Kalender muss technisch berücksichtigt werden.
- Die technische Architektur soll die spätere Integration von Backend, API und Kalender sauber abbilden, ohne dass das Frontend später neu geschrieben werden muss.
- Die technische Architektur soll die Integration ins Contao Backend sauber abbilden, ohne dass das Frontend später neu geschrieben werden muss falls die Kommunikation zu Datenbank angepasst werden muss. (Separation of concerns)
## Sicherheitsanforderungen
- Keine geheimen Zugangsdaten, Tokens, Keys oder Secrets im Repository speichern.
- Eingaben immer validieren und bereinigen; XSS- und Injection-Angriffe vermeiden.
- Wenn Sicherheitsrelevanter Code geschrieben wurde, soll gefragt werden ob eine gesonderete tiefe Sicherheitsanalyse dafür durchgeführt werden soll.
- Wenn Formulare oder API-Calls später eingesetzt werden, nur mit abgesicherten, validierten Requests arbeiten.
- Bei späterem Backend- und API-Setup: sichere Authentifizierung, autorisierte Zugriffe und minimal notwendige Berechtigungen verwenden.
- Keine sensiblen Daten im Client- oder Mockup-Code sichtbar machen.
- Wenn Formulare oder API-Calls eingesetzt werden, nur mit abgesicherten, validierten Requests arbeiten.
- Bei Backend- und API-Setup: sichere Authentifizierung, autorisierte Zugriffe und minimal notwendige Berechtigungen verwenden.
## Qualitätsmaßstäbe
@@ -106,31 +187,27 @@ Für die weitere Umsetzung bedeutet das:
## Dokumentationsanforderungen
- Eine README mit Installations- und Startanweisungen erstellen.
- Eine kurze Architektur- und Integrationsdokumentation für Frontend, Mockup, späteres Backend und Kalender-API ergänzen.
- Eine kurze Architektur- und Integrationsdokumentation für Frontend und Backend ergänzen.
- Konfigurationswerte und Umgebungsvariablen sauber dokumentieren, aber keine geheimen Daten enthalten.
- Hinweise zur späteren Google-Calendar- und Exchange-Anbindung dokumentieren.
- Frontend-Entscheidungen, nicht trivialen Implementierungsdetails und potenzielle Risiken ebenfalls kurz dokumentieren.
## Arbeitsweise
1. Zuerst die bestehende Website-Struktur bzw. vorhandene technische Basis analysieren.
2. Das Design und die Nutzerführung für ein mockup-basiertes Booking-Flow definieren.
3. Die geeignete Architektur für die Terminbuchung bestimmen.
4. Die Buchungs-UI mit den erforderlichen Projekten und Terminarten umsetzen.
5. Testfälle für Kernfunktionen schreiben und anschließend die Umsetzung validieren.
6. Die Kalender-Integration vorbereiten und auf Google-Kalender ausrichten.
7. Das Ergebnis lokalisieren, validieren und möglichst mit Beispiel- oder Testdaten absichern.
1. Das Design und die Nutzerführung für Booking-Flow definieren.
2. Die geeignete Architektur für die Terminbuchung bestimmen.
3. Die Buchungs-UI mit den erforderlichen Projekten und Terminarten umsetzen.
4. Testfälle für Kernfunktionen schreiben und anschließend die Umsetzung validieren.
## Abnahme
Die Aufgabe gilt als abgeschlossen, wenn:
- die Angebotsseite für beide Projekte korrekt funktioniert,
- wenn eine Contao-Komponente erstellt wurde die im Contao-Backend eingebunden werden kann
- auf der Contao-Webseite die Terminbuchung angezeigt wird.
- die Terminarten auswählbar sind,
- die Oberfläche mobil-first, performant und barrierearm ist,
- das Frontend in TypeScript umgesetzt wurde,
- die Lösung als Mockup auf der aktuellen Seite verankert ist und später backendfähig bleibt,
- die Integration zur Kalender-API bzw. zum Google-Kalender vorbereitet ist,
- das Frontend in browsernativem JavaScript umgesetzt wurde,
- die Konfiguration sicher, dokumentiert und nachvollziehbar ist,
- die Kernfunktionen durch Tests abgesichert sind.
@@ -139,4 +216,3 @@ Die Aufgabe gilt als abgeschlossen, wenn:
- Zugangsdaten sind vertraulich und dürfen nicht in die Versionskontrolle eingegeben werden.
- Wenn die Umgebung oder der vorhandene Stack unklar ist, zuerst die technische Basis prüfen und dann gezielt implementieren.
- Das Projekt ist zunächst als funktionierende Terminbuchung mit klarer Erweiterbarkeit zu sehen; keine Über-Engineering-Phase ohne Notwendigkeit.
- Das Frontend muss zunächst als robustes, realistisches Mockup funktionieren, auch wenn das Backend noch nicht definiert ist.
+18
View File
@@ -0,0 +1,18 @@
Dieses Logbuch enthält updates zu Stichtagen, einträge sind nicht Tagesgenau und es ist chonologisch absteigend sortiert
# 31. August 2026
- Ausgangssituation aufgenommen und die Anforderungen für die digitale Terminbuchung gemeinsam abgestimmt.
- Konzept für eine verständliche Buchung von Terminen für PerspektivWerk und Produktionsschule erarbeitet.
- Erste funktionsfähige Buchungsoberfläche erstellt.
- Separate Kalender für PerspektivWerk und Produktionsschule eingebunden.
- Beispieltermine ergänzt, damit der Buchungsablauf realitätsnah getestet werden kann.
- Termine und Buchungsanfragen werden zentral gespeichert und im Kalender angezeigt.
- Interessierte können einen freien Termin anfragen und ihre E-Mail-Adresse über einen Bestätigungslink bestätigen.
- Nach erfolgreicher Bestätigung erhalten Interessierte eine Terminbestätigung mit Kalenderdatei zum einfachen Übernehmen in eigene Kalender.
- Eine separate Verwaltungsansicht für Mitarbeitende wurde ergänzt.
- Der Versand von Bestätigungs-E-Mails funktioniert auf dem Testserver.
# Früher
- Aufbau des Systems
+1
View File
@@ -5,6 +5,7 @@
/public/share
/public/system
/public/vendor
/public/booking-config.php
/system
/bin/console
/node_modules/
+209 -5
View File
@@ -1,6 +1,6 @@
# Perspektivwerk Terminbuchung – lokales Entwicklungssystem
Dieser Ordner enthält eine lokale Contao-4.13.58-Entwicklungsumgebung mit Apache, MariaDB sowie die im Frontend verbreiteten Komponenten Bootstrap 5, jQuery und Slick Carousel.
Dieser Ordner enthält eine lokale Contao-4.13.58-Entwicklungsumgebung mit Apache, MariaDB und Bootstrap 5. Die Buchungskomponente verwendet browsernative DOM-APIs und keine jQuery-Abhängigkeit.
## Contao-Version und Sicherheitsstatus
@@ -10,7 +10,7 @@ Contao 4.13.58 ist ausschließlich als lokale Kompatibilitätsumgebung vorgesehe
## Start
Voraussetzungen: Docker Desktop (Compose v2) und optional Node.js 20+ für die TypeScript-Assets.
Voraussetzungen: Docker Desktop (Compose v2) und optional Node.js 20+ zum Ausführen der JavaScript-Regressionstests.
```powershell
docker compose up --build
@@ -39,11 +39,11 @@ Danach den versionierten Seitenzustand importieren:
- Contao-Login: <http://localhost:8080/contao/login>
- Datenbankinspektor: <http://localhost:8081> (Server `db`, Benutzer `perspektivwerk`, Passwort `perspektivwerk`, Datenbank `perspektivwerk`)
Für die Frontend-Logik:
Für die Frontend-Regressionstests:
```powershell
npm install
npm run build
npm test
```
**Seite öffnen:**
@@ -58,7 +58,211 @@ http://localhost:8080/perspektivwerk/
Die genaue Contao-, PHP- und Template-Version der öffentlichen Seite ist nicht verlässlich aus dem ausgelieferten HTML ableitbar. Das Setup bildet daher die wahrscheinlichste Komponentenfamilie nach; es ist kein Dump der Produktivinstallation. IONOS/Hosting, Matomo-Konfiguration, Inhalte, Templates und Erweiterungen müssen bei Bedarf separat ergänzt werden.
Die Terminlogik liegt zunächst in `src/booking.ts`. Eine spätere Google-Calendar- oder Exchange-Anbindung sollte über eine Backend-API erfolgen, damit keine Secrets im Browser landen.
Der vollständige, statisch auslieferbare Perspektivwerk-DocumentRoot liegt in
`public/perspektivwerk/`. Er enthält die Buchungs-API unter `booking/` und die
Verwaltung unter `booking-admin/`. Die Vorschau verwendet relative API-URLs und
funktioniert dadurch lokal unter `/perspektivwerk/` sowie auf dem Server direkt
im DocumentRoot.
Für einen einfachen Upload der vollständigen statischen Mockup-Seite auf den
Testserver steht unter Windows dieses Skript bereit:
```bat
scripts\Deploy-Perspektivwerk.bat
```
Es überträgt den vollständigen Ordner `public\perspektivwerk` und dessen
benachbarte Konfigurationsdatei `public\booking-config.php` nach
`boogie:html/perspektivwerk/`. Damit entsteht der Server-DocumentRoot unter
`html/perspektivwerk/perspektivwerk/`; die Konfigurationsdatei liegt eine Ebene
darüber und damit außerhalb des DocumentRoots. Anschließend setzt es per SSH
lesbare Dateien und durchsuchbare Verzeichnisse.
Vor dem ersten Upload `public\booking-config.php.example` nach
`public\booking-config.php` kopieren und dort die Produktiv-`database_url`
eintragen. Die echte Konfigurationsdatei ist von Git ausgeschlossen; sie wird
bei fehlender oder leerer PHP-Umgebungsvariable `DATABASE_URL` verwendet. Die
mit übertragene `.htaccess` verweigert den HTTP-Zugriff auf diese Datei.
Voraussetzung ist ein konfigurierter OpenSSH-Zugang mit dem Host-Alias
`boogie`.
## Datenbankgestützte Buchungsdemo
Die Buchungsdemo lädt ihre Termine aus MariaDB und speichert eine Anmeldung
über `public/perspektivwerk/booking/api.php`. Die API wird unabhängig von
Contao direkt durch Apache ausgeliefert. In der statischen Vorschau liegt sie
relativ unter `/perspektivwerk/booking/api.php`.
Vor dem ersten Aufruf müssen die externen Migrationen eingespielt werden:
```powershell
.\scripts\Invoke-BookingMigrations.ps1
```
`database/migrations/001_booking_schema.sql` ist produktionsgeeignet
versioniert und idempotent. Sie enthält Tabellen für Termine und Anmeldungen
sowie klar erkennbare Demo-Termine. Die API prüft Kapazitäten innerhalb einer
Transaktion und sperrt den Termin dabei mit `SELECT … FOR UPDATE`; parallele
Anmeldungen können dadurch die Kapazität nicht überschreiten.
Die Demo speichert eine Anfrage zunächst als `pending_confirmation` und
übergibt die Bestätigungsnachricht direkt an das Server-Sendmail. Der Link ist
30 Minuten gültig; sein Aufruf bestätigt die Teilnahme atomar und löst die
zweite Nachricht mit ICS-Anhang aus. Ohne Cronjob werden keine Erinnerungen
verschickt. Lokal ist der Mailtransport bewusst mit
`BOOKING_MAIL_TRANSPORT=disabled` deaktiviert.
Der Browser erhält ein CSRF-Token per gleicher Sitzung; die API validiert alle
Pflichtdaten serverseitig und begrenzt Anmeldungen pro Sitzung. Dies ist eine
Demonstration der Schutzrichtung, ersetzt aber keine gesonderte
Sicherheitsanalyse für den Produktivbetrieb.
### API verwenden
Die API ist unter `/booking/api.php` erreichbar und akzeptiert ausschließlich
gleichartige Same-Origin-Anfragen. Sie liefert JSON und verwendet keine
Zugangsdaten im Browser.
Termine eines Monats laden (`to` ist der erste Tag des Folgemonats):
```text
GET /booking/api.php?project=perspektivwerk&from=2026-09-01&to=2026-10-01
```
`project` ist entweder `perspektivwerk` oder `produktionsschule`. Der Zeitraum
darf höchstens 93 Tage umfassen. Die Antwort enthält ein `csrfToken` und eine
Liste `events`. Ein Event enthält unter anderem `id`, `title`, `startsAt`,
`endsAt`, `capacity`, `availableCapacity`, `status` (`available` oder `full`),
`format` (`vor_ort` oder `telefonisch`) und `introductionRequired`.
Eine Anmeldung wird per `POST` angelegt. Dazu muss dieselbe Browser-Sitzung wie
beim `GET` verwendet und das erhaltene Token im Header
`X-SUFW-Booking-CSRF` übertragen werden:
```http
POST /booking/api.php
Content-Type: application/json
X-SUFW-Booking-CSRF: <csrfToken-aus-dem-GET>
{
"project": "perspektivwerk",
"eventId": "6c7f216b-2704-4c81-b17a-000000000001",
"name": "Max Mustermann",
"email": "max@example.org",
"phone": "+49 351 123456",
"privacyAccepted": true,
"introductionAttended": false,
"introductionDate": ""
}
```
Für ein PS-`Probearbeiten` mit `introductionRequired: true` müssen
`introductionAttended` den Wert `true` und `introductionDate` ein gültiges
Datum im Format `YYYY-MM-DD` enthalten. Bei Erfolg antwortet die Demo mit
`202 Accepted`; bei einem inzwischen ausgebuchten oder doppelt gebuchten Termin
mit `409 Conflict`; fehlerhafte Pflichtangaben erhalten `422`.
Der Link ruft `GET /booking/confirm.php?token=<Token>` auf. Erst dort wird die
Teilnahme in den Status `confirmed` überführt. Nicht bestätigte Anfragen laufen
nach 30 Minuten ab.
## Eigenständiges PHP-Backend
Die Testverwaltung läuft unabhängig von Contao unter
<http://localhost:8080/perspektivwerk/booking-admin/>. Sie bietet für PW und PS:
- Termin anlegen, bearbeiten sowie aktivieren/deaktivieren,
- Übersicht und Entfernen von Teilnahmen,
- Anzeige bestätigter Teilnahmen nach frei wählbarem Zeitraum,
- abonnierbaren ICS-Feed je Projekt.
Der Verwaltungsbereich hat absichtlich keine Benutzerverwaltung
und darf nicht unverändert außerhalb der Testumgebung bereitgestellt werden.
Er ist eine eigenständige PHP-Seite, keine Contao-Komponente.
Die zeitgesteuerten Aufgaben können zusätzlich ohne Browser ausgeführt werden:
```powershell
docker compose exec app php scripts/Run-BookingJobs.php
```
Für einen späteren Betrieb muss dieser Aufruf als geplanter Job erfolgen. Die
ICS-Adresse zeigt die Testverwaltung an; sie enthält nur Terminmetadaten und
keine Daten der Teilnehmenden.
## Zwei Einbindungswege
Die Terminbuchungskomponente hat eine Vorschau auf einem Webseiten-Nachbau der Orginalseite:
**Apache-Vorschau:** auf der statischen Perspektivwerk-Seite unter <http://localhost:8080/perspektivwerk/>. Die Seite liegt in `public/perspektivwerk/`; ihre Referenzen auf die Komponente verwenden die relativen Pfade `js/booking.css` und `js/booking.js`.
2. **Contao:** als eigenständiges Inhaltselement vom Typ **HTML** auf einer Contao-Seite. Es enthält das kombinierte Snippet mit CSS, Komponentenplatzhalter und JavaScript.
`public/` ist der Apache-Webroot der lokalen Entwicklungsumgebung. Daher ist kein Kopieren der Komponentenassets nach `files/terminbuchung/` erforderlich.
## Statische Buchungsvorschau in Contao einbinden
Die Komponente lädt Termin- und Kapazitätsdaten über die beschriebene API. Ein
Klick auf einen freien Termin öffnet eine modale Eingabemaske für Name,
E-Mail-Adresse, Telefonnummer und die DSGVO-Kenntnisnahme; für Probearbeiten
kommen Einführungsveranstaltung und Datum hinzu.
Die Komponente benötigt keinen TypeScript-Compiler und keinen Build-Schritt. Regressionstests ausführen:
```powershell
npm run test
```
### Einbinden in Contao
Ein **neues** Inhaltselement vom Typ **HTML** anlegen. Im Feld **HTML-Code** den vollständigen Inhalt aus [integration/contao-html-element.html](integration/contao-html-element.html) einfügen und speichern. Nicht das Inhaltselement **Text** und keinen TinyMCE-Editor verwenden: Diese können `link`- und `script`-Tags als Text maskieren. Für die Produktionsschule wird `data-project="produktionsschule"` gesetzt.
In der Seitenvorschau muss der HTML-Quelltext die Tags als `<link ...>` und `<script ...>` enthalten. Erscheinen stattdessen `&lt;link` oder `&lt;script`, wurde nicht das unmaskierte Feld **HTML-Code** verwendet und die Komponente kann nicht starten.
### Erforderliche Contao-Sicherheitseinstellung
Contao maskiert nicht freigegebene HTML-Tags auch im Inhaltselement **HTML**. Vor dem Einfügen der Komponente unter **System → Einstellungen → Sicherheit** die folgenden Einträge ergänzen:
- **Erlaubte HTML-Tags:** `script`, `link`
- **Erlaubte HTML-Attribute:** für `script` die Attribute `src`, `type`; für `link` die Attribute `href`, `rel`; für den Komponentenplatzhalter auf `div` entweder `data-*` oder mindestens `data-sufw-booking`, `data-project`.
Danach den Contao-Cache über **Systemwartung** leeren und das Inhaltselement erneut speichern. Keine globale Freigabe mit `*` verwenden. Wer ein HTML-Inhaltselement bearbeiten darf, kann mit dem freigegebenen `script`-Tag JavaScript auf der Website ausführen; diese Bearbeitungsrechte müssen daher auf vertrauenswürdige Backend-Nutzer beschränkt bleiben.
Die Termin- und Kapazitätsdaten kommen in der statischen Vorschau aus
`/perspektivwerk/booking/api.php`; Datenbankzugänge oder SQL gehören niemals in
das HTML oder das Browser-JavaScript.
`public/perspektivwerk/js/booking.js` und die zugehörige CSS-Datei sind die für
den statischen Upload bestimmte Kopie. Ein Test stellt sicher, dass beide
Asset-Paare gleich bleiben. Nach Änderungen können sie lokal synchronisiert
werden mit:
```powershell
Copy-Item public\perspektivwerk\booking\booking.js public\perspektivwerk\js\booking.js -Force
Copy-Item public\perspektivwerk\booking\booking.css public\perspektivwerk\js\booking.css -Force
```
Der vorhandene SCP-Upload überträgt den vollständigen statischen
Perspektivwerk-DocumentRoot einschließlich Buchungsdemo. Vor dem externen
Einsatz müssen die Migrationen eingespielt und `booking-config.php` mit einer
sicheren Datenbank-URL befüllt sein.
## Statische Perspektivwerk-Seite übertragen
Das Skript [scripts/Deploy-Perspektivwerk.bat](scripts/Deploy-Perspektivwerk.bat) überträgt den Ordner `public/perspektivwerk/` als vollständigen DocumentRoot per SCP und korrigiert anschließend die Lesbarkeit auf dem Server per SSH. Es lädt weder Contao noch Datenbank-Migrationen hoch.
```bat
scripts\Deploy-Perspektivwerk.bat
```
Der Hostname `boogie` wird aus der lokalen SSH-Konfiguration verwendet. Der
Ordner landet unter `/home/boogie/html/perspektivwerk/perspektivwerk/`; die
Konfiguration liegt daneben unter `/home/boogie/html/perspektivwerk/booking-config.php`.
Anschließend führt ein separater SSH-Aufruf `chmod -R a+rX
/home/boogie/html/perspektivwerk` aus. Die Passphrase des SSH-Schlüssels wird
daher in der Regel zweimal abgefragt.
Vor dem Upload muss die ignorierte `booking-config.php` befüllt sein. Das große
`X` in `a+rX` setzt das Ausführungsrecht nur auf Verzeichnissen und auf bereits
ausführbaren Dateien; normale HTML-, CSS- und JavaScript-Dateien erhalten kein
unnötiges Ausführungsbit.
## Versionierter Contao-Seitenzustand
+33
View File
@@ -35,3 +35,36 @@ Der Import bricht ab, wenn die versionierten Contao-Tabellen bereits Inhalte ent
## Medien
Medien gehören als Dateien nach `development-system/files/` und werden in Git eingecheckt. Sobald Contao-Medien verwendet werden, muss der Seed zusätzlich die Metadaten aus `tl_files` enthalten. Das ist absichtlich noch nicht automatisiert, weil die aktuelle Datenbank nur Verweise auf nicht versionierte lokale Dateien enthält.
## Terminbuchungs-Migrationen
Die Terminbuchung verwendet absichtlich keine Contao-Tabellen. Ihre separat
versionierten SQL-Migrationen liegen unter `database/migrations/`. Jede Datei
ist eigenständig und idempotent; sie kann deshalb auch später einzeln auf dem
Produktivsystem ausgeführt werden.
Lokal werden alle Migrationen in Dateireihenfolge ausgeführt mit:
```powershell
.\scripts\Invoke-BookingMigrations.ps1
```
Für ein Produktivsystem wird die jeweilige Datei mit einem dort berechtigten
MariaDB-/MySQL-Client ausgeführt. Zugangsdaten gehören dabei in die
Betriebskonfiguration und niemals in die SQL-Datei oder das Repository. Die
erste Migration erstellt `sufw_booking_events` und
`sufw_booking_registrations` sowie neun ungefährliche Demo-Termine für beide
Instanzen. `002_booking_workflow.sql` ergänzt Bestätigungstoken, den später
entfernten Test-Mailausgang und pro Projekt einen beim Import zufällig
erzeugten, widerrufbaren ICS-Feed-Token. `003_booking_demo_data.sql` legt zusätzlich
bestätigte Demo-Teilnahmen an und hält Beispieltermine jeweils einen Monat in
der Vergangenheit und Zukunft. Diese letzte Datei ist ausschließlich für die
lokale Entwicklungs- und Testdatenbank bestimmt und darf nicht auf dem
Produktivsystem ausgeführt werden. `004_booking_contact_phones.sql` ergänzt
die Telefonnummer der Ansprechperson und ersetzt die Kontakte der stabilen
Demo-Termine durch Carina Hollederer und Sarah van den Heuvel.
`005_booking_demo_july_august.sql` ergänzt ausschließlich lokal weitere
bestätigte Demo-Termine und -Teilnahmen für Juli und August.
`006_remove_booking_outbox.sql` entfernt den nicht mehr verwendeten
Test-Mailausgang; Buchungsnachrichten werden direkt an das Server-Sendmail
übergeben.
@@ -0,0 +1,69 @@
-- SUFW Terminbuchung: Anfangsschema und Demo-Termine
--
-- Diese Migration ist idempotent. Sie kann sowohl in der lokalen MariaDB als
-- auch einzeln auf dem Produktivsystem ausgeführt werden.
CREATE TABLE IF NOT EXISTS `sufw_booking_schema_migrations` (
`version` varchar(100) NOT NULL,
`applied_at` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sufw_booking_events` (
`id` char(36) NOT NULL,
`project` varchar(32) NOT NULL,
`title` varchar(160) NOT NULL,
`starts_at` datetime NOT NULL,
`ends_at` datetime NOT NULL,
`capacity` tinyint unsigned NOT NULL DEFAULT 1,
`format` varchar(24) NOT NULL,
`introduction_required` tinyint(1) NOT NULL DEFAULT 0,
`contact_name` varchar(160) NOT NULL,
`contact_email` varchar(254) NOT NULL,
`is_active` tinyint(1) NOT NULL DEFAULT 1,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
KEY `sufw_booking_events_project_starts_at` (`project`, `starts_at`),
CONSTRAINT `sufw_booking_events_project_check` CHECK (`project` IN ('perspektivwerk', 'produktionsschule')),
CONSTRAINT `sufw_booking_events_capacity_check` CHECK (`capacity` BETWEEN 1 AND 100),
CONSTRAINT `sufw_booking_events_format_check` CHECK (`format` IN ('vor_ort', 'telefonisch')),
CONSTRAINT `sufw_booking_events_dates_check` CHECK (`ends_at` > `starts_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sufw_booking_registrations` (
`id` char(36) NOT NULL,
`event_id` char(36) NOT NULL,
`status` varchar(24) NOT NULL,
`name` varchar(160) NOT NULL,
`email` varchar(254) NOT NULL,
`phone` varchar(64) NOT NULL,
`privacy_accepted_at` datetime NOT NULL,
`introduction_attended` tinyint(1) NOT NULL DEFAULT 0,
`introduction_date` date DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
KEY `sufw_booking_registrations_event_status` (`event_id`, `status`),
KEY `sufw_booking_registrations_person` (`email`, `name`),
CONSTRAINT `sufw_booking_registrations_status_check` CHECK (`status` IN ('requested', 'pending_confirmation', 'confirmed', 'removed', 'expired')),
CONSTRAINT `sufw_booking_registrations_introduction_check` CHECK ((`introduction_attended` = 0 AND `introduction_date` IS NULL) OR (`introduction_attended` = 1 AND `introduction_date` IS NOT NULL)),
CONSTRAINT `sufw_booking_registrations_event_fk` FOREIGN KEY (`event_id`) REFERENCES `sufw_booking_events` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Die IDs sind bewusst stabil, damit INSERT IGNORE diese Beispieldaten bei
-- wiederholter Ausführung nicht dupliziert.
INSERT IGNORE INTO `sufw_booking_events`
(`id`, `project`, `title`, `starts_at`, `ends_at`, `capacity`, `format`, `introduction_required`, `contact_name`, `contact_email`)
VALUES
('6c7f216b-2704-4c81-b17a-000000000001', 'perspektivwerk', 'Erstgespräch PerspektivWerk', '2026-09-02 09:00:00', '2026-09-02 10:00:00', 2, 'vor_ort', 0, 'PerspektivWerk-Team', 'perspektivwerk@sufw.de'),
('6c7f216b-2704-4c81-b17a-000000000002', 'perspektivwerk', 'Soz. Päd. Modul PerspektivWerk', '2026-09-04 09:00:00', '2026-09-04 13:00:00', 1, 'vor_ort', 0, 'PerspektivWerk-Team', 'perspektivwerk@sufw.de'),
('6c7f216b-2704-4c81-b17a-000000000003', 'perspektivwerk', 'Psy Modul PerspektivWerk', '2026-09-09 09:00:00', '2026-09-09 13:00:00', 2, 'telefonisch', 0, 'PerspektivWerk-Team', 'perspektivwerk@sufw.de'),
('6c7f216b-2704-4c81-b17a-000000000004', 'perspektivwerk', 'Praxis Modul PerspektivWerk', '2026-09-14 08:00:00', '2026-09-18 16:00:00', 5, 'vor_ort', 0, 'PerspektivWerk-Team', 'perspektivwerk@sufw.de'),
('6c7f216b-2704-4c81-b17a-000000000005', 'perspektivwerk', 'Erstgespräch PerspektivWerk', '2026-10-01 11:00:00', '2026-10-01 12:00:00', 1, 'telefonisch', 0, 'PerspektivWerk-Team', 'perspektivwerk@sufw.de'),
('6c7f216b-2704-4c81-b17a-000000000006', 'produktionsschule', 'Voranmeldung Kennenlerntermin Produktionsschule', '2026-09-01 10:00:00', '2026-09-01 11:00:00', 3, 'vor_ort', 0, 'Produktionsschule-Team', 'produktionsschule@sufw.de'),
('6c7f216b-2704-4c81-b17a-000000000007', 'produktionsschule', 'Voranmeldung Kennenlerntermin Produktionsschule', '2026-09-08 10:00:00', '2026-09-08 11:00:00', 3, 'vor_ort', 0, 'Produktionsschule-Team', 'produktionsschule@sufw.de'),
('6c7f216b-2704-4c81-b17a-000000000008', 'produktionsschule', 'Probearbeiten', '2026-09-14 08:00:00', '2026-09-18 16:00:00', 2, 'vor_ort', 1, 'Produktionsschule-Team', 'produktionsschule@sufw.de'),
('6c7f216b-2704-4c81-b17a-000000000009', 'produktionsschule', 'Voranmeldung Kennenlerntermin Produktionsschule', '2026-10-06 10:00:00', '2026-10-06 11:00:00', 3, 'telefonisch', 0, 'Produktionsschule-Team', 'produktionsschule@sufw.de');
INSERT IGNORE INTO `sufw_booking_schema_migrations` (`version`) VALUES ('001_booking_schema');
@@ -0,0 +1,40 @@
-- SUFW Terminbuchung: Bestätigungsfluss, Test-Mailausgang und iCalendar-Feeds
-- Diese Migration ergänzt das Anfangsschema und kann gefahrlos erneut laufen.
ALTER TABLE `sufw_booking_registrations`
ADD COLUMN IF NOT EXISTS `confirmation_token_hash` char(64) DEFAULT NULL AFTER `privacy_accepted_at`,
ADD COLUMN IF NOT EXISTS `confirmation_expires_at` datetime DEFAULT NULL AFTER `confirmation_token_hash`,
ADD COLUMN IF NOT EXISTS `confirmed_at` datetime DEFAULT NULL AFTER `confirmation_expires_at`,
ADD COLUMN IF NOT EXISTS `removed_at` datetime DEFAULT NULL AFTER `confirmed_at`;
CREATE TABLE IF NOT EXISTS `sufw_booking_project_settings` (
`project` varchar(32) NOT NULL,
`feed_token` char(64) NOT NULL,
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`project`),
UNIQUE KEY `sufw_booking_project_settings_feed_token` (`feed_token`),
CONSTRAINT `sufw_booking_project_settings_project_check` CHECK (`project` IN ('perspektivwerk', 'produktionsschule'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sufw_booking_outbox` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`registration_id` char(36) NOT NULL,
`kind` varchar(32) NOT NULL,
`recipient_email` varchar(254) NOT NULL,
`subject` varchar(255) NOT NULL,
`body` text NOT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`sent_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `sufw_booking_outbox_registration_kind` (`registration_id`, `kind`),
KEY `sufw_booking_outbox_pending` (`sent_at`, `created_at`),
CONSTRAINT `sufw_booking_outbox_kind_check` CHECK (`kind` IN ('confirmation_request', 'confirmation', 'reminder')),
CONSTRAINT `sufw_booking_outbox_registration_fk` FOREIGN KEY (`registration_id`) REFERENCES `sufw_booking_registrations` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO `sufw_booking_project_settings` (`project`, `feed_token`)
VALUES
('perspektivwerk', SHA2(CONCAT(UUID(), RAND(), NOW(6)), 256)),
('produktionsschule', SHA2(CONCAT(UUID(), RAND(), NOW(6)), 256));
INSERT IGNORE INTO `sufw_booking_schema_migrations` (`version`) VALUES ('002_booking_workflow');
@@ -0,0 +1,29 @@
-- SUFW Terminbuchung: Lokale Demo-Termine und bestätigte Teilnahmen
--
-- Nur für lokale Entwicklungs- und Testdatenbanken. Diese Datei darf nicht
-- auf einem Produktivsystem ausgeführt werden: Sie legt bewusst Testpersonen
-- und Beispieltermine an. Die stabilen IDs verhindern Duplikate; beim erneuten
-- Ausführen werden die Termine relativ zum heutigen Datum aktualisiert.
INSERT INTO `sufw_booking_events`
(`id`, `project`, `title`, `starts_at`, `ends_at`, `capacity`, `format`, `introduction_required`, `contact_name`, `contact_email`, `is_active`)
VALUES
('d3c7f216-2704-4c81-b17a-000000000001', 'perspektivwerk', 'Erstgespräch PerspektivWerk', TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL -1 MONTH), '09:00:00'), TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL -1 MONTH), '10:00:00'), 3, 'vor_ort', 0, 'Anna Müller', 'anna.mueller@example.test', 1),
('d3c7f216-2704-4c81-b17a-000000000002', 'perspektivwerk', 'Psy Modul PerspektivWerk', TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL 1 MONTH), '13:15:00'), TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL 1 MONTH), '17:15:00'), 2, 'telefonisch', 0, 'Anna Müller', 'anna.mueller@example.test', 1),
('d3c7f216-2704-4c81-b17a-000000000003', 'produktionsschule', 'Voranmeldung Kennenlerntermin Produktionsschule', TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL -1 MONTH), '10:30:00'), TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL -1 MONTH), '11:30:00'), 3, 'vor_ort', 0, 'Bernd Fischer', 'bernd.fischer@example.test', 1),
('d3c7f216-2704-4c81-b17a-000000000004', 'produktionsschule', 'Probearbeiten', TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL 1 MONTH), '08:00:00'), TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL 1 MONTH), '16:00:00'), 3, 'vor_ort', 1, 'Bernd Fischer', 'bernd.fischer@example.test', 1)
ON DUPLICATE KEY UPDATE
`title` = VALUES(`title`), `starts_at` = VALUES(`starts_at`), `ends_at` = VALUES(`ends_at`), `capacity` = VALUES(`capacity`), `format` = VALUES(`format`), `introduction_required` = VALUES(`introduction_required`), `contact_name` = VALUES(`contact_name`), `contact_email` = VALUES(`contact_email`), `is_active` = VALUES(`is_active`);
INSERT INTO `sufw_booking_registrations`
(`id`, `event_id`, `status`, `name`, `email`, `phone`, `privacy_accepted_at`, `confirmed_at`, `introduction_attended`, `introduction_date`)
VALUES
('e3c7f216-2704-4c81-b17a-000000000001', 'd3c7f216-2704-4c81-b17a-000000000001', 'confirmed', 'Demo Alina Beispiel', 'alina.beispiel@example.test', '+49 000 100001', DATE_SUB(NOW(), INTERVAL 35 DAY), DATE_SUB(NOW(), INTERVAL 35 DAY), 0, NULL),
('e3c7f216-2704-4c81-b17a-000000000002', 'd3c7f216-2704-4c81-b17a-000000000001', 'confirmed', 'Demo Cem Beispiel', 'cem.beispiel@example.test', '+49 000 100002', DATE_SUB(NOW(), INTERVAL 34 DAY), DATE_SUB(NOW(), INTERVAL 34 DAY), 0, NULL),
('e3c7f216-2704-4c81-b17a-000000000003', 'd3c7f216-2704-4c81-b17a-000000000002', 'confirmed', 'Demo Dana Beispiel', 'dana.beispiel@example.test', '+49 000 100003', NOW(), NOW(), 0, NULL),
('e3c7f216-2704-4c81-b17a-000000000004', 'd3c7f216-2704-4c81-b17a-000000000003', 'confirmed', 'Demo Emir Beispiel', 'emir.beispiel@example.test', '+49 000 100004', DATE_SUB(NOW(), INTERVAL 35 DAY), DATE_SUB(NOW(), INTERVAL 35 DAY), 0, NULL),
('e3c7f216-2704-4c81-b17a-000000000005', 'd3c7f216-2704-4c81-b17a-000000000004', 'confirmed', 'Demo Frida Beispiel', 'frida.beispiel@example.test', '+49 000 100005', NOW(), NOW(), 1, CURDATE())
ON DUPLICATE KEY UPDATE
`event_id` = VALUES(`event_id`), `status` = VALUES(`status`), `name` = VALUES(`name`), `email` = VALUES(`email`), `phone` = VALUES(`phone`), `privacy_accepted_at` = VALUES(`privacy_accepted_at`), `confirmed_at` = VALUES(`confirmed_at`), `introduction_attended` = VALUES(`introduction_attended`), `introduction_date` = VALUES(`introduction_date`), `removed_at` = NULL;
INSERT IGNORE INTO `sufw_booking_schema_migrations` (`version`) VALUES ('003_booking_demo_data');
@@ -0,0 +1,34 @@
-- SUFW Terminbuchung: Telefonnummern der Ansprechpersonen
--
-- Eigenständige, idempotente Migration. Sie erweitert das Terminmodell um
-- die Telefonnummer der fachlichen Ansprechperson.
ALTER TABLE `sufw_booking_events`
ADD COLUMN IF NOT EXISTS `contact_phone` varchar(64) NOT NULL DEFAULT '' AFTER `contact_email`;
-- Die fest versionierten Demo-Termine verwenden die abgestimmten Kontaktdaten.
-- Eigene, später angelegte Termine bleiben von diesen Updates unberührt.
UPDATE `sufw_booking_events`
SET `contact_name` = 'Carina Hollederer', `contact_email` = 'c.hollederer@sufw.de', `contact_phone` = '(0176) 17922244'
WHERE `id` IN (
'6c7f216b-2704-4c81-b17a-000000000001',
'6c7f216b-2704-4c81-b17a-000000000002',
'6c7f216b-2704-4c81-b17a-000000000003',
'6c7f216b-2704-4c81-b17a-000000000004',
'6c7f216b-2704-4c81-b17a-000000000005',
'd3c7f216-2704-4c81-b17a-000000000001',
'd3c7f216-2704-4c81-b17a-000000000002'
);
UPDATE `sufw_booking_events`
SET `contact_name` = 'Sarah van den Heuvel', `contact_email` = 's.vandenHeuvel@sufw.de', `contact_phone` = '(0176) 60328929'
WHERE `id` IN (
'6c7f216b-2704-4c81-b17a-000000000006',
'6c7f216b-2704-4c81-b17a-000000000007',
'6c7f216b-2704-4c81-b17a-000000000008',
'6c7f216b-2704-4c81-b17a-000000000009',
'd3c7f216-2704-4c81-b17a-000000000003',
'd3c7f216-2704-4c81-b17a-000000000004'
);
INSERT IGNORE INTO `sufw_booking_schema_migrations` (`version`) VALUES ('004_booking_contact_phones');
@@ -0,0 +1,26 @@
-- SUFW Terminbuchung: Zusätzliche lokale Demo-Termine für Juli und August
--
-- Nur für lokale Entwicklungs- und Testdatenbanken. Stabile IDs sorgen dafür,
-- dass die Beispieldaten bei erneutem Ausführen nicht dupliziert werden.
INSERT INTO `sufw_booking_events`
(`id`, `project`, `title`, `starts_at`, `ends_at`, `capacity`, `format`, `introduction_required`, `contact_name`, `contact_email`, `contact_phone`, `is_active`)
VALUES
('f5c7f216-2704-4c81-b17a-000000000001', 'perspektivwerk', 'Soz. Päd. Modul PerspektivWerk', CONCAT(YEAR(CURDATE()), '-07-10 09:00:00'), CONCAT(YEAR(CURDATE()), '-07-10 13:00:00'), 2, 'vor_ort', 0, 'Carina Hollederer', 'c.hollederer@sufw.de', '(0176) 17922244', 1),
('f5c7f216-2704-4c81-b17a-000000000002', 'produktionsschule', 'Voranmeldung Kennenlerntermin Produktionsschule', CONCAT(YEAR(CURDATE()), '-07-10 10:00:00'), CONCAT(YEAR(CURDATE()), '-07-10 11:00:00'), 3, 'vor_ort', 0, 'Sarah van den Heuvel', 's.vandenHeuvel@sufw.de', '(0176) 60328929', 1),
('f5c7f216-2704-4c81-b17a-000000000003', 'perspektivwerk', 'Erstgespräch PerspektivWerk', CONCAT(YEAR(CURDATE()), '-08-12 09:00:00'), CONCAT(YEAR(CURDATE()), '-08-12 10:00:00'), 2, 'vor_ort', 0, 'Carina Hollederer', 'c.hollederer@sufw.de', '(0176) 17922244', 1),
('f5c7f216-2704-4c81-b17a-000000000004', 'produktionsschule', 'Probearbeiten', CONCAT(YEAR(CURDATE()), '-08-12 08:00:00'), CONCAT(YEAR(CURDATE()), '-08-12 16:00:00'), 2, 'vor_ort', 1, 'Sarah van den Heuvel', 's.vandenHeuvel@sufw.de', '(0176) 60328929', 1)
ON DUPLICATE KEY UPDATE
`title` = VALUES(`title`), `starts_at` = VALUES(`starts_at`), `ends_at` = VALUES(`ends_at`), `capacity` = VALUES(`capacity`), `format` = VALUES(`format`), `introduction_required` = VALUES(`introduction_required`), `contact_name` = VALUES(`contact_name`), `contact_email` = VALUES(`contact_email`), `contact_phone` = VALUES(`contact_phone`), `is_active` = VALUES(`is_active`);
INSERT INTO `sufw_booking_registrations`
(`id`, `event_id`, `status`, `name`, `email`, `phone`, `privacy_accepted_at`, `confirmed_at`, `introduction_attended`, `introduction_date`)
VALUES
('g5c7f216-2704-4c81-b17a-000000000001', 'f5c7f216-2704-4c81-b17a-000000000001', 'confirmed', 'Demo Greta Juli', 'greta.juli@example.test', '+49 000 100006', DATE_SUB(NOW(), INTERVAL 50 DAY), DATE_SUB(NOW(), INTERVAL 50 DAY), 0, NULL),
('g5c7f216-2704-4c81-b17a-000000000002', 'f5c7f216-2704-4c81-b17a-000000000002', 'confirmed', 'Demo Hasan Juli', 'hasan.juli@example.test', '+49 000 100007', DATE_SUB(NOW(), INTERVAL 50 DAY), DATE_SUB(NOW(), INTERVAL 50 DAY), 0, NULL),
('g5c7f216-2704-4c81-b17a-000000000003', 'f5c7f216-2704-4c81-b17a-000000000003', 'confirmed', 'Demo Ines August', 'ines.august@example.test', '+49 000 100008', DATE_SUB(NOW(), INTERVAL 20 DAY), DATE_SUB(NOW(), INTERVAL 20 DAY), 0, NULL),
('g5c7f216-2704-4c81-b17a-000000000004', 'f5c7f216-2704-4c81-b17a-000000000004', 'confirmed', 'Demo Jakob August', 'jakob.august@example.test', '+49 000 100009', DATE_SUB(NOW(), INTERVAL 20 DAY), DATE_SUB(NOW(), INTERVAL 20 DAY), 1, CURDATE())
ON DUPLICATE KEY UPDATE
`event_id` = VALUES(`event_id`), `status` = VALUES(`status`), `name` = VALUES(`name`), `email` = VALUES(`email`), `phone` = VALUES(`phone`), `privacy_accepted_at` = VALUES(`privacy_accepted_at`), `confirmed_at` = VALUES(`confirmed_at`), `introduction_attended` = VALUES(`introduction_attended`), `introduction_date` = VALUES(`introduction_date`), `removed_at` = NULL;
INSERT IGNORE INTO `sufw_booking_schema_migrations` (`version`) VALUES ('005_booking_demo_july_august');
@@ -0,0 +1,6 @@
-- SUFW Terminbuchung: Der Test-Mailausgang wird durch direkten Sendmail-Versand ersetzt.
-- Diese Migration entfernt ausschließlich die nicht mehr verwendete Outbox-Tabelle.
DROP TABLE IF EXISTS `sufw_booking_outbox`;
INSERT IGNORE INTO `sufw_booking_schema_migrations` (`version`) VALUES ('006_remove_booking_outbox');
+2
View File
@@ -9,6 +9,8 @@ services:
APP_ENV: dev
CONTAO_ENVIRONMENT: dev
DATABASE_URL: mysql://perspektivwerk:perspektivwerk@db:3306/perspektivwerk?serverVersion=mariadb-10.11.0
BOOKING_BASE_URL: http://localhost:8080/perspektivwerk
BOOKING_MAIL_TRANSPORT: disabled
command:
- sh
- -c
@@ -0,0 +1,9 @@
<!--
Contao: Neues Inhaltselement vom Typ "HTML" anlegen.
Diesen vollständigen Block unverändert in das Feld "HTML-Code" einfügen.
Nicht in ein Text-/TinyMCE-Element einfügen.
Für Produktionsschule data-project="produktionsschule" setzen.
-->
<link rel="stylesheet" href="/booking/booking.css">
<div data-sufw-booking data-project="perspektivwerk" data-api-url="/booking/api.php"></div>
<script type="module" src="/booking/booking.js"></script>
+1 -53
View File
@@ -6,13 +6,7 @@
"": {
"name": "perspektivwerk-booking-frontend",
"dependencies": {
"bootstrap": "^5.3.3",
"jquery": "^3.7.1",
"slick-carousel": "^1.8.1"
},
"devDependencies": {
"@types/jquery": "^3.5.32",
"typescript": "^5.7.2"
"bootstrap": "^5.3.3"
}
},
"node_modules/@popperjs/core": {
@@ -26,23 +20,6 @@
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@types/jquery": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.34.tgz",
"integrity": "sha512-3m3939S3erqmTLJANS/uy0B6V7BorKx7RorcGZVjZ62dF5PAGbKEDZK1CuLtKombJkFA2T1jl8LAIIs7IV6gBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/sizzle": "*"
}
},
"node_modules/@types/sizzle": {
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz",
"integrity": "sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==",
"dev": true,
"license": "MIT"
},
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
@@ -61,35 +38,6 @@
"peerDependencies": {
"@popperjs/core": "^2.11.8"
}
},
"node_modules/jquery": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==",
"license": "MIT"
},
"node_modules/slick-carousel": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/slick-carousel/-/slick-carousel-1.8.1.tgz",
"integrity": "sha512-XB9Ftrf2EEKfzoQXt3Nitrt/IPbT+f1fgqBdoxO3W/+JYvtEOW6EgxnWfr9GH6nmULv7Y2tPmEX3koxThVmebA==",
"license": "MIT",
"peerDependencies": {
"jquery": ">=1.8.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+2 -9
View File
@@ -3,16 +3,9 @@
"private": true,
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
"test": "node --test tests/*.test.mjs"
},
"dependencies": {
"bootstrap": "^5.3.3",
"jquery": "^3.7.1",
"slick-carousel": "^1.8.1"
},
"devDependencies": {
"@types/jquery": "^3.5.32",
"typescript": "^5.7.2"
"bootstrap": "^5.3.3"
}
}
+14
View File
@@ -1,3 +1,13 @@
<Files "booking-config.php">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
</Files>
<IfModule mod_rewrite.c>
RewriteEngine On
@@ -44,6 +54,10 @@
# directory request to Contao's front controller.
RewriteRule ^perspektivwerk/?$ perspektivwerk/index.html [L]
# The standalone booking administration lives below the static mockup and
# must not be handled by Contao's front controller.
RewriteRule ^perspektivwerk/booking-admin/?$ perspektivwerk/booking-admin/index.php [L]
# If the requested filename exists, simply serve it.
# We only want to let Apache serve files and not directories.
RewriteCond %{REQUEST_FILENAME} -f
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
// Copy this file to booking-config.php, then insert the production database URL.
// booking-config.php is intentionally excluded from Git and denied by public/.htaccess.
return [
'database_url' => 'mysql://USER:PASSWORD@database-host:3306/booking_database',
];
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
require __DIR__.'/../booking/backend.php';
session_name('sufw_booking_admin');
session_start();
$csrf = $_SESSION['csrf'] ??= bin2hex(random_bytes(32));
$database = bookingDatabase();
$project = (string) ($_GET['project'] ?? $_POST['project'] ?? 'perspektivwerk');
if (!bookingProjectIsValid($project)) $project = 'perspektivwerk';
$view = (string) ($_GET['view'] ?? 'events');
if (!in_array($view, ['events', 'registrations', 'export', 'export-data'], true)) $view = 'events';
function h(mixed $value): string { return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
function isQuarterHourDateTime(string $value): bool {
$timestamp = strtotime($value);
return $timestamp !== false && date('s', $timestamp) === '00' && ((int) date('i', $timestamp) % 15) === 0;
}
function adminBack(string $project, string $view, string $notice): never {
header('Location: ?project='.rawurlencode($project).'&view='.rawurlencode($view).'&notice='.rawurlencode($notice));
exit;
}
if ($view === 'export-data') {
$from = (string) ($_GET['from'] ?? '');
$to = (string) ($_GET['to'] ?? '');
$exportProject = (string) ($_GET['filterProject'] ?? 'all');
if (!in_array($exportProject, ['all', 'perspektivwerk', 'produktionsschule'], true)) $exportProject = 'all';
header('Content-Type: application/json; charset=utf-8');
if (!preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $from) || !preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $to) || strtotime($from) === false || strtotime($to) === false || strtotime($from) > strtotime($to)) {
echo json_encode(['error' => 'Bitte wählen Sie einen gültigen Zeitraum.'], JSON_UNESCAPED_UNICODE);
exit;
}
$sql = "SELECT e.title, DATE(e.starts_at) AS event_day, r.name FROM sufw_booking_registrations r JOIN sufw_booking_events e ON e.id = r.event_id WHERE r.status = 'confirmed' AND e.starts_at BETWEEN :fromDate AND :toDate";
$parameters = ['fromDate' => $from.' 00:00:00', 'toDate' => $to.' 23:59:59'];
if ($exportProject !== 'all') { $sql .= ' AND e.project = :exportProject'; $parameters['exportProject'] = $exportProject; }
$statement = $database->prepare($sql.' ORDER BY e.starts_at, r.name');
$statement->execute($parameters);
echo json_encode(['registrations' => $statement->fetchAll()], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!hash_equals($csrf, (string) ($_POST['csrf'] ?? ''))) { http_response_code(403); exit('Ungültige Sitzung.'); }
$action = (string) ($_POST['action'] ?? '');
if ($action === 'save_event') {
$id = (string) ($_POST['id'] ?? '') ?: bookingUuid();
$title = trim((string) ($_POST['title'] ?? ''));
$startsAt = str_replace('T', ' ', (string) ($_POST['starts_at'] ?? ''));
$endsAt = str_replace('T', ' ', (string) ($_POST['ends_at'] ?? ''));
$capacity = (int) ($_POST['capacity'] ?? 0);
$format = (string) ($_POST['format'] ?? 'vor_ort');
$contactName = trim((string) ($_POST['contact_name'] ?? ''));
$contactEmail = trim((string) ($_POST['contact_email'] ?? ''));
$contactPhone = trim((string) ($_POST['contact_phone'] ?? ''));
if ($title === '' || $capacity < 1 || $capacity > 100 || !in_array($format, ['vor_ort', 'telefonisch'], true) || $contactName === '' || $contactPhone === '' || !filter_var($contactEmail, FILTER_VALIDATE_EMAIL) || !isQuarterHourDateTime($startsAt) || !isQuarterHourDateTime($endsAt) || strtotime($endsAt) <= strtotime($startsAt)) adminBack($project, 'events', 'Bitte prüfen Sie die Termindaten. Uhrzeiten sind nur im 15-Minuten-Raster zulässig.');
$save = $database->prepare("INSERT INTO sufw_booking_events (id, project, title, starts_at, ends_at, capacity, format, introduction_required, contact_name, contact_email, contact_phone) VALUES (:id, :project, :title, :startsAt, :endsAt, :capacity, :format, :introductionRequired, :contactName, :contactEmail, :contactPhone) ON DUPLICATE KEY UPDATE title = VALUES(title), starts_at = VALUES(starts_at), ends_at = VALUES(ends_at), capacity = VALUES(capacity), format = VALUES(format), introduction_required = VALUES(introduction_required), contact_name = VALUES(contact_name), contact_email = VALUES(contact_email), contact_phone = VALUES(contact_phone)");
$save->execute(['id' => $id, 'project' => $project, 'title' => $title, 'startsAt' => $startsAt, 'endsAt' => $endsAt, 'capacity' => $capacity, 'format' => $format, 'introductionRequired' => isset($_POST['introduction_required']) ? 1 : 0, 'contactName' => $contactName, 'contactEmail' => $contactEmail, 'contactPhone' => $contactPhone]);
adminBack($project, 'events', 'Termin gespeichert.');
}
if ($action === 'toggle_event') {
$database->prepare('UPDATE sufw_booking_events SET is_active = 1 - is_active WHERE id = :id AND project = :project')->execute(['id' => $_POST['id'] ?? '', 'project' => $project]);
adminBack($project, 'events', 'Terminstatus geändert.');
}
if ($action === 'remove_registration') {
$database->prepare("UPDATE sufw_booking_registrations SET status = 'removed', removed_at = UTC_TIMESTAMP() WHERE id = :id AND status IN ('pending_confirmation', 'confirmed')")->execute(['id' => $_POST['id'] ?? '']);
adminBack($project, 'registrations', 'Teilnahme entfernt; die Kapazität ist wieder frei.');
}
}
$titles = $database->prepare('SELECT DISTINCT title FROM sufw_booking_events WHERE project = :project ORDER BY title');
$titles->execute(['project' => $project]);
$titles = $titles->fetchAll(PDO::FETCH_COLUMN);
$contacts = $database->prepare('SELECT DISTINCT contact_name, contact_email, contact_phone FROM sufw_booking_events WHERE project = :project ORDER BY contact_name, contact_email');
$contacts->execute(['project' => $project]);
$contactByName = [];
$contactByEmail = [];
foreach ($contacts as $contact) { $contactByName[$contact['contact_name']] ??= ['email' => $contact['contact_email'], 'phone' => $contact['contact_phone']]; $contactByEmail[$contact['contact_email']] ??= ['name' => $contact['contact_name'], 'phone' => $contact['contact_phone']]; }
$settings = $database->prepare('SELECT feed_token FROM sufw_booking_project_settings WHERE project = :project');
$settings->execute(['project' => $project]);
$feedToken = (string) $settings->fetchColumn();
$notice = (string) ($_GET['notice'] ?? '');
$events = [];
$edit = null;
if ($view === 'events') {
$statement = $database->prepare("SELECT e.*, COUNT(CASE WHEN r.status = 'confirmed' THEN 1 END) AS confirmed_count FROM sufw_booking_events e LEFT JOIN sufw_booking_registrations r ON r.event_id = e.id WHERE e.project = :project GROUP BY e.id ORDER BY e.starts_at");
$statement->execute(['project' => $project]);
$events = $statement->fetchAll();
foreach ($events as $event) if ($event['id'] === ($_GET['edit'] ?? '')) $edit = $event;
}
$registrations = [];
if ($view === 'registrations') {
$statement = $database->prepare("SELECT r.*, e.title, e.starts_at FROM sufw_booking_registrations r JOIN sufw_booking_events e ON e.id = r.event_id WHERE e.project = :project ORDER BY r.created_at DESC LIMIT 200");
$statement->execute(['project' => $project]);
$registrations = $statement->fetchAll();
}
$nextMonth = new DateTimeImmutable('first day of next month');
$defaultFrom = $nextMonth->format('Y-m-01');
$defaultTo = $nextMonth->format('Y-m-t');
?>
<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Terminbuchung · Testverwaltung</title>
<style>
:root{--green:#08752a;--bg:#f3f6f3;--line:#cad2cb;--ink:#202020}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.45 Arial,sans-serif}.shell{max-width:1180px;margin:auto;padding:1.5rem}header{display:flex;gap:1rem;justify-content:space-between;align-items:start;flex-wrap:wrap}h1,h2{color:var(--green)}h1{margin:0}.warning,.notice{padding:.75rem 1rem;border-left:4px solid #a36a00;background:#fff8e7}.notice{border-color:var(--green);background:#eaf6ed}.tabs{display:flex;flex-wrap:wrap;gap:.5rem;margin:1rem 0}.tabs a,.button,button{display:inline-block;padding:.65rem .85rem;border:1px solid var(--green);background:#fff;color:var(--green);font:inherit;text-decoration:none;cursor:pointer}.tabs a.active,button.primary{background:var(--green);color:#fff}.grid{display:grid;gap:1.25rem;grid-template-columns:1.25fr .75fr;margin-top:1.25rem}.card{background:#fff;border:1px solid var(--line);padding:1rem;overflow:auto}table{border-collapse:collapse;width:100%;min-width:680px}th,td{padding:.6rem;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}small{color:#555}.status{font-weight:bold}.confirmed{color:#08752a}.pending_confirmation{color:#a36a00}.removed,.expired{color:#8a1515}form.inline{display:inline}label{display:grid;gap:.25rem;font-weight:bold}input,select{font:inherit;padding:.55rem;border:1px solid #68736a;width:100%}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.8rem}.wide{grid-column:1/-1}pre{white-space:pre-wrap;max-width:42rem;font:13px/1.35 ui-monospace,monospace}.date-time-trigger{width:100%;min-height:2.75rem;text-align:left}.picker-dialog{width:min(25rem,calc(100% - 2rem));border:0;padding:0;box-shadow:0 .75rem 2rem #0006}.picker-dialog::backdrop{background:#0008}.picker-content{padding:1rem}.picker-head,.picker-actions{display:flex;justify-content:space-between;gap:.5rem;align-items:center}.picker-month{font-weight:bold;text-transform:capitalize}.picker-week,.picker-days{display:grid;grid-template-columns:repeat(7,1fr);gap:.25rem;text-align:center}.picker-week{margin:1rem 0 .25rem;font-size:.8rem;font-weight:bold}.picker-days button{min-height:2.4rem;padding:.25rem;border:1px solid transparent}.picker-days button.selected{background:var(--green);color:#fff}.picker-days button:disabled{visibility:hidden}.picker-time{display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin:1rem 0}.export-results{margin-top:1rem}.export-results li{margin:.5rem 0;white-space:nowrap;overflow-x:auto}@media(max-width:760px){.grid,.form-grid{grid-template-columns:1fr}.shell{padding:1rem}}
select[data-dropdown-indicator]{appearance:none;padding-right:2.5rem;background:#fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%2308752a' d='m3 6 5 5 5-5z'/%3E%3C/svg%3E") no-repeat right .75rem center/1rem}</style></head><body><main class="shell">
<header><div><h1>Terminbuchung · Testverwaltung</h1></div></header>
<?php if ($notice !== ''): ?><p class="notice"><?=h($notice)?></p><?php endif; ?>
<nav class="tabs"><a class="<?= $view === 'events' && $project === 'perspektivwerk' ? 'active' : '' ?>" href="?project=perspektivwerk&view=events">PerspektivWerk</a><a class="<?= $view === 'events' && $project === 'produktionsschule' ? 'active' : '' ?>" href="?project=produktionsschule&view=events">Produktionsschule</a><a class="<?= $view === 'registrations' ? 'active' : '' ?>" href="?project=<?=h($project)?>&view=registrations">Teilnahmen</a><a class="<?= $view === 'export' ? 'active' : '' ?>" href="?project=<?=h($project)?>&view=export">Export</a></nav>
<?php if ($view === 'events'): ?>
<section class="grid"><article class="card"><h2><?= $edit ? 'Termin bearbeiten' : 'Termin anlegen' ?></h2><form method="post"><input type="hidden" name="csrf" value="<?=h($csrf)?>"><input type="hidden" name="action" value="save_event"><input type="hidden" name="project" value="<?=h($project)?>"><input type="hidden" name="id" value="<?=h($edit['id'] ?? '')?>"><div class="form-grid">
<label class="wide">Terminart<select name="title" required data-dropdown-indicator><option value="">Terminart wählen …</option><?php foreach ($titles as $title): ?><option value="<?=h($title)?>" <?=($edit['title'] ?? '') === $title ? 'selected' : ''?>><?=h($title)?></option><?php endforeach; ?></select></label>
<label>Beginn<input type="hidden" name="starts_at" value="<?=h($edit['starts_at'] ?? '')?>"><button type="button" class="date-time-trigger" data-datetime-picker data-target="starts_at">Datum &amp; Uhrzeit wählen</button></label><label>Ende<input type="hidden" name="ends_at" value="<?=h($edit['ends_at'] ?? '')?>"><button type="button" class="date-time-trigger" data-datetime-picker data-target="ends_at">Datum &amp; Uhrzeit wählen</button></label>
<label>Kapazität (1–100)<input type="number" min="1" max="100" name="capacity" required value="<?=h($edit['capacity'] ?? 1)?>"></label><label>Format<select name="format"><option value="vor_ort" <?=($edit['format'] ?? '') === 'vor_ort' ? 'selected' : ''?>>Vor Ort</option><option value="telefonisch" <?=($edit['format'] ?? '') === 'telefonisch' ? 'selected' : ''?>>Telefonisch</option></select></label>
<label>Ansprechperson<select id="contact_name" name="contact_name" required data-dropdown-indicator><option value="">Ansprechperson wählen …</option><?php foreach ($contactByName as $name => $contact): ?><option value="<?=h($name)?>" <?=($edit['contact_name'] ?? '') === $name ? 'selected' : ''?>><?=h($name)?></option><?php endforeach; ?></select></label><label>Kontakt-E-Mail<select id="contact_email" name="contact_email" required data-dropdown-indicator><option value="">Kontakt-E-Mail wählen …</option><?php foreach ($contactByEmail as $email => $contact): ?><option value="<?=h($email)?>" <?=($edit['contact_email'] ?? '') === $email ? 'selected' : ''?>><?=h($email)?></option><?php endforeach; ?></select></label>
<label>Telefon Ansprechperson<input id="contact_phone" name="contact_phone" type="tel" required readonly value="<?=h($edit['contact_phone'] ?? '')?>"></label>
<label class="wide"><span><input type="checkbox" name="introduction_required" <?=!empty($edit['introduction_required']) ? 'checked' : ''?>> Einführungsveranstaltung erforderlich (PS-Probearbeiten)</span></label></div><p><button class="primary">Termin speichern</button><?php if ($edit): ?> <a class="button" href="?project=<?=h($project)?>&view=events">Abbrechen</a><?php endif; ?></p></form></article>
<aside class="card"><h2>Kalenderfeed</h2><p>Nur bestätigte Termine, ohne Teilnehmendendaten:</p><p><a href="<?=h(bookingBaseUrl().'/booking/ical.php?project='.$project.'&token='.$feedToken)?>">ICS-Feed öffnen</a></p></aside></section>
<section class="card"><h2>Termine</h2><table><thead><tr><th>Termin</th><th>Zeit</th><th>Kapazität</th><th>Status</th><th></th></tr></thead><tbody><?php foreach ($events as $event): ?><tr><td><strong><?=h($event['title'])?></strong><br><small><?=h($event['format'])?><?=!empty($event['introduction_required']) ? ' · Einführung erforderlich' : ''?></small></td><td><?=h($event['starts_at'])?><br>bis <?=h($event['ends_at'])?></td><td><?=h($event['confirmed_count'])?> / <?=h($event['capacity'])?> bestätigt</td><td><?= $event['is_active'] ? 'Aktiv' : 'Inaktiv' ?></td><td><a class="button" href="?project=<?=h($project)?>&view=events&edit=<?=h($event['id'])?>">Bearbeiten</a> <form class="inline" method="post"><input type="hidden" name="csrf" value="<?=h($csrf)?>"><input type="hidden" name="project" value="<?=h($project)?>"><input type="hidden" name="action" value="toggle_event"><input type="hidden" name="id" value="<?=h($event['id'])?>"><button><?= $event['is_active'] ? 'Deaktivieren' : 'Aktivieren' ?></button></form></td></tr><?php endforeach; ?></tbody></table></section>
<?php elseif ($view === 'registrations'): ?>
<section class="card"><h2>Teilnahmen · <?=h(bookingProjectTitle($project))?></h2><table><thead><tr><th>Person</th><th>Termin</th><th>Status</th><th></th></tr></thead><tbody><?php foreach ($registrations as $registration): ?><tr><td><?=h($registration['name'])?><br><small><?=h($registration['email'])?><br><?=h($registration['phone'])?></small></td><td><?=h($registration['title'])?><br><small><?=h($registration['starts_at'])?></small></td><td class="status <?=h($registration['status'])?>"><?=h($registration['status'])?></td><td><?php if (in_array($registration['status'], ['pending_confirmation','confirmed'], true)): ?><form method="post"><input type="hidden" name="csrf" value="<?=h($csrf)?>"><input type="hidden" name="project" value="<?=h($project)?>"><input type="hidden" name="action" value="remove_registration"><input type="hidden" name="id" value="<?=h($registration['id'])?>"><button>Entfernen</button></form><?php endif; ?></td></tr><?php endforeach; ?></tbody></table></section>
<?php elseif ($view === 'export'): ?>
<section class="card"><h2>Bestätigte Teilnahmen anzeigen</h2><p>Der Zeitraum umfasst jeweils vollständige Kalendertage (00:00 bis 23:59 Uhr) und aktualisiert die Liste unmittelbar.</p><div class="form-grid"><label>Projekt<select id="export-project" data-dropdown-indicator><option value="all">PerspektivWerk und Produktionsschule</option><option value="perspektivwerk">Nur PerspektivWerk</option><option value="produktionsschule">Nur Produktionsschule</option></select></label>
<label></label>
<label>Von<input type="hidden" id="export-from" value="<?=h($defaultFrom)?>"><button type="button" class="date-time-trigger" data-date-picker data-target="export-from">Datum wählen</button></label>
<label>Bis<input type="hidden" id="export-to" value="<?=h($defaultTo)?>"><button type="button" class="date-time-trigger" data-date-picker data-target="export-to">Datum wählen</button></label>
</div><div id="export-results" class="export-results" role="status" aria-live="polite">Lade Teilnahmen …</div></section>
<?php endif; ?>
</main>
<dialog class="picker-dialog" id="date-time-dialog" aria-labelledby="date-time-title"><div class="picker-content"><div class="picker-head"><button type="button" data-picker-prev aria-label="Vorheriger Monat">‹</button><strong class="picker-month" id="date-time-title"></strong><button type="button" data-picker-next aria-label="Nächster Monat">›</button></div><div class="picker-week" aria-hidden="true"><span>Mo</span><span>Di</span><span>Mi</span><span>Do</span><span>Fr</span><span>Sa</span><span>So</span></div><div class="picker-days" data-picker-days></div><?php if ($view === 'events'): ?><div class="picker-time" data-picker-time-controls><label><select data-picker-hour></select></label><label><select data-picker-quarter></select></label></div><?php endif; ?><div class="picker-actions"><button type="button" data-picker-close>Schließen</button><button type="button" class="primary" data-picker-apply>Übernehmen</button></div></div></dialog>
<script>
const contactByName = <?=json_encode($contactByName, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)?>;
const contactByEmail = <?=json_encode($contactByEmail, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)?>;
const contactName = document.querySelector('#contact_name'), contactEmail = document.querySelector('#contact_email'), contactPhone = document.querySelector('#contact_phone');
contactName?.addEventListener('change', () => { const contact=contactByName[contactName.value]; if (contact) { contactEmail.value=contact.email; contactPhone.value=contact.phone; } });
contactEmail?.addEventListener('change', () => { const contact=contactByEmail[contactEmail.value]; if (contact) { contactName.value=contact.name; contactPhone.value=contact.phone; } });
const dialog = document.querySelector('#date-time-dialog'), days = dialog.querySelector('[data-picker-days]'), monthLabel = dialog.querySelector('.picker-month'), timeControls = dialog.querySelector('[data-picker-time-controls]'), hourSelect = dialog.querySelector('[data-picker-hour]'), quarterSelect = dialog.querySelector('[data-picker-quarter]');
if (timeControls) { const hours = Array.from({length:24}, (_,hour) => String(hour).padStart(2,'0')); hourSelect.innerHTML = hours.map(hour => '<option value="' + hour + '">' + hour + ' Uhr</option>').join(''); quarterSelect.innerHTML = ['00','15','30','45'].map(minute => '<option value="' + minute + '">' + minute + ' Min.</option>').join(''); }
let pickerTarget, pickerDate, pickerMonth, pickerMode;
const asDate = value => { const parts = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?/); if (parts) return new Date(Number(parts[1]), Number(parts[2])-1, Number(parts[3]), Number(parts[4] || 0), Number(parts[5] || 0)); const defaultDate = new Date(); defaultDate.setHours(9,0,0,0); return defaultDate; };
const formatDateValue = date => String(date.getFullYear())+'-'+String(date.getMonth()+1).padStart(2,'0')+'-'+String(date.getDate()).padStart(2,'0');
const formatValue = date => formatDateValue(date)+' '+String(date.getHours()).padStart(2,'0')+':'+String(date.getMinutes()).padStart(2,'0')+':00';
const formatLabel = (value, includeTime) => value ? new Intl.DateTimeFormat('de-DE', includeTime ? {dateStyle:'medium',timeStyle:'short'} : {dateStyle:'medium'}).format(asDate(value)) : (includeTime ? 'Datum & Uhrzeit wählen' : 'Datum wählen');
const getTarget = name => document.querySelector('[name="'+name+'"],#'+name);
const syncPickerLabels = () => document.querySelectorAll('[data-datetime-picker],[data-date-picker]').forEach(button => { const input = getTarget(button.dataset.target); button.textContent = formatLabel(input?.value, button.hasAttribute('data-datetime-picker')); });
function renderPicker() {
monthLabel.textContent = new Intl.DateTimeFormat('de-DE',{month:'long',year:'numeric'}).format(pickerMonth);
const first = new Date(pickerMonth.getFullYear(),pickerMonth.getMonth(),1), offset = (first.getDay()+6)%7, last = new Date(pickerMonth.getFullYear(),pickerMonth.getMonth()+1,0).getDate();
days.innerHTML = Array.from({length:offset+last}, (_,index) => { if(index<offset) return '<button type="button" disabled></button>'; const day=index-offset+1, selected=pickerDate.getFullYear()===pickerMonth.getFullYear()&&pickerDate.getMonth()===pickerMonth.getMonth()&&pickerDate.getDate()===day; return '<button type="button" data-picker-day="'+day+'" class="'+(selected?'selected':'')+'" aria-pressed="'+selected+'">'+day+'</button>'; }).join('');
days.querySelectorAll('[data-picker-day]').forEach(button => button.addEventListener('click', () => { pickerDate = new Date(pickerMonth.getFullYear(),pickerMonth.getMonth(),Number(button.dataset.pickerDay),pickerDate.getHours(),pickerDate.getMinutes()); renderPicker(); }));
}
document.querySelectorAll('[data-datetime-picker],[data-date-picker]').forEach(button => button.addEventListener('click', () => { pickerTarget=getTarget(button.dataset.target); pickerMode=button.hasAttribute('data-date-picker') ? 'date' : 'datetime'; pickerDate=asDate(pickerTarget.value); pickerMonth=new Date(pickerDate.getFullYear(),pickerDate.getMonth(),1); if (timeControls) { hourSelect.value=String(pickerDate.getHours()).padStart(2,'0'); quarterSelect.value=String(Math.floor(pickerDate.getMinutes()/15)*15).padStart(2,'0'); } renderPicker(); dialog.showModal(); }));
dialog.querySelector('[data-picker-prev]').addEventListener('click', () => { pickerMonth=new Date(pickerMonth.getFullYear(),pickerMonth.getMonth()-1,1); renderPicker(); });
dialog.querySelector('[data-picker-next]').addEventListener('click', () => { pickerMonth=new Date(pickerMonth.getFullYear(),pickerMonth.getMonth()+1,1); renderPicker(); });
dialog.querySelector('[data-picker-close]').addEventListener('click', () => dialog.close());
dialog.querySelector('[data-picker-apply]').addEventListener('click', () => { if (pickerMode === 'datetime' && timeControls) pickerDate.setHours(Number(hourSelect.value),Number(quarterSelect.value),0,0); pickerTarget.value=pickerMode === 'date' ? formatDateValue(pickerDate) : formatValue(pickerDate); syncPickerLabels(); pickerTarget.dispatchEvent(new Event('booking-datetime-change')); dialog.close(); });
syncPickerLabels();
const exportResults=document.querySelector('#export-results');
const escapeHtml = value => String(value).replace(/[&<>"']/g, character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character]));
async function updateExport() { if(!exportResults)return; const from=document.querySelector('#export-from').value,to=document.querySelector('#export-to').value,filterProject=document.querySelector('#export-project').value; exportResults.textContent='Lade Teilnahmen …'; const result=await fetch('?view=export-data&filterProject='+encodeURIComponent(filterProject)+'&from='+encodeURIComponent(from)+'&to='+encodeURIComponent(to)).then(response=>response.json()); if(result.error){exportResults.textContent=result.error;return;} exportResults.innerHTML=result.registrations.length?'<p>'+result.registrations.length+' bestätigte Teilnahme(n)</p><ul>'+result.registrations.map(item=>'<li><strong>'+escapeHtml(item.name)+'</strong> · '+escapeHtml(item.title)+' · '+escapeHtml(item.event_day)+'</li>').join('')+'</ul>':'<p>Für diesen Zeitraum liegen keine bestätigten Teilnahmen vor.</p>'; }
document.querySelectorAll('#export-from,#export-to').forEach(input=>input.addEventListener('booking-datetime-change',updateExport)); document.querySelector('#export-project')?.addEventListener('change', updateExport); updateExport();
</script></body></html>
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
require __DIR__.'/backend.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
function respond(int $status, array $payload): never
{
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
exit;
}
function bookingSession(): void
{
if (session_status() === PHP_SESSION_ACTIVE) return;
session_name('sufw_booking');
session_set_cookie_params(['httponly' => true, 'samesite' => 'Lax', 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')]);
session_start();
}
function csrfToken(): string
{
bookingSession();
return $_SESSION['csrf_token'] ??= bin2hex(random_bytes(32));
}
function readPayload(): array
{
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 32768) respond(413, ['error' => 'Die Anfrage ist zu groß.']);
try { $payload = json_decode((string) file_get_contents('php://input'), true, 32, JSON_THROW_ON_ERROR); }
catch (JsonException) { respond(400, ['error' => 'Die Anfrage konnte nicht verarbeitet werden.']); }
if (!is_array($payload)) respond(400, ['error' => 'Die Anfrage konnte nicht verarbeitet werden.']);
return $payload;
}
function ensureRateLimit(): void
{
bookingSession();
$now = time();
$recent = array_values(array_filter($_SESSION['booking_requests'] ?? [], static fn ($value): bool => is_int($value) && $value > $now - 900));
if (count($recent) >= 10) respond(429, ['error' => 'Zu viele Anfragen. Bitte versuchen Sie es später erneut.']);
$recent[] = $now;
$_SESSION['booking_requests'] = $recent;
}
try {
$database = bookingDatabase();
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$project = (string) ($_GET['project'] ?? '');
$from = (string) ($_GET['from'] ?? '');
$to = (string) ($_GET['to'] ?? '');
$fromDate = DateTimeImmutable::createFromFormat('!Y-m-d', $from);
$toDate = DateTimeImmutable::createFromFormat('!Y-m-d', $to);
if (!bookingProjectIsValid($project) || !$fromDate || !$toDate || $fromDate >= $toDate || $fromDate->diff($toDate)->days > 93) respond(400, ['error' => 'Ungültige Kalenderanfrage.']);
respond(200, ['csrfToken' => csrfToken(), 'events' => bookingListEvents($database, $project, $from, $to)]);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
ensureRateLimit();
if (!hash_equals(csrfToken(), (string) ($_SERVER['HTTP_X_SUFW_BOOKING_CSRF'] ?? ''))) respond(403, ['error' => 'Die Sitzung ist abgelaufen. Bitte laden Sie den Kalender neu.']);
try { $registration = bookingCreateRegistration($database, readPayload()); }
catch (InvalidArgumentException $exception) { respond(422, ['error' => $exception->getMessage()]); }
catch (OutOfBoundsException $exception) { respond(404, ['error' => $exception->getMessage()]); }
catch (LogicException|RuntimeException $exception) { respond(409, ['error' => $exception->getMessage()]); }
respond(202, ['message' => 'Ihre Anfrage wurde gespeichert. Bitte bestätigen Sie den Link aus der E-Mail innerhalb von 30 Minuten.', 'expiresAt' => $registration['expiresAt']]);
}
header('Allow: GET, POST');
respond(405, ['error' => 'Diese Methode wird nicht unterstützt.']);
} catch (Throwable $exception) {
error_log('SUFW booking API: '.$exception->getMessage());
respond(500, ['error' => 'Der Kalender ist vorübergehend nicht verfügbar.']);
}
@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
function bookingDatabaseUrl(?string $environmentUrl = null, ?string $configurationFile = null): string
{
$url = $environmentUrl ?? getenv('DATABASE_URL');
if (!is_string($url) || trim($url) === '') {
$configurationFile ??= dirname(__DIR__, 2).'/booking-config.php';
if (is_file($configurationFile)) {
$configuration = require $configurationFile;
$url = is_array($configuration) ? ($configuration['database_url'] ?? '') : '';
}
}
if (!is_string($url) || trim($url) === '') {
throw new RuntimeException('DATABASE_URL is not configured and the booking configuration file does not contain a database URL.');
}
return trim($url);
}
function bookingDatabase(): PDO
{
$url = bookingDatabaseUrl();
$parts = is_string($url) ? parse_url($url) : false;
if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'mysql' || !isset($parts['host'], $parts['path'], $parts['user'])) {
throw new RuntimeException('DATABASE_URL is not configured correctly.');
}
$dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $parts['host'], (int) ($parts['port'] ?? 3306), ltrim($parts['path'], '/'));
return new PDO($dsn, rawurldecode($parts['user']), rawurldecode($parts['pass'] ?? ''), [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false]);
}
function bookingBaseUrl(): string
{
$configured = getenv('BOOKING_BASE_URL');
if (is_string($configured) && $configured !== '') {
return rtrim($configured, '/');
}
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
return $scheme.'://'.($_SERVER['HTTP_HOST'] ?? 'localhost');
}
function bookingUuid(): string
{
$hex = bin2hex(random_bytes(16));
return sprintf('%s-%s-%s-%s-%s', substr($hex, 0, 8), substr($hex, 8, 4), substr($hex, 12, 4), substr($hex, 16, 4), substr($hex, 20));
}
function bookingProjectIsValid(string $project): bool
{
return in_array($project, ['perspektivwerk', 'produktionsschule'], true);
}
function bookingProjectTitle(string $project): string
{
return $project === 'produktionsschule' ? 'Produktionsschule' : 'PerspektivWerk';
}
function bookingMailTransport(?string $configuredTransport = null): string
{
$transport = $configuredTransport ?? getenv('BOOKING_MAIL_TRANSPORT');
if (!is_string($transport) || $transport === '') return 'sendmail';
return $transport;
}
function bookingIcsCalendar(array $event): string
{
$timezone = new DateTimeZone('Europe/Berlin');
$utc = new DateTimeZone('UTC');
$start = new DateTimeImmutable((string) $event['starts_at'], $timezone);
$end = new DateTimeImmutable((string) $event['ends_at'], $timezone);
$lines = [
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//SUFW//Terminbuchung//DE', 'CALSCALE:GREGORIAN',
'BEGIN:VEVENT',
'UID:'.bookingIcsEscape((string) $event['id']).'@sufw-booking',
'DTSTAMP:'.gmdate('Ymd\\THis\\Z'),
'DTSTART:'.$start->setTimezone($utc)->format('Ymd\\THis\\Z'),
'DTEND:'.$end->setTimezone($utc)->format('Ymd\\THis\\Z'),
'SUMMARY:'.bookingIcsEscape((string) $event['title']),
'DESCRIPTION:'.bookingIcsEscape(($event['format'] ?? '') === 'telefonisch' ? 'Telefonischer Termin' : 'Termin vor Ort'),
'END:VEVENT', 'END:VCALENDAR',
];
return implode("\r\n", $lines)."\r\n";
}
function bookingSendMail(string $email, string $subject, string $textBody, ?string $calendar = null): void
{
$transport = bookingMailTransport();
if ($transport === 'disabled') return;
if ($transport !== 'sendmail') throw new RuntimeException('Unsupported booking mail transport.');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new InvalidArgumentException('Invalid mail recipient.');
$sender = 'sufw-buchung@boogiedev.net';
$headers = ['From' => $sender, 'Reply-To' => $sender, 'MIME-Version' => '1.0'];
if ($calendar === null) {
$headers['Content-Type'] = 'text/plain; charset=UTF-8';
$message = $textBody;
} else {
$boundary = '=_sufw_'.bin2hex(random_bytes(16));
$headers['Content-Type'] = 'multipart/mixed; boundary="'.$boundary.'"';
$message = '--'.$boundary."\r\n"
."Content-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n"
.$textBody."\r\n--".$boundary."\r\n"
."Content-Type: text/calendar; charset=UTF-8; method=PUBLISH; name=\"termin.ics\"\r\n"
."Content-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename=\"termin.ics\"\r\n\r\n"
.chunk_split(base64_encode($calendar), 76, "\r\n")
.'--'.$boundary."--\r\n";
}
if (!mail($email, $subject, $message, $headers, '-f'.$sender)) {
throw new RuntimeException('The confirmation email could not be handed to sendmail.');
}
}
function bookingNormaliseEvent(array $event): array
{
$event['capacity'] = (int) $event['capacity'];
$event['confirmedCount'] = (int) $event['confirmedCount'];
$event['availableCapacity'] = max(0, $event['capacity'] - $event['confirmedCount']);
$event['status'] = $event['availableCapacity'] > 0 ? 'available' : 'full';
$event['introductionRequired'] = (bool) $event['introductionRequired'];
unset($event['confirmedCount']);
return $event;
}
function bookingListEvents(PDO $database, string $project, string $from, string $to): array
{
$statement = $database->prepare(<<<'SQL'
SELECT e.id, e.project, e.title, e.starts_at AS startsAt, e.ends_at AS endsAt, e.capacity,
e.format, e.introduction_required AS introductionRequired, e.contact_name AS contactName,
COUNT(r.id) AS confirmedCount
FROM sufw_booking_events e
LEFT JOIN sufw_booking_registrations r ON r.event_id = e.id AND r.status = 'confirmed'
WHERE e.project = :project AND e.is_active = 1 AND e.starts_at < :toDate AND e.ends_at >= :fromDate
GROUP BY e.id ORDER BY e.starts_at, e.title
SQL);
$statement->execute(['project' => $project, 'fromDate' => "$from 00:00:00", 'toDate' => "$to 00:00:00"]);
return array_map('bookingNormaliseEvent', $statement->fetchAll());
}
function bookingCreateRegistration(PDO $database, array $payload): array
{
$project = (string) ($payload['project'] ?? '');
$eventId = (string) ($payload['eventId'] ?? '');
$name = trim((string) ($payload['name'] ?? ''));
$email = mb_strtolower(trim((string) ($payload['email'] ?? '')));
$phone = trim((string) ($payload['phone'] ?? ''));
$privacyAccepted = ($payload['privacyAccepted'] ?? false) === true;
$introductionAttended = ($payload['introductionAttended'] ?? false) === true;
$introductionDate = trim((string) ($payload['introductionDate'] ?? ''));
if (!bookingProjectIsValid($project) || !preg_match('/^[a-f0-9-]{36}$/', $eventId) || $name === '' || mb_strlen($name) > 160 || !filter_var($email, FILTER_VALIDATE_EMAIL) || mb_strlen($email) > 254 || !preg_match('/^[0-9+()\-\/\s]{6,64}$/', $phone) || !$privacyAccepted) {
throw new InvalidArgumentException('Bitte prüfen Sie Ihre Pflichtangaben.');
}
if ($introductionDate !== '' && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $introductionDate) || !DateTimeImmutable::createFromFormat('!Y-m-d', $introductionDate))) {
throw new InvalidArgumentException('Bitte prüfen Sie das Datum der Einführungsveranstaltung.');
}
$database->beginTransaction();
try {
$eventStatement = $database->prepare('SELECT * FROM sufw_booking_events WHERE id = :id AND project = :project AND is_active = 1 FOR UPDATE');
$eventStatement->execute(['id' => $eventId, 'project' => $project]);
$event = $eventStatement->fetch();
if (!$event) throw new OutOfBoundsException('Dieser Termin ist nicht mehr verfügbar.');
if ((bool) $event['introduction_required'] && (!$introductionAttended || $introductionDate === '')) {
throw new InvalidArgumentException('Bitte bestätigen Sie die Einführungsveranstaltung und geben Sie das Datum an.');
}
$confirmed = $database->prepare("SELECT COUNT(*) FROM sufw_booking_registrations WHERE event_id = :eventId AND status = 'confirmed'");
$confirmed->execute(['eventId' => $eventId]);
if ((int) $confirmed->fetchColumn() >= (int) $event['capacity']) throw new RuntimeException('Dieser Termin ist inzwischen ausgebucht.');
$duplicate = $database->prepare("SELECT id FROM sufw_booking_registrations WHERE event_id = :eventId AND email = :email AND name = :name AND status IN ('pending_confirmation', 'confirmed') LIMIT 1");
$duplicate->execute(['eventId' => $eventId, 'email' => $email, 'name' => $name]);
if ($duplicate->fetch()) throw new LogicException('Für diese Person liegt bereits eine Anmeldung für diesen Termin vor.');
$registrationId = bookingUuid();
$token = bin2hex(random_bytes(32));
$insert = $database->prepare(<<<'SQL'
INSERT INTO sufw_booking_registrations
(id, event_id, status, name, email, phone, privacy_accepted_at, confirmation_token_hash, confirmation_expires_at, introduction_attended, introduction_date)
VALUES
(:id, :eventId, 'pending_confirmation', :name, :email, :phone, UTC_TIMESTAMP(), :tokenHash, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 30 MINUTE), :introductionAttended, :introductionDate)
SQL);
$insert->execute(['id' => $registrationId, 'eventId' => $eventId, 'name' => $name, 'email' => $email, 'phone' => $phone, 'tokenHash' => hash('sha256', $token), 'introductionAttended' => $introductionAttended ? 1 : 0, 'introductionDate' => $introductionAttended ? $introductionDate : null]);
$link = bookingBaseUrl().'/booking/confirm.php?token='.rawurlencode($token);
$subject = 'Bitte bestätigen Sie Ihre Terminanfrage';
$body = "Guten Tag $name,\n\nbitte bestätigen Sie Ihre Anfrage für \"{$event['title']}\" am {$event['starts_at']} innerhalb von 30 Minuten:\n$link\n\nKontakt für Rückfragen: {$event['contact_name']} ({$event['contact_email']}).";
$database->commit();
bookingSendMail($email, $subject, $body);
return ['registrationId' => $registrationId, 'expiresAt' => gmdate('c', time() + 1800)];
} catch (Throwable $exception) {
if ($database->inTransaction()) $database->rollBack();
throw $exception;
}
}
function bookingConfirmRegistration(PDO $database, string $token): array
{
if (!preg_match('/^[a-f0-9]{64}$/', $token)) throw new InvalidArgumentException('Der Bestätigungslink ist ungültig.');
$database->beginTransaction();
try {
$statement = $database->prepare(<<<'SQL'
SELECT r.*, e.title, e.project, e.starts_at, e.ends_at, e.capacity, e.format, e.contact_name, e.contact_email
FROM sufw_booking_registrations r JOIN sufw_booking_events e ON e.id = r.event_id
WHERE r.confirmation_token_hash = :tokenHash FOR UPDATE
SQL);
$statement->execute(['tokenHash' => hash('sha256', $token)]);
$registration = $statement->fetch();
if (!$registration || $registration['status'] !== 'pending_confirmation') throw new OutOfBoundsException('Dieser Bestätigungslink ist nicht mehr gültig.');
if (strtotime($registration['confirmation_expires_at'].' UTC') < time()) {
$database->prepare("UPDATE sufw_booking_registrations SET status = 'expired' WHERE id = :id")->execute(['id' => $registration['id']]);
$database->commit();
throw new OutOfBoundsException('Die Bestätigungsfrist ist abgelaufen. Bitte fragen Sie den Termin erneut an.');
}
$count = $database->prepare("SELECT COUNT(*) FROM sufw_booking_registrations WHERE event_id = :eventId AND status = 'confirmed'");
$count->execute(['eventId' => $registration['event_id']]);
if ((int) $count->fetchColumn() >= (int) $registration['capacity']) throw new RuntimeException('Der Termin wurde inzwischen vergeben. Bitte wählen Sie einen anderen Termin.');
$database->prepare("UPDATE sufw_booking_registrations SET status = 'confirmed', confirmed_at = UTC_TIMESTAMP(), confirmation_token_hash = NULL WHERE id = :id")->execute(['id' => $registration['id']]);
$history = $database->prepare(<<<'SQL'
SELECT e.title, e.starts_at FROM sufw_booking_registrations r JOIN sufw_booking_events e ON e.id = r.event_id
WHERE r.email = :email AND r.name = :name AND r.status = 'confirmed' ORDER BY e.starts_at
SQL);
$history->execute(['email' => $registration['email'], 'name' => $registration['name']]);
$appointments = implode("\n", array_map(static fn (array $row): string => "- {$row['title']} am {$row['starts_at']}", $history->fetchAll()));
$body = "Guten Tag {$registration['name']},\n\nIhr Termin ist bestätigt:\n{$registration['title']} am {$registration['starts_at']} bis {$registration['ends_at']}.\n\nIhre weiteren bestätigten Termine:\n$appointments\n\nBei Stornierungswunsch wenden Sie sich an {$registration['contact_name']} ({$registration['contact_email']}).";
$database->commit();
bookingSendMail($registration['email'], 'Ihr Termin ist bestätigt', $body, bookingIcsCalendar($registration));
return $registration;
} catch (Throwable $exception) {
if ($database->inTransaction()) $database->rollBack();
throw $exception;
}
}
function bookingIcsEscape(string $value): string
{
return str_replace(["\\", ";", ",", "\n"], ["\\\\", "\\;", "\\,", "\\n"], $value);
}
@@ -0,0 +1,356 @@
.sufw-booking {
--sufw-booking-green: #08752a;
--sufw-booking-text: #202020;
--sufw-booking-muted: #5a5a5a;
--sufw-booking-line: #c7c7c7;
--sufw-booking-pale: #f3f6f3;
box-sizing: border-box;
max-width: 860px;
margin: 2rem auto;
padding: 1.25rem;
color: var(--sufw-booking-text);
background: #fff;
border: 1px solid var(--sufw-booking-line);
font: 1rem/1.5 Arial, "Helvetica Neue", sans-serif;
}
.sufw-booking * {
box-sizing: border-box;
}
.sufw-booking__header h2,
.sufw-booking__slots h3 {
margin: 0.15rem 0 0.5rem;
color: var(--sufw-booking-green);
font-size: 1.4rem;
}
.sufw-booking__header p {
margin: 0.3rem 0;
}
.sufw-booking__eyebrow {
color: var(--sufw-booking-green);
font-size: 0.875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sufw-booking__legend {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin: 1.25rem 0;
font-size: 0.9rem;
}
.sufw-booking__marker {
display: inline-block;
width: 0.8rem;
height: 0.8rem;
margin-right: 0.35rem;
border-radius: 50%;
}
.sufw-booking__marker--available {
background: var(--sufw-booking-green);
}
.sufw-booking__marker--booked {
background: #535353;
}
.sufw-booking__month-nav {
display: grid;
grid-template-columns: 3rem 1fr 3rem;
align-items: center;
margin-top: 1rem;
text-align: center;
}
.sufw-booking__month-nav h3 {
margin: 0;
font-size: 1.1rem;
text-transform: capitalize;
}
.sufw-booking__nav,
.sufw-booking__day,
.sufw-booking__slot {
font: inherit;
}
.sufw-booking__nav {
min-width: 2.75rem;
min-height: 2.75rem;
border: 1px solid var(--sufw-booking-green);
color: var(--sufw-booking-green);
background: #fff;
font-size: 1.7rem;
cursor: pointer;
}
.sufw-booking__nav:hover,
.sufw-booking__nav:focus-visible {
background: var(--sufw-booking-pale);
}
.sufw-booking__weekdays,
.sufw-booking__calendar {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.3rem;
}
.sufw-booking__weekdays {
margin-top: 1rem;
text-align: center;
font-weight: 700;
font-size: 0.8rem;
}
.sufw-booking__day {
display: grid;
place-items: center;
gap: 0.1rem;
min-height: 3.8rem;
padding: 0.25rem;
border: 1px solid transparent;
background: #fff;
color: var(--sufw-booking-text);
cursor: pointer;
}
.sufw-booking__day:hover {
border-color: var(--sufw-booking-green);
background: var(--sufw-booking-pale);
}
.sufw-booking__day--available {
background: #dff3e4;
border-color: var(--sufw-booking-green);
}
.sufw-booking__day--booked {
background: #ececec;
border-color: #777;
}
.sufw-booking__day.is-selected {
outline: 3px solid #1f4fbc;
outline-offset: 1px;
}
.sufw-booking__day small {
font-size: 0.7rem;
}
.sufw-booking__empty {
min-height: 3.8rem;
}
.sufw-booking__slots {
margin-top: 1.5rem;
padding-top: 1.25rem;
border-top: 1px solid var(--sufw-booking-line);
}
.sufw-booking__slots ul {
padding: 0;
margin: 0;
list-style: none;
}
.sufw-booking__slot {
display: grid;
grid-template-columns: 5rem 1fr auto;
gap: 0.7rem;
width: 100%;
margin: 0.45rem 0;
padding: 0.8rem;
text-align: left;
color: var(--sufw-booking-text);
background: #fff;
border: 1px solid var(--sufw-booking-green);
cursor: pointer;
}
.sufw-booking__slot:hover,
.sufw-booking__slot:focus-visible {
background: var(--sufw-booking-pale);
}
.sufw-booking__slot em {
color: var(--sufw-booking-green);
font-style: normal;
font-weight: 700;
}
.sufw-booking__slot span {
display: grid;
gap: 0.15rem;
}
.sufw-booking__slot small {
color: var(--sufw-booking-muted);
font-size: 0.875rem;
font-weight: 400;
}
.sufw-booking__slot:disabled {
border-color: #777;
background: #eee;
color: #555;
cursor: not-allowed;
}
.sufw-booking__slot:disabled em {
color: #555;
}
.sufw-booking__empty-state,
.sufw-booking__status {
margin: 0.7rem 0;
}
.sufw-booking__status {
font-weight: 700;
}
.sufw-booking__dialog {
width: min(42rem, calc(100% - 2rem));
max-height: calc(100vh - 2rem);
padding: 0;
border: 0;
color: var(--sufw-booking-text);
box-shadow: 0 0.75rem 2.5rem rgba(0, 0, 0, 0.35);
font: 1rem/1.5 Arial, "Helvetica Neue", sans-serif;
}
.sufw-booking__dialog::backdrop {
background: rgba(0, 0, 0, 0.62);
}
.sufw-booking__form {
display: grid;
gap: 1rem;
padding: 1.25rem;
}
.sufw-booking__form-header {
padding-bottom: 1rem;
border-bottom: 1px solid var(--sufw-booking-line);
}
.sufw-booking__form-header h2 {
margin: 0.15rem 0 0.5rem;
color: var(--sufw-booking-green);
font-size: 1.5rem;
}
.sufw-booking__appointment,
.sufw-booking__required,
.sufw-booking__prototype-note {
margin: 0.35rem 0;
}
.sufw-booking__required {
font-size: 0.875rem;
}
.sufw-booking__optional {
color: var(--sufw-booking-muted);
font-size: 0.875rem;
font-weight: 400;
}
.sufw-booking__form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.sufw-booking__form label {
display: grid;
gap: 0.35rem;
font-weight: 700;
}
.sufw-booking__form input,
.sufw-booking__form textarea {
width: 100%;
padding: 0.65rem;
border: 1px solid #767676;
color: var(--sufw-booking-text);
font: inherit;
}
.sufw-booking__form input:focus-visible,
.sufw-booking__form textarea:focus-visible,
.sufw-booking__form button:focus-visible {
outline: 3px solid #1f4fbc;
outline-offset: 2px;
}
.sufw-booking__consent {
grid-template-columns: auto 1fr;
align-items: start;
font-weight: 400 !important;
}
.sufw-booking__consent input {
width: 1.1rem !important;
height: 1.1rem;
margin-top: 0.25rem;
}
.sufw-booking__form-errors:empty {
display: none;
}
.sufw-booking__form-errors {
padding: 0.75rem;
border-left: 4px solid #a31111;
background: #fff1f1;
color: #6c0808;
}
.sufw-booking__conditional {
display: grid;
gap: 0.8rem;
margin: 0;
padding: 1rem;
border: 1px solid var(--sufw-booking-line);
}
.sufw-booking__conditional legend {
padding: 0 0.25rem;
font-weight: 700;
}
.sufw-booking__load-error {
margin: 1rem 0;
padding: 1rem;
border-left: 4px solid #a31111;
background: #fff1f1;
color: #6c0808;
}
.sufw-booking__form-errors ul {
margin: 0;
padding-left: 1.25rem;
}
.sufw-booking__prototype-note {
padding: 0.75rem;
background: var(--sufw-booking-pale);
font-size: 0.9rem;
}
.sufw-booking__form-status {
margin: 0;
color: var(--sufw-booking-green);
font-weight: 700;
}
.sufw-booking__form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
flex-wrap: wrap;
}
.sufw-booking__form-actions button {
padding: 0.7rem 1rem;
border: 1px solid var(--sufw-booking-green);
background-color: #08752a;
color: #fff;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.sufw-booking__form-actions button:hover:not(:disabled) {
border-color: #05571f;
background-color: #05571f;
color: #fff;
}
.sufw-booking__form-actions button:disabled {
border-color: #777;
background-color: #d8d8d8;
color: #555;
cursor: not-allowed;
}
.sufw-booking__form-actions .sufw-booking__secondary {
background-color: #fff;
color: var(--sufw-booking-green);
}
@media (max-width: 520px) {
.sufw-booking {
margin: 1rem 0;
padding: 1rem;
}
.sufw-booking__weekdays {
font-size: 0.7rem;
}
.sufw-booking__day {
min-height: 3.4rem;
padding: 0.15rem;
}
.sufw-booking__slot {
grid-template-columns: 1fr;
gap: 0.15rem;
}
.sufw-booking__slot strong {
font-size: 1.1rem;
}
.sufw-booking__form-grid {
grid-template-columns: 1fr;
}
.sufw-booking__form-actions button {
width: 100%;
}
}
@@ -0,0 +1,192 @@
/* Browsernatives ES-Modul; wird ohne Build-Schritt ausgeliefert. */
const dateFormatter = new Intl.DateTimeFormat('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const monthFormatter = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' });
const timeFormatter = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit' });
const weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
const sessionProfileKey = 'sufw-booking-profile-v1';
function parseDateTime(value) { return new Date(value.replace(' ', 'T')); }
function parseDate(value) { return new Date(`${value}T12:00:00`); }
export function formatDateKey(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function eventDateRange(event) { return [event.startsAt.slice(0, 10), event.endsAt.slice(0, 10)]; }
function occursOnDate(event, date) { const [startDate, endDate] = eventDateRange(event); return startDate <= date && endDate >= date; }
export function getSlotsForDate(project, date, events = []) { return events.filter((event) => event.project === project && occursOnDate(event, date)); }
export function requiresIntroductionDetails(event = {}) { return event.project === 'produktionsschule' && event.introductionRequired === true; }
export function formatEventDuration(event) {
const start = parseDateTime(event.startsAt);
const end = parseDateTime(event.endsAt);
const [startDate, endDate] = eventDateRange(event);
if (startDate !== endDate) {
const dayCount = Math.round((parseDate(endDate) - parseDate(startDate)) / 86400000) + 1;
return `${dayCount} Tage`;
}
const hours = Math.round((end - start) / 3600000 * 10) / 10;
return `${String(hours).replace('.', ',')} ${hours === 1 ? 'Stunde' : 'Stunden'}`;
}
function formatEventTime(event) { return `${timeFormatter.format(parseDateTime(event.startsAt))}–${timeFormatter.format(parseDateTime(event.endsAt))} Uhr`; }
function formatEventFormat(event) { return event.format === 'telefonisch' ? 'Telefonisch' : 'Vor Ort'; }
export function validateBookingField(fieldName, value) {
const text = typeof value === 'string' ? value.trim() : '';
if (fieldName === 'name') return text && text.length <= 160 ? '' : 'Bitte geben Sie Ihren Namen ein.';
if (fieldName === 'email') return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text) && text.length <= 254 ? '' : 'Bitte geben Sie eine gültige E-Mail-Adresse ein.';
if (fieldName === 'phone') return /^[0-9+()\-/\s]{6,64}$/.test(text) ? '' : 'Bitte geben Sie eine gültige Telefonnummer ein.';
if (fieldName === 'introductionAttended') return value ? '' : 'Bitte bestätigen Sie die Teilnahme an einer Einführungsveranstaltung.';
if (fieldName === 'introductionDate') return /^\d{4}-\d{2}-\d{2}$/.test(text) ? '' : 'Bitte geben Sie das Datum der Einführungsveranstaltung ein.';
if (fieldName === 'privacy') return value ? '' : 'Bitte bestätigen Sie die Datenschutzhinweise.';
return '';
}
export function validateBookingForm({ name = '', email = '', phone = '', privacyAccepted = false, introductionAttended = false, introductionDate = '', event = {} }) {
const values = { name, email, phone, privacy: privacyAccepted };
if (requiresIntroductionDetails(event)) Object.assign(values, { introductionAttended, introductionDate });
return Object.fromEntries(Object.entries(values).map(([fieldName, value]) => [fieldName, validateBookingField(fieldName, value)]).filter(([, error]) => error));
}
export function canSubmitBookingForm(privacyAccepted) { return privacyAccepted; }
export function bookingConfirmationMessage(appointmentTitle) { return `Ihre Anfrage für ${appointmentTitle} wurde gespeichert. Bitte bestätigen Sie den Link aus der E-Mail innerhalb von 30 Minuten.`; }
function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' })[character]); }
function createMonthDates(month) {
const first = new Date(month.getFullYear(), month.getMonth(), 1);
const dates = new Array((first.getDay() + 6) % 7).fill(null);
const lastDay = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
for (let day = 1; day <= lastDay; day += 1) dates.push(new Date(month.getFullYear(), month.getMonth(), day));
return dates;
}
function loadSessionProfile() {
try { const profile = JSON.parse(sessionStorage.getItem(sessionProfileKey) ?? '{}'); return typeof profile === 'object' && profile ? profile : {}; } catch { return {}; }
}
function saveSessionProfile(profile) { try { sessionStorage.setItem(sessionProfileKey, JSON.stringify(profile)); } catch { /* Speicherung ist optional. */ } }
function showFieldError(form, fieldName, error) {
const field = form.elements.namedItem(fieldName);
if (!field) return;
const errorId = `sufw-booking-${fieldName}-error`;
let message = form.querySelector(`#${errorId}`);
if (!message) {
message = document.createElement('span');
message.id = errorId;
message.className = 'sufw-booking__field-error';
message.setAttribute('role', 'alert');
const host = fieldName === 'privacy' || fieldName === 'introductionAttended' ? field.closest('label') : field;
host.insertAdjacentElement('afterend', message);
field.setAttribute('aria-describedby', errorId);
}
message.textContent = error;
message.hidden = !error;
field.setAttribute('aria-invalid', error ? 'true' : 'false');
}
function openConfirmationDialog(container, event) {
const dialog = document.createElement('dialog');
dialog.className = 'sufw-booking__dialog';
dialog.setAttribute('aria-labelledby', 'sufw-booking-confirmation-title');
dialog.innerHTML = `<section class="sufw-booking__form"><header class="sufw-booking__form-header"><p class="sufw-booking__eyebrow">E-Mail bestätigen</p><h2 id="sufw-booking-confirmation-title">Fast geschafft</h2><p>${escapeHtml(bookingConfirmationMessage(event.title))}</p></header><p class="sufw-booking__appointment"><strong>${escapeHtml(event.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule')}</strong><br>${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(event.title)}</p><p>Bitte prüfen Sie Ihr E-Mail-Postfach und bestätigen Sie dort Ihre Anfrage.</p><div class="sufw-booking__form-actions"><button type="button" data-close-confirmation>Schließen</button></div></section>`;
dialog.querySelector('[data-close-confirmation]').addEventListener('click', () => dialog.close());
dialog.addEventListener('close', () => dialog.remove());
container.append(dialog);
if (typeof dialog.showModal === 'function') dialog.showModal(); else { dialog.setAttribute('open', ''); dialog.querySelector('[data-close-confirmation]').focus(); }
}
function openBookingDialog(container, event, state) {
const profile = loadSessionProfile();
const requiresIntroduction = requiresIntroductionDetails(event);
const dialog = document.createElement('dialog');
dialog.className = 'sufw-booking__dialog';
dialog.setAttribute('aria-labelledby', 'sufw-booking-form-title');
dialog.innerHTML = `<form class="sufw-booking__form" novalidate><header class="sufw-booking__form-header"><p class="sufw-booking__eyebrow">Termin verbindlich anmelden</p><h2 id="sufw-booking-form-title">Ihre Angaben</h2><p class="sufw-booking__appointment"><strong>${escapeHtml(event.title)}</strong><br>${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(formatEventDuration(event))} · ${escapeHtml(formatEventFormat(event))}</p></header><p class="sufw-booking__required"><span aria-hidden="true">*</span> Pflichtfeld</p><p class="sufw-booking__form-errors" role="alert" hidden></p><div class="sufw-booking__form-grid"><label>Vor- und Nachname <span aria-hidden="true">*</span><input name="name" type="text" autocomplete="name" maxlength="160" required value="${escapeHtml(profile.name ?? '')}"></label><label>E-Mail-Adresse <span aria-hidden="true">*</span><input name="email" type="email" autocomplete="email" maxlength="254" required value="${escapeHtml(profile.email ?? '')}"></label><label>Telefonnummer <span aria-hidden="true">*</span><input name="phone" type="tel" autocomplete="tel" maxlength="64" required value="${escapeHtml(profile.phone ?? '')}"></label></div>${requiresIntroduction ? '<fieldset class="sufw-booking__conditional"><legend>Voraussetzung für Probearbeiten</legend><label class="sufw-booking__consent"><input name="introductionAttended" type="checkbox" value="accepted"><span>Ich habe an einer Einführungsveranstaltung teilgenommen. <span aria-hidden="true">*</span></span></label><label>Datum der Einführungsveranstaltung <span aria-hidden="true">*</span><input name="introductionDate" type="date"></label></fieldset>' : ''}<label class="sufw-booking__consent"><input name="privacy" type="checkbox" value="accepted" required><span>Ich habe die Datenschutzhinweise nach Art. 13 DSGVO gelesen und stimme der Verarbeitung meiner Angaben zur Terminplanung zu. <span aria-hidden="true">*</span></span></label><div class="sufw-booking__form-actions"><button type="button" class="sufw-booking__secondary" data-close-dialog>Abbrechen</button><button type="submit" data-submit-booking disabled>Verbindlich anmelden</button></div></form>`;
const form = dialog.querySelector('form');
const submitButton = dialog.querySelector('[data-submit-booking]');
const privacy = form.elements.namedItem('privacy');
const fieldNames = ['name', 'email', 'phone', 'privacy', ...(requiresIntroduction ? ['introductionAttended', 'introductionDate'] : [])];
const formError = form.querySelector('.sufw-booking__form-errors');
const validateField = (fieldName) => {
const field = form.elements.namedItem(fieldName);
const value = fieldName === 'privacy' || fieldName === 'introductionAttended' ? field.checked : field.value;
const error = validateBookingField(fieldName, value);
showFieldError(form, fieldName, error);
return error;
};
fieldNames.forEach((fieldName) => {
const field = form.elements.namedItem(fieldName);
field.addEventListener(field.type === 'checkbox' ? 'change' : 'input', () => validateField(fieldName));
if (field.type !== 'checkbox') field.addEventListener('blur', () => validateField(fieldName));
});
privacy.addEventListener('change', () => { submitButton.disabled = !canSubmitBookingForm(privacy.checked); });
form.addEventListener('submit', async (submitEvent) => {
submitEvent.preventDefault();
const values = Object.fromEntries(new FormData(form).entries());
const booking = { name: values.name ?? '', email: values.email ?? '', phone: values.phone ?? '', privacyAccepted: privacy.checked, introductionAttended: form.elements.namedItem('introductionAttended')?.checked ?? false, introductionDate: values.introductionDate ?? '', event };
const errors = validateBookingForm(booking);
fieldNames.forEach((fieldName) => showFieldError(form, fieldName, errors[fieldName] ?? ''));
if (Object.keys(errors).length) { form.elements.namedItem(Object.keys(errors)[0]).focus(); return; }
submitButton.disabled = true;
formError.hidden = true;
try {
const response = await fetch(state.apiUrl, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-SUFW-Booking-CSRF': state.csrfToken }, body: JSON.stringify({ project: event.project, eventId: event.id, name: booking.name, email: booking.email, phone: booking.phone, privacyAccepted: true, introductionAttended: booking.introductionAttended, introductionDate: booking.introductionDate }) });
const result = await response.json();
if (!response.ok) throw new Error(result.error ?? 'Die Anmeldung konnte nicht gespeichert werden.');
saveSessionProfile({ name: booking.name, email: booking.email, phone: booking.phone });
dialog.close();
await state.reload();
openConfirmationDialog(container, event);
} catch (error) {
formError.textContent = error instanceof Error ? error.message : 'Die Anmeldung konnte nicht gespeichert werden.';
formError.hidden = false;
submitButton.disabled = false;
}
});
dialog.querySelector('[data-close-dialog]').addEventListener('click', () => dialog.close());
dialog.addEventListener('close', () => dialog.remove());
container.append(dialog);
if (typeof dialog.showModal === 'function') dialog.showModal(); else { dialog.setAttribute('open', ''); form.elements.namedItem('name').focus(); }
}
function renderComponent(container) {
const project = container.dataset.project === 'produktionsschule' ? 'produktionsschule' : 'perspektivwerk';
const now = new Date();
const state = { apiUrl: container.dataset.apiUrl || 'booking/api.php', csrfToken: '', events: [], currentMonth: new Date(now.getFullYear(), now.getMonth(), 1), selectedDate: '' };
const fetchEvents = async () => {
const from = formatDateKey(state.currentMonth);
const to = formatDateKey(new Date(state.currentMonth.getFullYear(), state.currentMonth.getMonth() + 1, 1));
const response = await fetch(`${state.apiUrl}?${new URLSearchParams({ project, from, to })}`, { credentials: 'same-origin' });
const result = await response.json();
if (!response.ok) throw new Error(result.error ?? 'Der Kalender konnte nicht geladen werden.');
state.events = result.events;
state.csrfToken = result.csrfToken;
if (!state.selectedDate && state.events.length) state.selectedDate = state.events[0].startsAt.slice(0, 10);
};
const render = () => {
const slots = state.selectedDate ? getSlotsForDate(project, state.selectedDate, state.events) : [];
const selectedLabel = state.selectedDate ? dateFormatter.format(parseDate(state.selectedDate)) : 'einen Tag';
container.innerHTML = `<section class="sufw-booking" aria-labelledby="sufw-booking-title"><header class="sufw-booking__header"><p class="sufw-booking__eyebrow">Terminbuchung</p><h2 id="sufw-booking-title">${project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule'}</h2><p>Wählen Sie einen Tag. Termine zeigen Dauer, Format und freie Plätze.</p></header><div class="sufw-booking__legend" aria-label="Legende"><span><i class="sufw-booking__marker sufw-booking__marker--available"></i> Buchbar</span><span><i class="sufw-booking__marker sufw-booking__marker--booked"></i> Ausgebucht</span></div><div class="sufw-booking__month-nav"><button class="sufw-booking__nav" type="button" data-action="previous" aria-label="Vorheriger Monat">‹</button><h3 aria-live="polite">${monthFormatter.format(state.currentMonth)}</h3><button class="sufw-booking__nav" type="button" data-action="next" aria-label="Nächster Monat">›</button></div><div class="sufw-booking__weekdays" aria-hidden="true">${weekdays.map((weekday) => `<span>${weekday}</span>`).join('')}</div><div class="sufw-booking__calendar" role="grid" aria-label="Kalender ${monthFormatter.format(state.currentMonth)}">${createMonthDates(state.currentMonth).map((date) => {
if (!date) return '<span class="sufw-booking__empty" aria-hidden="true"></span>';
const key = formatDateKey(date); const daySlots = getSlotsForDate(project, key, state.events); const available = daySlots.some((event) => event.status === 'available'); const full = daySlots.length > 0 && !available; const selected = key === state.selectedDate;
const label = daySlots.length ? `${dateFormatter.format(date)}: ${available ? 'Termine buchbar' : 'Termine ausgebucht'}` : dateFormatter.format(date);
return `<button type="button" role="gridcell" class="sufw-booking__day sufw-booking__day--${available ? 'available' : full ? 'booked' : 'none'}${selected ? ' is-selected' : ''}" data-date="${key}" aria-pressed="${selected}" aria-label="${escapeHtml(label)}"><time datetime="${key}">${date.getDate()}</time>${daySlots.length ? `<small>${available ? 'freie Plätze' : 'ausgebucht'}</small>` : ''}</button>`;
}).join('')}</div><section class="sufw-booking__slots" aria-labelledby="sufw-slot-title"><h3 id="sufw-slot-title">Termine am ${escapeHtml(selectedLabel)}</h3>${slots.length ? `<ul>${slots.map((event) => `<li><button type="button" class="sufw-booking__slot" data-event-id="${event.id}" ${event.status === 'full' ? 'disabled aria-disabled="true"' : ''}><strong>${escapeHtml(formatEventTime(event))}</strong><span>${escapeHtml(event.title)}<small>${escapeHtml(formatEventDuration(event))} · ${escapeHtml(formatEventFormat(event))}</small></span><em>${event.status === 'available' ? `${event.availableCapacity} ${event.availableCapacity === 1 ? 'Platz frei' : 'Plätze frei'}` : 'Ausgebucht'}</em></button></li>`).join('')}</ul>` : '<p class="sufw-booking__empty-state">Für diesen Tag liegen keine Termine vor.</p>'}<p class="sufw-booking__status" role="status" aria-live="polite"></p></section></section>`;
container.querySelector('[data-action="previous"]').addEventListener('click', async () => { state.currentMonth = new Date(state.currentMonth.getFullYear(), state.currentMonth.getMonth() - 1, 1); state.selectedDate = ''; await loadAndRender(); });
container.querySelector('[data-action="next"]').addEventListener('click', async () => { state.currentMonth = new Date(state.currentMonth.getFullYear(), state.currentMonth.getMonth() + 1, 1); state.selectedDate = ''; await loadAndRender(); });
container.querySelectorAll('[data-date]').forEach((button) => button.addEventListener('click', () => { state.selectedDate = button.dataset.date ?? ''; render(); }));
container.querySelectorAll('[data-event-id]').forEach((button) => button.addEventListener('click', () => { const event = state.events.find((entry) => entry.id === button.dataset.eventId); if (event) openBookingDialog(container, event, state); }));
};
const loadAndRender = async () => {
container.setAttribute('aria-busy', 'true');
try { await fetchEvents(); render(); }
catch (error) { container.innerHTML = `<p class="sufw-booking__load-error" role="alert">${escapeHtml(error instanceof Error ? error.message : 'Der Kalender konnte nicht geladen werden.')}</p>`; }
finally { container.removeAttribute('aria-busy'); }
};
state.reload = loadAndRender;
loadAndRender();
}
export function initializeBookingComponents(root = document) { root.querySelectorAll('[data-sufw-booking]').forEach(renderComponent); }
if (typeof document !== 'undefined') { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => initializeBookingComponents()); else initializeBookingComponents(); }
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
require __DIR__.'/backend.php';
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
function page(string $title, string $message, bool $success = false): never
{
$safeTitle = htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$safeMessage = htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$class = $success ? 'success' : 'error';
echo "<!doctype html><html lang=\"de\"><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>$safeTitle</title><style>body{font:18px/1.5 Arial,sans-serif;margin:0;background:#f3f6f3;color:#202020}.box{max-width:42rem;margin:12vh auto;padding:2rem;background:#fff;border-top:6px solid #08752a}.success{color:#08752a}.error{color:#8a1515}a{color:#08752a}</style><main class=\"box\"><h1 class=\"$class\">$safeTitle</h1><p>$safeMessage</p><p><a href=\"../\">Zurück zur Terminbuchung</a></p></main></html>";
exit;
}
try {
$registration = bookingConfirmRegistration(bookingDatabase(), (string) ($_GET['token'] ?? ''));
page('Termin bestätigt', "Vielen Dank. Ihr Termin \"{$registration['title']}\" ist verbindlich bestätigt.", true);
} catch (Throwable $exception) {
page('Bestätigung nicht möglich', $exception->getMessage());
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
require __DIR__.'/backend.php';
$project = (string) ($_GET['project'] ?? '');
$token = (string) ($_GET['token'] ?? '');
if (!bookingProjectIsValid($project) || !preg_match('/^[a-f0-9]{64}$/', $token)) {
http_response_code(404);
exit;
}
$database = bookingDatabase();
$settings = $database->prepare('SELECT feed_token FROM sufw_booking_project_settings WHERE project = :project');
$settings->execute(['project' => $project]);
if (!hash_equals((string) $settings->fetchColumn(), $token)) {
http_response_code(404);
exit;
}
$events = $database->prepare(<<<'SQL'
SELECT e.id, e.title, e.starts_at, e.ends_at, e.format
FROM sufw_booking_events e
WHERE e.project = :project AND EXISTS (SELECT 1 FROM sufw_booking_registrations r WHERE r.event_id = e.id AND r.status = 'confirmed')
ORDER BY e.starts_at
SQL);
$events->execute(['project' => $project]);
$timezone = new DateTimeZone('Europe/Berlin');
$utc = new DateTimeZone('UTC');
$lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//SUFW//Terminbuchung//DE', 'CALSCALE:GREGORIAN', 'X-WR-CALNAME:'.bookingIcsEscape(bookingProjectTitle($project))];
foreach ($events as $event) {
$start = new DateTimeImmutable($event['starts_at'], $timezone);
$end = new DateTimeImmutable($event['ends_at'], $timezone);
$lines[] = 'BEGIN:VEVENT';
$lines[] = 'UID:'.bookingIcsEscape($event['id']).'@sufw-booking';
$lines[] = 'DTSTAMP:'.gmdate('Ymd\\THis\\Z');
$lines[] = 'DTSTART:'.$start->setTimezone($utc)->format('Ymd\\THis\\Z');
$lines[] = 'DTEND:'.$end->setTimezone($utc)->format('Ymd\\THis\\Z');
$lines[] = 'SUMMARY:'.bookingIcsEscape($event['title']);
$lines[] = 'DESCRIPTION:'.bookingIcsEscape($event['format'] === 'telefonisch' ? 'Telefonischer Termin' : 'Termin vor Ort');
$lines[] = 'END:VEVENT';
}
$lines[] = 'END:VCALENDAR';
header('Content-Type: text/calendar; charset=utf-8');
header('Content-Disposition: inline; filename="'.($project === 'produktionsschule' ? 'produktionsschule' : 'perspektivwerk').'-termine.ics"');
header('Cache-Control: no-store');
echo implode("\r\n", $lines)."\r\n";
@@ -6,6 +6,7 @@
<meta name="description" content="PerspektivWerk – Brücke zwischen Diagnostik, Begleitung und betrieblicher Praxis.">
<title>PerspektivWerk | SUFW Dresden</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="js/booking.css">
</head>
<body>
<a class="skip-link" href="#main">Navigation überspringen</a>
@@ -19,8 +20,15 @@
<section class="intro page-width"><h1>PerspektivWerk – Brücke zwischen Diagnostik, Begleitung und betrieblicher Praxis</h1><img class="project-logo" src="media/logo-perspektivwerk.jpg" alt="PerspektivWerk"><div class="intro-copy"><p>Unser Fachpersonal arbeitet mit Ihnen gemeinsam daran, <strong>Ihre aktuelle Lebenssituation zu verstehen.</strong> Ziel ist es, aus den Informationen individuelle und zu Ihnen <strong>passende Wege ins Arbeitsleben</strong> sichtbar zu machen.</p><p>Dazu gehört es, herauszufinden, welche <strong>Herausforderungen Sie im Alltag</strong> bestreiten müssen und wo es Unterstützungsbedarf gibt. Vor allem aber auch, wo Ihre Fähigkeiten, Interessen und Entwicklungsmöglichkeiten liegen. Dabei arbeiten wir eng mit den beratenden Fachkräften der Agentur für Arbeit und des Jobcenters zusammen.</p></div></section>
<section class="team-section"><div class="team-copy"><img src="media/wir-sind-fuer-sie-da.svg" alt="Wir sind für Sie da"><p>Unser Team, bestehend aus sozialpädagogischen, pädagogischen und psychologischen Mitarbeiterinnen und Mitarbeitern, <strong>unterstützt Ihren Prozess der beruflichen Orientierung.</strong> Wir arbeiten eng mit den Bearbeiterinnen und Beratern der Agentur für Arbeit, dem Jobcenter und weiteren Beratungsstellen zusammen.</p><p>Die Teilnahme am <strong>PerspektivWerk ist kostenlos.</strong> Sie bestimmen, an welchen Angeboten Sie teilnehmen.</p></div><div class="team-image"><img src="media/perspektivwerk-psych.jpg" alt="Beratungsgespräch im PerspektivWerk"></div></section>
<section class="content page-width" id="projekte"><div class="content-grid"><article><h2>Wer kann zu uns kommen?</h2><p>Menschen zwischen 15 und 65 Jahren, die sich beruflich neu orientieren möchten, beim Einstieg Unterstützung suchen oder ihre Möglichkeiten gemeinsam klären wollen.</p></article><article><h2>1. Orientierung &amp; Ersteinschätzung</h2><p>Sozialpädagogische und psychologische Module helfen dabei, persönliche Situation, Motivation, Belastbarkeit und Stärken einzuschätzen.</p></article><article><h2>2. Praktische Erprobung &amp; Belastungstest</h2><p>Arbeitsproben und praktische Erprobungen machen fachliche, soziale und arbeitsbezogene Kompetenzen sichtbar.</p></article><article><h2>3. Auswertung &amp; Perspektiventwicklung</h2><p>Wir führen die Ergebnisse zusammen und entwickeln konkrete, realistische nächste Schritte.</p></article></div></section>
<section class="page-width" aria-label="Terminbuchung PerspektivWerk">
<div data-sufw-booking data-project="perspektivwerk" data-api-url="booking/api.php"></div>
</section>
<section class="page-width" aria-label="Terminbuchung Produktionsschule">
<div data-sufw-booking data-project="produktionsschule" data-api-url="booking/api.php"></div>
</section>
<section class="contact page-width" id="kontakt"><h2>Sie haben Fragen zu dieser Maßnahme?</h2><p>Haben Sie Interesse an unserem Angebot, melden Sie sich bei uns. Wir beraten Sie gern vorab zu den weiteren Schritten.</p><p><strong>Kontakt:</strong> <a href="tel:+4917660328929">(0176) 60328929</a> · <a href="mailto:perspektivwerk@sufw.de">perspektivwerk@sufw.de</a> · Dresdner Straße 16, 01326 Dresden</p></section>
</main>
<footer><div class="page-width">Sächsisches Umschulungs- und Fortbildungswerk Dresden e. V.</div></footer>
<script type="module" src="js/booking.js"></script>
</body>
</html>
@@ -0,0 +1,356 @@
.sufw-booking {
--sufw-booking-green: #08752a;
--sufw-booking-text: #202020;
--sufw-booking-muted: #5a5a5a;
--sufw-booking-line: #c7c7c7;
--sufw-booking-pale: #f3f6f3;
box-sizing: border-box;
max-width: 860px;
margin: 2rem auto;
padding: 1.25rem;
color: var(--sufw-booking-text);
background: #fff;
border: 1px solid var(--sufw-booking-line);
font: 1rem/1.5 Arial, "Helvetica Neue", sans-serif;
}
.sufw-booking * {
box-sizing: border-box;
}
.sufw-booking__header h2,
.sufw-booking__slots h3 {
margin: 0.15rem 0 0.5rem;
color: var(--sufw-booking-green);
font-size: 1.4rem;
}
.sufw-booking__header p {
margin: 0.3rem 0;
}
.sufw-booking__eyebrow {
color: var(--sufw-booking-green);
font-size: 0.875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sufw-booking__legend {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin: 1.25rem 0;
font-size: 0.9rem;
}
.sufw-booking__marker {
display: inline-block;
width: 0.8rem;
height: 0.8rem;
margin-right: 0.35rem;
border-radius: 50%;
}
.sufw-booking__marker--available {
background: var(--sufw-booking-green);
}
.sufw-booking__marker--booked {
background: #535353;
}
.sufw-booking__month-nav {
display: grid;
grid-template-columns: 3rem 1fr 3rem;
align-items: center;
margin-top: 1rem;
text-align: center;
}
.sufw-booking__month-nav h3 {
margin: 0;
font-size: 1.1rem;
text-transform: capitalize;
}
.sufw-booking__nav,
.sufw-booking__day,
.sufw-booking__slot {
font: inherit;
}
.sufw-booking__nav {
min-width: 2.75rem;
min-height: 2.75rem;
border: 1px solid var(--sufw-booking-green);
color: var(--sufw-booking-green);
background: #fff;
font-size: 1.7rem;
cursor: pointer;
}
.sufw-booking__nav:hover,
.sufw-booking__nav:focus-visible {
background: var(--sufw-booking-pale);
}
.sufw-booking__weekdays,
.sufw-booking__calendar {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.3rem;
}
.sufw-booking__weekdays {
margin-top: 1rem;
text-align: center;
font-weight: 700;
font-size: 0.8rem;
}
.sufw-booking__day {
display: grid;
place-items: center;
gap: 0.1rem;
min-height: 3.8rem;
padding: 0.25rem;
border: 1px solid transparent;
background: #fff;
color: var(--sufw-booking-text);
cursor: pointer;
}
.sufw-booking__day:hover {
border-color: var(--sufw-booking-green);
background: var(--sufw-booking-pale);
}
.sufw-booking__day--available {
background: #dff3e4;
border-color: var(--sufw-booking-green);
}
.sufw-booking__day--booked {
background: #ececec;
border-color: #777;
}
.sufw-booking__day.is-selected {
outline: 3px solid #1f4fbc;
outline-offset: 1px;
}
.sufw-booking__day small {
font-size: 0.7rem;
}
.sufw-booking__empty {
min-height: 3.8rem;
}
.sufw-booking__slots {
margin-top: 1.5rem;
padding-top: 1.25rem;
border-top: 1px solid var(--sufw-booking-line);
}
.sufw-booking__slots ul {
padding: 0;
margin: 0;
list-style: none;
}
.sufw-booking__slot {
display: grid;
grid-template-columns: 5rem 1fr auto;
gap: 0.7rem;
width: 100%;
margin: 0.45rem 0;
padding: 0.8rem;
text-align: left;
color: var(--sufw-booking-text);
background: #fff;
border: 1px solid var(--sufw-booking-green);
cursor: pointer;
}
.sufw-booking__slot:hover,
.sufw-booking__slot:focus-visible {
background: var(--sufw-booking-pale);
}
.sufw-booking__slot em {
color: var(--sufw-booking-green);
font-style: normal;
font-weight: 700;
}
.sufw-booking__slot span {
display: grid;
gap: 0.15rem;
}
.sufw-booking__slot small {
color: var(--sufw-booking-muted);
font-size: 0.875rem;
font-weight: 400;
}
.sufw-booking__slot:disabled {
border-color: #777;
background: #eee;
color: #555;
cursor: not-allowed;
}
.sufw-booking__slot:disabled em {
color: #555;
}
.sufw-booking__empty-state,
.sufw-booking__status {
margin: 0.7rem 0;
}
.sufw-booking__status {
font-weight: 700;
}
.sufw-booking__dialog {
width: min(42rem, calc(100% - 2rem));
max-height: calc(100vh - 2rem);
padding: 0;
border: 0;
color: var(--sufw-booking-text);
box-shadow: 0 0.75rem 2.5rem rgba(0, 0, 0, 0.35);
font: 1rem/1.5 Arial, "Helvetica Neue", sans-serif;
}
.sufw-booking__dialog::backdrop {
background: rgba(0, 0, 0, 0.62);
}
.sufw-booking__form {
display: grid;
gap: 1rem;
padding: 1.25rem;
}
.sufw-booking__form-header {
padding-bottom: 1rem;
border-bottom: 1px solid var(--sufw-booking-line);
}
.sufw-booking__form-header h2 {
margin: 0.15rem 0 0.5rem;
color: var(--sufw-booking-green);
font-size: 1.5rem;
}
.sufw-booking__appointment,
.sufw-booking__required,
.sufw-booking__prototype-note {
margin: 0.35rem 0;
}
.sufw-booking__required {
font-size: 0.875rem;
}
.sufw-booking__optional {
color: var(--sufw-booking-muted);
font-size: 0.875rem;
font-weight: 400;
}
.sufw-booking__form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.sufw-booking__form label {
display: grid;
gap: 0.35rem;
font-weight: 700;
}
.sufw-booking__form input,
.sufw-booking__form textarea {
width: 100%;
padding: 0.65rem;
border: 1px solid #767676;
color: var(--sufw-booking-text);
font: inherit;
}
.sufw-booking__form input:focus-visible,
.sufw-booking__form textarea:focus-visible,
.sufw-booking__form button:focus-visible {
outline: 3px solid #1f4fbc;
outline-offset: 2px;
}
.sufw-booking__consent {
grid-template-columns: auto 1fr;
align-items: start;
font-weight: 400 !important;
}
.sufw-booking__consent input {
width: 1.1rem !important;
height: 1.1rem;
margin-top: 0.25rem;
}
.sufw-booking__form-errors:empty {
display: none;
}
.sufw-booking__form-errors {
padding: 0.75rem;
border-left: 4px solid #a31111;
background: #fff1f1;
color: #6c0808;
}
.sufw-booking__conditional {
display: grid;
gap: 0.8rem;
margin: 0;
padding: 1rem;
border: 1px solid var(--sufw-booking-line);
}
.sufw-booking__conditional legend {
padding: 0 0.25rem;
font-weight: 700;
}
.sufw-booking__load-error {
margin: 1rem 0;
padding: 1rem;
border-left: 4px solid #a31111;
background: #fff1f1;
color: #6c0808;
}
.sufw-booking__form-errors ul {
margin: 0;
padding-left: 1.25rem;
}
.sufw-booking__prototype-note {
padding: 0.75rem;
background: var(--sufw-booking-pale);
font-size: 0.9rem;
}
.sufw-booking__form-status {
margin: 0;
color: var(--sufw-booking-green);
font-weight: 700;
}
.sufw-booking__form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
flex-wrap: wrap;
}
.sufw-booking__form-actions button {
padding: 0.7rem 1rem;
border: 1px solid var(--sufw-booking-green);
background-color: #08752a;
color: #fff;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.sufw-booking__form-actions button:hover:not(:disabled) {
border-color: #05571f;
background-color: #05571f;
color: #fff;
}
.sufw-booking__form-actions button:disabled {
border-color: #777;
background-color: #d8d8d8;
color: #555;
cursor: not-allowed;
}
.sufw-booking__form-actions .sufw-booking__secondary {
background-color: #fff;
color: var(--sufw-booking-green);
}
@media (max-width: 520px) {
.sufw-booking {
margin: 1rem 0;
padding: 1rem;
}
.sufw-booking__weekdays {
font-size: 0.7rem;
}
.sufw-booking__day {
min-height: 3.4rem;
padding: 0.15rem;
}
.sufw-booking__slot {
grid-template-columns: 1fr;
gap: 0.15rem;
}
.sufw-booking__slot strong {
font-size: 1.1rem;
}
.sufw-booking__form-grid {
grid-template-columns: 1fr;
}
.sufw-booking__form-actions button {
width: 100%;
}
}
@@ -0,0 +1,192 @@
/* Browsernatives ES-Modul; wird ohne Build-Schritt ausgeliefert. */
const dateFormatter = new Intl.DateTimeFormat('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const monthFormatter = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' });
const timeFormatter = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit' });
const weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
const sessionProfileKey = 'sufw-booking-profile-v1';
function parseDateTime(value) { return new Date(value.replace(' ', 'T')); }
function parseDate(value) { return new Date(`${value}T12:00:00`); }
export function formatDateKey(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function eventDateRange(event) { return [event.startsAt.slice(0, 10), event.endsAt.slice(0, 10)]; }
function occursOnDate(event, date) { const [startDate, endDate] = eventDateRange(event); return startDate <= date && endDate >= date; }
export function getSlotsForDate(project, date, events = []) { return events.filter((event) => event.project === project && occursOnDate(event, date)); }
export function requiresIntroductionDetails(event = {}) { return event.project === 'produktionsschule' && event.introductionRequired === true; }
export function formatEventDuration(event) {
const start = parseDateTime(event.startsAt);
const end = parseDateTime(event.endsAt);
const [startDate, endDate] = eventDateRange(event);
if (startDate !== endDate) {
const dayCount = Math.round((parseDate(endDate) - parseDate(startDate)) / 86400000) + 1;
return `${dayCount} Tage`;
}
const hours = Math.round((end - start) / 3600000 * 10) / 10;
return `${String(hours).replace('.', ',')} ${hours === 1 ? 'Stunde' : 'Stunden'}`;
}
function formatEventTime(event) { return `${timeFormatter.format(parseDateTime(event.startsAt))}–${timeFormatter.format(parseDateTime(event.endsAt))} Uhr`; }
function formatEventFormat(event) { return event.format === 'telefonisch' ? 'Telefonisch' : 'Vor Ort'; }
export function validateBookingField(fieldName, value) {
const text = typeof value === 'string' ? value.trim() : '';
if (fieldName === 'name') return text && text.length <= 160 ? '' : 'Bitte geben Sie Ihren Namen ein.';
if (fieldName === 'email') return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text) && text.length <= 254 ? '' : 'Bitte geben Sie eine gültige E-Mail-Adresse ein.';
if (fieldName === 'phone') return /^[0-9+()\-/\s]{6,64}$/.test(text) ? '' : 'Bitte geben Sie eine gültige Telefonnummer ein.';
if (fieldName === 'introductionAttended') return value ? '' : 'Bitte bestätigen Sie die Teilnahme an einer Einführungsveranstaltung.';
if (fieldName === 'introductionDate') return /^\d{4}-\d{2}-\d{2}$/.test(text) ? '' : 'Bitte geben Sie das Datum der Einführungsveranstaltung ein.';
if (fieldName === 'privacy') return value ? '' : 'Bitte bestätigen Sie die Datenschutzhinweise.';
return '';
}
export function validateBookingForm({ name = '', email = '', phone = '', privacyAccepted = false, introductionAttended = false, introductionDate = '', event = {} }) {
const values = { name, email, phone, privacy: privacyAccepted };
if (requiresIntroductionDetails(event)) Object.assign(values, { introductionAttended, introductionDate });
return Object.fromEntries(Object.entries(values).map(([fieldName, value]) => [fieldName, validateBookingField(fieldName, value)]).filter(([, error]) => error));
}
export function canSubmitBookingForm(privacyAccepted) { return privacyAccepted; }
export function bookingConfirmationMessage(appointmentTitle) { return `Ihre Anfrage für ${appointmentTitle} wurde gespeichert. Bitte bestätigen Sie den Link aus der E-Mail innerhalb von 30 Minuten.`; }
function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' })[character]); }
function createMonthDates(month) {
const first = new Date(month.getFullYear(), month.getMonth(), 1);
const dates = new Array((first.getDay() + 6) % 7).fill(null);
const lastDay = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
for (let day = 1; day <= lastDay; day += 1) dates.push(new Date(month.getFullYear(), month.getMonth(), day));
return dates;
}
function loadSessionProfile() {
try { const profile = JSON.parse(sessionStorage.getItem(sessionProfileKey) ?? '{}'); return typeof profile === 'object' && profile ? profile : {}; } catch { return {}; }
}
function saveSessionProfile(profile) { try { sessionStorage.setItem(sessionProfileKey, JSON.stringify(profile)); } catch { /* Speicherung ist optional. */ } }
function showFieldError(form, fieldName, error) {
const field = form.elements.namedItem(fieldName);
if (!field) return;
const errorId = `sufw-booking-${fieldName}-error`;
let message = form.querySelector(`#${errorId}`);
if (!message) {
message = document.createElement('span');
message.id = errorId;
message.className = 'sufw-booking__field-error';
message.setAttribute('role', 'alert');
const host = fieldName === 'privacy' || fieldName === 'introductionAttended' ? field.closest('label') : field;
host.insertAdjacentElement('afterend', message);
field.setAttribute('aria-describedby', errorId);
}
message.textContent = error;
message.hidden = !error;
field.setAttribute('aria-invalid', error ? 'true' : 'false');
}
function openConfirmationDialog(container, event) {
const dialog = document.createElement('dialog');
dialog.className = 'sufw-booking__dialog';
dialog.setAttribute('aria-labelledby', 'sufw-booking-confirmation-title');
dialog.innerHTML = `<section class="sufw-booking__form"><header class="sufw-booking__form-header"><p class="sufw-booking__eyebrow">E-Mail bestätigen</p><h2 id="sufw-booking-confirmation-title">Fast geschafft</h2><p>${escapeHtml(bookingConfirmationMessage(event.title))}</p></header><p class="sufw-booking__appointment"><strong>${escapeHtml(event.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule')}</strong><br>${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(event.title)}</p><p>Bitte prüfen Sie Ihr E-Mail-Postfach und bestätigen Sie dort Ihre Anfrage.</p><div class="sufw-booking__form-actions"><button type="button" data-close-confirmation>Schließen</button></div></section>`;
dialog.querySelector('[data-close-confirmation]').addEventListener('click', () => dialog.close());
dialog.addEventListener('close', () => dialog.remove());
container.append(dialog);
if (typeof dialog.showModal === 'function') dialog.showModal(); else { dialog.setAttribute('open', ''); dialog.querySelector('[data-close-confirmation]').focus(); }
}
function openBookingDialog(container, event, state) {
const profile = loadSessionProfile();
const requiresIntroduction = requiresIntroductionDetails(event);
const dialog = document.createElement('dialog');
dialog.className = 'sufw-booking__dialog';
dialog.setAttribute('aria-labelledby', 'sufw-booking-form-title');
dialog.innerHTML = `<form class="sufw-booking__form" novalidate><header class="sufw-booking__form-header"><p class="sufw-booking__eyebrow">Termin verbindlich anmelden</p><h2 id="sufw-booking-form-title">Ihre Angaben</h2><p class="sufw-booking__appointment"><strong>${escapeHtml(event.title)}</strong><br>${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(formatEventDuration(event))} · ${escapeHtml(formatEventFormat(event))}</p></header><p class="sufw-booking__required"><span aria-hidden="true">*</span> Pflichtfeld</p><p class="sufw-booking__form-errors" role="alert" hidden></p><div class="sufw-booking__form-grid"><label>Vor- und Nachname <span aria-hidden="true">*</span><input name="name" type="text" autocomplete="name" maxlength="160" required value="${escapeHtml(profile.name ?? '')}"></label><label>E-Mail-Adresse <span aria-hidden="true">*</span><input name="email" type="email" autocomplete="email" maxlength="254" required value="${escapeHtml(profile.email ?? '')}"></label><label>Telefonnummer <span aria-hidden="true">*</span><input name="phone" type="tel" autocomplete="tel" maxlength="64" required value="${escapeHtml(profile.phone ?? '')}"></label></div>${requiresIntroduction ? '<fieldset class="sufw-booking__conditional"><legend>Voraussetzung für Probearbeiten</legend><label class="sufw-booking__consent"><input name="introductionAttended" type="checkbox" value="accepted"><span>Ich habe an einer Einführungsveranstaltung teilgenommen. <span aria-hidden="true">*</span></span></label><label>Datum der Einführungsveranstaltung <span aria-hidden="true">*</span><input name="introductionDate" type="date"></label></fieldset>' : ''}<label class="sufw-booking__consent"><input name="privacy" type="checkbox" value="accepted" required><span>Ich habe die Datenschutzhinweise nach Art. 13 DSGVO gelesen und stimme der Verarbeitung meiner Angaben zur Terminplanung zu. <span aria-hidden="true">*</span></span></label><div class="sufw-booking__form-actions"><button type="button" class="sufw-booking__secondary" data-close-dialog>Abbrechen</button><button type="submit" data-submit-booking disabled>Verbindlich anmelden</button></div></form>`;
const form = dialog.querySelector('form');
const submitButton = dialog.querySelector('[data-submit-booking]');
const privacy = form.elements.namedItem('privacy');
const fieldNames = ['name', 'email', 'phone', 'privacy', ...(requiresIntroduction ? ['introductionAttended', 'introductionDate'] : [])];
const formError = form.querySelector('.sufw-booking__form-errors');
const validateField = (fieldName) => {
const field = form.elements.namedItem(fieldName);
const value = fieldName === 'privacy' || fieldName === 'introductionAttended' ? field.checked : field.value;
const error = validateBookingField(fieldName, value);
showFieldError(form, fieldName, error);
return error;
};
fieldNames.forEach((fieldName) => {
const field = form.elements.namedItem(fieldName);
field.addEventListener(field.type === 'checkbox' ? 'change' : 'input', () => validateField(fieldName));
if (field.type !== 'checkbox') field.addEventListener('blur', () => validateField(fieldName));
});
privacy.addEventListener('change', () => { submitButton.disabled = !canSubmitBookingForm(privacy.checked); });
form.addEventListener('submit', async (submitEvent) => {
submitEvent.preventDefault();
const values = Object.fromEntries(new FormData(form).entries());
const booking = { name: values.name ?? '', email: values.email ?? '', phone: values.phone ?? '', privacyAccepted: privacy.checked, introductionAttended: form.elements.namedItem('introductionAttended')?.checked ?? false, introductionDate: values.introductionDate ?? '', event };
const errors = validateBookingForm(booking);
fieldNames.forEach((fieldName) => showFieldError(form, fieldName, errors[fieldName] ?? ''));
if (Object.keys(errors).length) { form.elements.namedItem(Object.keys(errors)[0]).focus(); return; }
submitButton.disabled = true;
formError.hidden = true;
try {
const response = await fetch(state.apiUrl, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-SUFW-Booking-CSRF': state.csrfToken }, body: JSON.stringify({ project: event.project, eventId: event.id, name: booking.name, email: booking.email, phone: booking.phone, privacyAccepted: true, introductionAttended: booking.introductionAttended, introductionDate: booking.introductionDate }) });
const result = await response.json();
if (!response.ok) throw new Error(result.error ?? 'Die Anmeldung konnte nicht gespeichert werden.');
saveSessionProfile({ name: booking.name, email: booking.email, phone: booking.phone });
dialog.close();
await state.reload();
openConfirmationDialog(container, event);
} catch (error) {
formError.textContent = error instanceof Error ? error.message : 'Die Anmeldung konnte nicht gespeichert werden.';
formError.hidden = false;
submitButton.disabled = false;
}
});
dialog.querySelector('[data-close-dialog]').addEventListener('click', () => dialog.close());
dialog.addEventListener('close', () => dialog.remove());
container.append(dialog);
if (typeof dialog.showModal === 'function') dialog.showModal(); else { dialog.setAttribute('open', ''); form.elements.namedItem('name').focus(); }
}
function renderComponent(container) {
const project = container.dataset.project === 'produktionsschule' ? 'produktionsschule' : 'perspektivwerk';
const now = new Date();
const state = { apiUrl: container.dataset.apiUrl || 'booking/api.php', csrfToken: '', events: [], currentMonth: new Date(now.getFullYear(), now.getMonth(), 1), selectedDate: '' };
const fetchEvents = async () => {
const from = formatDateKey(state.currentMonth);
const to = formatDateKey(new Date(state.currentMonth.getFullYear(), state.currentMonth.getMonth() + 1, 1));
const response = await fetch(`${state.apiUrl}?${new URLSearchParams({ project, from, to })}`, { credentials: 'same-origin' });
const result = await response.json();
if (!response.ok) throw new Error(result.error ?? 'Der Kalender konnte nicht geladen werden.');
state.events = result.events;
state.csrfToken = result.csrfToken;
if (!state.selectedDate && state.events.length) state.selectedDate = state.events[0].startsAt.slice(0, 10);
};
const render = () => {
const slots = state.selectedDate ? getSlotsForDate(project, state.selectedDate, state.events) : [];
const selectedLabel = state.selectedDate ? dateFormatter.format(parseDate(state.selectedDate)) : 'einen Tag';
container.innerHTML = `<section class="sufw-booking" aria-labelledby="sufw-booking-title"><header class="sufw-booking__header"><p class="sufw-booking__eyebrow">Terminbuchung</p><h2 id="sufw-booking-title">${project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule'}</h2><p>Wählen Sie einen Tag. Termine zeigen Dauer, Format und freie Plätze.</p></header><div class="sufw-booking__legend" aria-label="Legende"><span><i class="sufw-booking__marker sufw-booking__marker--available"></i> Buchbar</span><span><i class="sufw-booking__marker sufw-booking__marker--booked"></i> Ausgebucht</span></div><div class="sufw-booking__month-nav"><button class="sufw-booking__nav" type="button" data-action="previous" aria-label="Vorheriger Monat">‹</button><h3 aria-live="polite">${monthFormatter.format(state.currentMonth)}</h3><button class="sufw-booking__nav" type="button" data-action="next" aria-label="Nächster Monat">›</button></div><div class="sufw-booking__weekdays" aria-hidden="true">${weekdays.map((weekday) => `<span>${weekday}</span>`).join('')}</div><div class="sufw-booking__calendar" role="grid" aria-label="Kalender ${monthFormatter.format(state.currentMonth)}">${createMonthDates(state.currentMonth).map((date) => {
if (!date) return '<span class="sufw-booking__empty" aria-hidden="true"></span>';
const key = formatDateKey(date); const daySlots = getSlotsForDate(project, key, state.events); const available = daySlots.some((event) => event.status === 'available'); const full = daySlots.length > 0 && !available; const selected = key === state.selectedDate;
const label = daySlots.length ? `${dateFormatter.format(date)}: ${available ? 'Termine buchbar' : 'Termine ausgebucht'}` : dateFormatter.format(date);
return `<button type="button" role="gridcell" class="sufw-booking__day sufw-booking__day--${available ? 'available' : full ? 'booked' : 'none'}${selected ? ' is-selected' : ''}" data-date="${key}" aria-pressed="${selected}" aria-label="${escapeHtml(label)}"><time datetime="${key}">${date.getDate()}</time>${daySlots.length ? `<small>${available ? 'freie Plätze' : 'ausgebucht'}</small>` : ''}</button>`;
}).join('')}</div><section class="sufw-booking__slots" aria-labelledby="sufw-slot-title"><h3 id="sufw-slot-title">Termine am ${escapeHtml(selectedLabel)}</h3>${slots.length ? `<ul>${slots.map((event) => `<li><button type="button" class="sufw-booking__slot" data-event-id="${event.id}" ${event.status === 'full' ? 'disabled aria-disabled="true"' : ''}><strong>${escapeHtml(formatEventTime(event))}</strong><span>${escapeHtml(event.title)}<small>${escapeHtml(formatEventDuration(event))} · ${escapeHtml(formatEventFormat(event))}</small></span><em>${event.status === 'available' ? `${event.availableCapacity} ${event.availableCapacity === 1 ? 'Platz frei' : 'Plätze frei'}` : 'Ausgebucht'}</em></button></li>`).join('')}</ul>` : '<p class="sufw-booking__empty-state">Für diesen Tag liegen keine Termine vor.</p>'}<p class="sufw-booking__status" role="status" aria-live="polite"></p></section></section>`;
container.querySelector('[data-action="previous"]').addEventListener('click', async () => { state.currentMonth = new Date(state.currentMonth.getFullYear(), state.currentMonth.getMonth() - 1, 1); state.selectedDate = ''; await loadAndRender(); });
container.querySelector('[data-action="next"]').addEventListener('click', async () => { state.currentMonth = new Date(state.currentMonth.getFullYear(), state.currentMonth.getMonth() + 1, 1); state.selectedDate = ''; await loadAndRender(); });
container.querySelectorAll('[data-date]').forEach((button) => button.addEventListener('click', () => { state.selectedDate = button.dataset.date ?? ''; render(); }));
container.querySelectorAll('[data-event-id]').forEach((button) => button.addEventListener('click', () => { const event = state.events.find((entry) => entry.id === button.dataset.eventId); if (event) openBookingDialog(container, event, state); }));
};
const loadAndRender = async () => {
container.setAttribute('aria-busy', 'true');
try { await fetchEvents(); render(); }
catch (error) { container.innerHTML = `<p class="sufw-booking__load-error" role="alert">${escapeHtml(error instanceof Error ? error.message : 'Der Kalender konnte nicht geladen werden.')}</p>`; }
finally { container.removeAttribute('aria-busy'); }
};
state.reload = loadAndRender;
loadAndRender();
}
export function initializeBookingComponents(root = document) { root.querySelectorAll('[data-sufw-booking]').forEach(renderComponent); }
if (typeof document !== 'undefined') { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => initializeBookingComponents()); else initializeBookingComponents(); }
@@ -0,0 +1,29 @@
@echo off
setlocal
set "SOURCE=%~dp0..\public\perspektivwerk"
set "TARGET=boogie:html/perspektivwerk/"
set "CONFIG=%~dp0..\public\booking-config.php"
if not exist "%CONFIG%" (
echo Konfiguration fehlt: %CONFIG%
exit /b 1
)
echo Lade den Perspektivwerk-DocumentRoot und die Konfiguration nach %TARGET% hoch ...
scp -r "%SOURCE%" "%CONFIG%" "%TARGET%"
if errorlevel 1 (
echo Upload fehlgeschlagen.
exit /b 1
)
echo Setze Leseberechtigungen auf %TARGET% ...
ssh boogie "chmod -R a+rX html/perspektivwerk/"
if errorlevel 1 (
echo Berechtigungen konnten nicht gesetzt werden.
exit /b 1
)
echo Upload abgeschlossen.
@@ -0,0 +1,34 @@
[CmdletBinding()]
param(
[string]$ComposeFile,
[string]$MigrationDirectory
)
$ErrorActionPreference = 'Stop'
$scriptDirectory = Split-Path -Parent $PSCommandPath
if ([string]::IsNullOrWhiteSpace($scriptDirectory)) {
$scriptDirectory = (Get-Location).Path
}
if ([string]::IsNullOrWhiteSpace($ComposeFile)) {
$ComposeFile = Join-Path $scriptDirectory '..\docker-compose.yml'
}
if ([string]::IsNullOrWhiteSpace($MigrationDirectory)) {
$MigrationDirectory = Join-Path $scriptDirectory '..\database\migrations'
}
if (-not (Test-Path -LiteralPath $MigrationDirectory -PathType Container)) {
throw "Das Migrationsverzeichnis '$MigrationDirectory' wurde nicht gefunden."
}
$composeFile = (Resolve-Path -LiteralPath $ComposeFile).Path
$migrations = Get-ChildItem -LiteralPath $MigrationDirectory -Filter '*.sql' -File | Sort-Object Name
foreach ($migration in $migrations) {
$sql = Get-Content -LiteralPath $migration.FullName -Raw
$sql | & docker compose -f $composeFile exec -T db sh -lc 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'
if ($LASTEXITCODE -ne 0) {
throw "Migration '$($migration.Name)' ist fehlgeschlagen."
}
Write-Host "Migration ausgeführt: $($migration.Name)"
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
require __DIR__.'/../public/booking/backend.php';
$database = bookingDatabase();
$result = bookingRunJobs($database);
$transport = getenv('BOOKING_MAIL_TRANSPORT') ?: 'database';
if ($transport === 'mail') {
$outbox = $database->query('SELECT id, recipient_email, subject, body FROM sufw_booking_outbox WHERE sent_at IS NULL ORDER BY id');
foreach ($outbox as $mail) {
if (mail($mail['recipient_email'], $mail['subject'], $mail['body'])) {
$database->prepare('UPDATE sufw_booking_outbox SET sent_at = UTC_TIMESTAMP() WHERE id = :id')->execute(['id' => $mail['id']]);
}
}
}
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL;
-21
View File
@@ -1,21 +0,0 @@
/**
* Dünne, frameworkfreie Buchungslogik. Sie kann später gegen eine Backend-API
* oder eine Kalender-Integration ausgetauscht werden, ohne das UI neu zu bauen.
*/
export type BookingProject = 'perspektivwerk' | 'produktionsschule';
export interface BookingOption {
id: string;
project: BookingProject;
title: string;
duration: string;
}
export const bookingOptions: BookingOption[] = [
{ id: 'pw-erstgespraech', project: 'perspektivwerk', title: 'Erstgespräch Perspektivwerk', duration: '1 Stunde' },
{ id: 'pw-sozpaed', project: 'perspektivwerk', title: 'Soz. Päd. Modul Perspektivwerk', duration: '4 Stunden' },
{ id: 'pw-psy', project: 'perspektivwerk', title: 'Psy Modul Perspektivwerk', duration: '4 Stunden' },
{ id: 'pw-praxis', project: 'perspektivwerk', title: 'Praxis Modul Perspektivwerk', duration: '1 Woche, ab Montag 08:00 Uhr' },
{ id: 'ps-kennenlernen', project: 'produktionsschule', title: 'Voranmeldung Kennenlerntermin Produktionsschule', duration: '1 Stunde, dienstags' },
{ id: 'ps-probearbeiten', project: 'produktionsschule', title: 'Probearbeiten', duration: '1 Woche, ab Montag 08:00 Uhr' }
];
@@ -0,0 +1,16 @@
$projectRoot = Split-Path -Parent $PSScriptRoot
$deployBatchPath = Join-Path $projectRoot 'scripts\Deploy-Perspektivwerk.bat'
$htaccessPath = Join-Path $projectRoot 'public\.htaccess'
Describe 'Perspektivwerk deployment' {
It 'deploys the complete Perspektivwerk document root and its sibling configuration file' {
$script = Get-Content -LiteralPath $deployBatchPath -Raw
$htaccess = Get-Content -LiteralPath $htaccessPath -Raw
$script | Should Match 'public\\booking-config\.php'
$script | Should Match 'public\\perspektivwerk"'
$script | Should Match 'boogie:html/perspektivwerk/'
$htaccess | Should Match '<Files "booking-config\.php">'
$htaccess | Should Match 'Require all denied'
}
}
@@ -1,5 +1,9 @@
$projectRoot = Split-Path -Parent $PSScriptRoot
$mockupPath = Join-Path $projectRoot 'public\perspektivwerk\index.html'
$bookingScriptPath = Join-Path $projectRoot 'public\perspektivwerk\js\booking.js'
$bookingStylesPath = Join-Path $projectRoot 'public\perspektivwerk\js\booking.css'
$deploymentBookingScriptPath = Join-Path $projectRoot 'public\perspektivwerk\booking\booking.js'
$deploymentBookingStylesPath = Join-Path $projectRoot 'public\perspektivwerk\booking\booking.css'
$htaccessPath = Join-Path $projectRoot 'public\.htaccess'
Describe 'Perspektivwerk static route' {
@@ -7,12 +11,50 @@ Describe 'Perspektivwerk static route' {
(Test-Path -LiteralPath $mockupPath) | Should Be $true
}
It 'embeds the appointment booking component with its public assets' {
$mockup = Get-Content -LiteralPath $mockupPath -Raw
(Test-Path -LiteralPath $bookingScriptPath) | Should Be $true
(Test-Path -LiteralPath $bookingStylesPath) | Should Be $true
$mockup | Should Match 'href="js/booking\.css"'
$mockup | Should Match 'data-sufw-booking data-project="perspektivwerk"'
$mockup | Should Match 'data-sufw-booking data-project="produktionsschule"'
$mockup | Should Match 'data-api-url="booking/api\.php"'
$mockup | Should Not Match 'data-api-url="/booking/api\.php"'
$mockup.IndexOf('data-project="perspektivwerk"') | Should BeLessThan $mockup.IndexOf('data-project="produktionsschule"')
$mockup | Should Match 'src="js/booking\.js"'
}
It 'keeps the deployed API assets and static-preview copies identical' {
(Test-Path -LiteralPath $deploymentBookingScriptPath) | Should Be $true
(Test-Path -LiteralPath $deploymentBookingStylesPath) | Should Be $true
(Get-FileHash -LiteralPath $bookingScriptPath).Hash | Should Be (Get-FileHash -LiteralPath $deploymentBookingScriptPath).Hash
(Get-FileHash -LiteralPath $bookingStylesPath).Hash | Should Be (Get-FileHash -LiteralPath $deploymentBookingStylesPath).Hash
}
It 'keeps the enabled submit button visibly rendered' {
$script = Get-Content -LiteralPath $deploymentBookingScriptPath -Raw
$script | Should Match "submitButton\.style\.visibility = 'visible'"
$script | Should Match "actions\.style\.position = 'sticky'"
}
It 'defines colours for the booking form action buttons' {
$styles = Get-Content -LiteralPath $deploymentBookingStylesPath -Raw
$styles | Should Match '\.sufw-booking__form-actions button\s*\{[^}]*background-color:\s*#08752a'
$styles | Should Match '\.sufw-booking__form-actions button\s*\{[^}]*color:\s*#fff'
}
It 'routes /perspektivwerk to the static entry point before the Contao fallback' {
$htaccess = Get-Content -LiteralPath $htaccessPath -Raw
$staticRouteIndex = $htaccess.IndexOf('RewriteRule ^perspektivwerk/?$ perspektivwerk/index.html [L]')
$adminRouteIndex = $htaccess.IndexOf('RewriteRule ^perspektivwerk/booking-admin/?$ perspektivwerk/booking-admin/index.php [L]')
$fallbackIndex = $htaccess.IndexOf('RewriteRule ^ %{ENV:BASE}/index.php [L]')
$staticRouteIndex | Should BeGreaterThan -1
$staticRouteIndex | Should BeLessThan $fallbackIndex
$adminRouteIndex | Should BeGreaterThan -1
$adminRouteIndex | Should BeLessThan $fallbackIndex
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
require __DIR__.'/../public/perspektivwerk/booking/backend.php';
function expectSame(string $expected, string $actual, string $message): void
{
if ($expected !== $actual) {
throw new RuntimeException($message."\nExpected: $expected\nActual: $actual");
}
}
function expectContains(string $needle, string $haystack, string $message): void
{
if (!str_contains($haystack, $needle)) throw new RuntimeException($message);
}
$configFile = tempnam(sys_get_temp_dir(), 'sufw-booking-config-');
if ($configFile === false) throw new RuntimeException('Temporary configuration file could not be created.');
try {
file_put_contents($configFile, "<?php\nreturn ['database_url' => 'mysql://fallback:secret@database:3306/booking'];\n");
expectSame(
'mysql://fallback:secret@database:3306/booking',
bookingDatabaseUrl('', $configFile),
'The configuration file must be used when DATABASE_URL is empty.'
);
expectSame(
'mysql://environment:secret@database:3306/booking',
bookingDatabaseUrl('mysql://environment:secret@database:3306/booking', $configFile),
'DATABASE_URL must take precedence over the configuration file.'
);
expectSame('disabled', bookingMailTransport('disabled'), 'Local mail delivery must be explicitly disabled.');
$calendar = bookingIcsCalendar([
'id' => '44c5d0f9-6c98-4b04-8d25-c32834d21a61',
'project' => 'perspektivwerk',
'title' => 'Erstgespräch PerspektivWerk',
'starts_at' => '2026-09-14 09:00:00',
'ends_at' => '2026-09-14 10:00:00',
'format' => 'vor_ort',
]);
expectContains('BEGIN:VCALENDAR', $calendar, 'The confirmation attachment must be an iCalendar document.');
expectContains('UID:44c5d0f9-6c98-4b04-8d25-c32834d21a61@sufw-booking', $calendar, 'The iCalendar attachment must contain the event UID.');
} finally {
unlink($configFile);
}
echo "booking backend configuration tests passed\n";
@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
test('liefert eine externe, idempotente Anfangsmigration für Termine und Anmeldungen', async () => {
const migration = await readFile(new URL('../database/migrations/001_booking_schema.sql', import.meta.url), 'utf8');
assert.match(migration, /CREATE TABLE IF NOT EXISTS `sufw_booking_events`/);
assert.match(migration, /CREATE TABLE IF NOT EXISTS `sufw_booking_registrations`/);
assert.match(migration, /FOREIGN KEY \(`event_id`\)/);
assert.match(migration, /CHECK \(`capacity` BETWEEN 1 AND 100\)/);
});
test('hält die statische Vorschaukopie der Buchungsassets identisch', async () => {
const [componentScript, previewScript, componentStyle, previewStyle] = await Promise.all([
readFile(new URL('../public/perspektivwerk/booking/booking.js', import.meta.url), 'utf8'),
readFile(new URL('../public/perspektivwerk/js/booking.js', import.meta.url), 'utf8'),
readFile(new URL('../public/perspektivwerk/booking/booking.css', import.meta.url), 'utf8'),
readFile(new URL('../public/perspektivwerk/js/booking.css', import.meta.url), 'utf8'),
]);
assert.equal(previewScript, componentScript);
assert.equal(previewStyle, componentStyle);
});
test('modelliert Bestätigung, Mailausgang und ICS-Feeds als zweite Migration', async () => {
const migration = await readFile(new URL('../database/migrations/002_booking_workflow.sql', import.meta.url), 'utf8');
assert.match(migration, /confirmation_token_hash/);
assert.match(migration, /confirmation_expires_at/);
assert.match(migration, /CREATE TABLE IF NOT EXISTS `sufw_booking_outbox`/);
assert.match(migration, /CREATE TABLE IF NOT EXISTS `sufw_booking_project_settings`/);
assert.match(migration, /UNIQUE KEY `sufw_booking_outbox_registration_kind`/);
});
test('entfernt den Test-Mailausgang mit einer Folgemigration', async () => {
const migration = await readFile(new URL('../database/migrations/006_remove_booking_outbox.sql', import.meta.url), 'utf8');
assert.match(migration, /DROP TABLE IF EXISTS `sufw_booking_outbox`/);
assert.match(migration, /INSERT IGNORE INTO `sufw_booking_schema_migrations` \(`version`\) VALUES \('006_remove_booking_outbox'\)/);
});
test('liefert lokale Demo-Termine und bestätigte Teilnahmen vor und nach dem heutigen Datum', async () => {
const migration = await readFile(new URL('../database/migrations/003_booking_demo_data.sql', import.meta.url), 'utf8');
assert.match(migration, /Nur für lokale Entwicklungs- und Testdatenbanken/);
assert.match(migration, /DATE_ADD\(CURDATE\(\), INTERVAL -1 MONTH\)/);
assert.match(migration, /DATE_ADD\(CURDATE\(\), INTERVAL 1 MONTH\)/);
assert.match(migration, /'confirmed'/);
assert.match(migration, /sufw_booking_registrations/);
});
test('erweitert Demo-Kontakte um die vorgesehenen Ansprechpartnerdaten', async () => {
const migration = await readFile(new URL('../database/migrations/004_booking_contact_phones.sql', import.meta.url), 'utf8');
assert.match(migration, /ADD COLUMN IF NOT EXISTS `contact_phone`/);
assert.match(migration, /Carina Hollederer/);
assert.match(migration, /Sarah van den Heuvel/);
});
test('liefert zusätzliche lokale Demo-Termine für Juli und August', async () => {
const migration = await readFile(new URL('../database/migrations/005_booking_demo_july_august.sql', import.meta.url), 'utf8');
assert.match(migration, /Nur für lokale Entwicklungs- und Testdatenbanken/);
assert.match(migration, /-07-10 09:00:00/);
assert.match(migration, /-08-12 09:00:00/);
assert.match(migration, /sufw_booking_registrations/);
});
test('liefert die unabhängige Verwaltung unter booking-admin mit getrennten Bereichen', async () => {
const admin = await readFile(new URL('../public/perspektivwerk/booking-admin/index.php', import.meta.url), 'utf8');
assert.match(admin, /view === 'registrations'/);
assert.match(admin, /view === 'export'/);
assert.doesNotMatch(admin, /getenv\(/);
assert.match(admin, />Teilnahmen</);
assert.match(admin, />Export</);
assert.match(admin, /data-datetime-picker/);
assert.match(admin, /data-date-picker/);
assert.doesNotMatch(admin, /datetime-local/);
assert.match(admin, /data-picker-hour/);
assert.match(admin, /data-picker-quarter/);
assert.match(admin, /<\?php if \(\$view === 'events'\): \?><div class="picker-time" data-picker-time-controls>/);
assert.match(admin, /Array\.from\(\{length:24\}/);
assert.match(admin, /\['00','15','30','45'\]/);
assert.match(admin, /defaultDate\.setHours\(9,0,0,0\)/);
assert.match(admin, /data-picker-close/);
assert.match(admin, /<select name="title"/);
assert.match(admin, /data-dropdown-indicator/);
assert.match(admin, /SELECT DISTINCT contact_name, contact_email, contact_phone/);
assert.match(admin, /contactByName\[contactName\.value\]/);
assert.match(admin, /contactByEmail\[contactEmail\.value\]/);
assert.match(admin, /booking-datetime-change/);
assert.match(admin, /id="export-project"/);
assert.match(admin, /DATE\(e\.starts_at\) AS event_day/);
assert.match(admin, /export-project/);
assert.match(admin, /new DateTimeImmutable\('first day of next month'\)/);
assert.doesNotMatch(admin, /text\/csv|fputcsv/i);
});
+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { bookingConfirmationMessage, canSubmitBookingForm, formatDateKey, formatEventDuration, getSlotsForDate, requiresIntroductionDetails, validateBookingForm } from '../public/perspektivwerk/booking/booking.js';
const events = [
{ id: 'one', project: 'perspektivwerk', startsAt: '2026-09-14 08:00:00', endsAt: '2026-09-18 16:00:00', status: 'available' },
{ id: 'two', project: 'perspektivwerk', startsAt: '2026-09-15 09:00:00', endsAt: '2026-09-15 13:00:00', status: 'full' },
];
test('liefert auch mehrtägige Termine für jeden betroffenen Kalendertag', () => {
const slots = getSlotsForDate('perspektivwerk', '2026-09-15', events);
assert.equal(slots.length, 2);
assert.equal(slots.filter((slot) => slot.status === 'available').length, 1);
});
test('liefert für einen Tag ohne Termin keine Slots', () => {
assert.deepEqual(getSlotsForDate('produktionsschule', '2026-09-15', events), []);
});
test('verschiebt Kalendertage in der Sommerzeit nicht auf den Vortag', () => {
process.env.TZ = 'Europe/Berlin';
assert.equal(formatDateKey(new Date(2026, 7, 24)), '2026-08-24');
});
test('validiert die Pflichtangaben der Buchungsmaske', () => {
assert.deepEqual(validateBookingForm({ name: '', email: 'ungültig', privacyAccepted: false }), {
name: 'Bitte geben Sie Ihren Namen ein.', email: 'Bitte geben Sie eine gültige E-Mail-Adresse ein.', phone: 'Bitte geben Sie eine gültige Telefonnummer ein.', privacy: 'Bitte bestätigen Sie die Datenschutzhinweise.'
});
});
test('verlangt im SOLL-Stand eine Telefonnummer', () => {
assert.equal(validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', privacyAccepted: true }).phone, 'Bitte geben Sie eine gültige Telefonnummer ein.');
});
test('akzeptiert die vollständigen Pflichtangaben der Buchungsmaske', () => {
assert.deepEqual(validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', phone: '+49 351 123456', privacyAccepted: true }), {});
});
test('setzt die Zusatzangaben für Probearbeiten durch', () => {
assert.equal(requiresIntroductionDetails({ project: 'produktionsschule', introductionRequired: true }), true);
assert.equal(requiresIntroductionDetails({ project: 'perspektivwerk', introductionRequired: true }), false);
assert.deepEqual(validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', phone: '+49 351 123456', privacyAccepted: true, event: { project: 'produktionsschule', introductionRequired: true } }), {
introductionAttended: 'Bitte bestätigen Sie die Teilnahme an einer Einführungsveranstaltung.', introductionDate: 'Bitte geben Sie das Datum der Einführungsveranstaltung ein.'
});
});
test('formatiert kurze und mehrtägige Termindauern', () => {
assert.equal(formatEventDuration({ startsAt: '2026-09-15 09:00:00', endsAt: '2026-09-15 13:00:00' }), '4 Stunden');
assert.equal(formatEventDuration({ startsAt: '2026-09-21 08:00:00', endsAt: '2026-09-25 16:00:00' }), '5 Tage');
});
test('gibt den Absenden-Button erst nach DSGVO-Einwilligung frei', () => {
assert.equal(canSubmitBookingForm(false), false);
assert.equal(canSubmitBookingForm(true), true);
});
test('zeigt nach dem Absenden den nächsten Bestätigungsschritt ohne Mockup-Hinweis', () => {
const message = bookingConfirmationMessage('Erstgespräch');
assert.match(message, /E-Mail/);
assert.doesNotMatch(message, /Vorschau|Mockup/i);
});
-11
View File
@@ -1,11 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "Bundler",
"strict": true,
"noEmitOnError": true,
"outDir": "public/perspektivwerk/js"
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,44 @@
Bedarfanalyse Gespräche 27.08.2026 mit Sarah (Perspektivwerk) und Corinna (Produktionsschule)
1) Das technische Umfeld in dem der Kalender genau stattfinden wird, ist noch nicht genau definiert. Es könnte als Komponente in Contao integriert werden, oder als unabhängiges eigenständiges tool mit eigener Runtime, Datenbank(Model) und Backend implementiert werden
2) Damit Besucher ser Seiten Perspektivwerk (PW) und Produktionsschule (PS) nicht verwirrt sind, sollte nur dessen dezidierte Kalenderinstanz angezeigt werden. Kein Umschalten zwischen beiden möglich. Aber technisch sollten beide Kalender von der gleichen technischen Basis ausgeliefert werden.
3) Im Backend soll wärend der Konfiguration des Kalenders eine Demo dessen live angezeigt werden, damit Änderungen ander Konfiguration sofort sichtbar sind.
4) PS: einer der Termintypen heißt Probearbeiten, dieser
- 4.1) setzt vorraus das bereits die Einführungsveranstaltung absolviert wurde. Das wird geprüft durch eine entsprechende Checkbox und ein Eingabefeld in dem der Termin der Einführungsveranstaltung eingegeben werden muss.
- 4.2) Mehrfachanmeldungen sollen erkannt werden. Dazu wird, wenn Anmeldung bestätigt, der Anmeldename und die Email in einer History gespeichert. In der History kann geschaut werden, ob die Person bereits früher angemeldet war.
- 4.3) Das eine Person sich für mehrere Termine angemeldet hat wird in der Bestätigungsmail für einen zu buchenden Termin erwähnt und diese genannt.
5) Alle Prozesse und Funktionen des Kalenders sollen durch technische Tests abgedeckt sein. Zb. Unit-Tests und Integration-tests.
6) Jeder Termin hat eine definierte Auslastung/Kapazität (1-100). Wenn die angegebene Anzahl erreicht ist, wird der Termin als nicht weiter buchbar geführt.
7) Im Kalender ist die Dauer eines Termins klar visuell erkennbar. Termine die nur bis max 4h dauern werden nicht weiter visuell hervorgehoben, aber ist ein Termin länger ist ein sichtbarer Balken für den Termin über den ganzen Tag zu sehen. Und Termine die über mehrere Tage gehen, haben einen sichtbaren Balken der sich über alle Tage ersteckt.
8) Es ist zu prüfen, ob Bestätigungen nicht nur via E-Mail versendet werden können sondern auch via Signal Messanger.
9) In der Terminbuchung soll keine freitext Nachricht ausfüllbar sein, kein entsprechendes Feld existieren.
10) Es soll jeweils für PS und PW einen eigenen abonnierbaren ical Kalender geben, der immer aktuell alle bestätigten Termine enthält. Sobalt ein neuer Termin bestätigt ist, wird der ical Kalender entsprechend aktualisiert.
11) Aktuell ist keine Benutzerverwaltung angedacht. Jeder der den Backend-Link hat, kann Termine eintragen/bearbeiten/löschen.
12) Stornierung von Terminen durch Klienten soll aktuell einfach nur dadurch funktionieren, das diese sich bei der durchführenden Person melden können (Kontakt in Bestätigungsmail enthalten). Diese kann die angemeldete Person dann aus der Veranstaltung löschen.
13) Klienten erhalten zwei Tage vor Beginn der Veranstaltung eine Errinerungsmail
14) Für Klienten soll es einfach möglich sein mehrere Termine nacheinander zu buchen, dazu sollen die eingegebenen Anmeldedaten im Browser gespeicehrt bleiben, und angewendet werden, wenn ein weitere Termin gebucht wird.
15) Ein Termin kann ein vor Ort oder ein Telefonischer Termin sein.
16) BEi der Anmeldung für einen Termin ist die Angabe einer Telefonnummer verbindlich
17) Exportfunktion im Backend: je für PS und PW sollen alle bestätigten Teilnehmer aller Termine eines definierbaren Zeitraums als Liste ausgegeben werden können.
Bekannte Kalender Parameter welche durch Datenbank, Fontend und Backend abgedeckt sein müssen:
- Wird der Kalender des PS oder PW angezeigt
- historische Teilnehmer an Terminen (name, email, Veranstaltungs-ID)
- Termindaten: ID, Name, Start, Ende, PS oder PW, Kapazität, Anmeldebedingungen (Einführungsveranstaltung besucht), durchführende Person (Benutzerliste mit Name + Email), Format (vor Ort oder Telefonisch)
@@ -0,0 +1,26 @@
# Produktbeschreibung – Terminbuchungskalender (Mockup)
## Zweck dieses Dokuments
Dieses Dokument beschreibt den gegenwärtigen Stand des Terminbuchungskalenders. Es ist die fachliche Ausgangsbasis. Technische Einbettung und Auslieferung sind ausdrücklich nicht Teil des beschriebenen Produkts.
## Produktgegenstand
Der Produktgegenstand ist ein eigenständiger, im Browser laufender Kalender für Terminangebote. Er soll Besuchenden ermöglichen, verfügbare Termine eines Projekts zu finden, einen passenden Slot auszuwählen und eine Terminanfrage vorzubereiten.
Das Mockup kennt die Projekte `perspektivwerk` und `produktionsschule`. Eine Instanz des Kalenders ist jeweils genau einem Projekt zugeordnet. Eine Auswahl zwischen Projekten innerhalb derselben Kalenderansicht ist gegenwärtig nicht vorhanden.
## Aktueller Nutzen
Das Mockup macht die geplante Nutzerführung erfahrbar:
- freie und belegte Tage sind im Monatskalender unterscheidbar,
- die Auswahl eines Tages zeigt dessen Slots,
- freie Slots führen zu einem Eingabedialog für eine Terminanfrage,
- der Dialog veranschaulicht die für eine spätere Anfrage denkbaren Angaben und die Datenschutzbestätigung.
## Abgrenzung des aktuellen Mockups
Das Mockup ist kein produktives Buchungssystem. Es reserviert keine Termine, speichert keine Eingaben und versendet keine E-Mails. Eine angezeigte Bestätigung ist ausschließlich eine lokale Erfolgsmeldung im Browser.
Nicht Teil dieses Produktstands sind insbesondere Mitarbeitendenverwaltung, Terminpflege, Kalenderabonnements, ICS-Dateien, E-Mail-Bestätigungen, Stornierungen und eine serverseitige Schnittstelle.
@@ -0,0 +1,39 @@
# Funktionsstand – Terminbuchungskalender (Mockup)
## Kalenderansicht
Der Kalender zeigt einen Monat als Raster von Montag bis Sonntag. Die Monatsansicht lässt sich mit Schaltflächen vor- und zurückblättern. Der anfänglich gezeigte Monat richtet sich nach dem ersten vorhandenen Beispiel-Slot des konfigurierten Projekts.
Tage mit mindestens einem freien Slot werden grün hervorgehoben und mit „frei“ beschriftet. Tage, an denen ausschließlich belegte Beispiel-Slots liegen, werden grau markiert und mit „belegt“ beschriftet. Tage ohne Slots bleiben neutral. Eine Legende erläutert die beiden Zustände.
Jeder Tag ist über die Tastatur erreichbar. Für Screenreader enthält die Tagesauswahl ein Datum und – soweit vorhanden – den Verfügbarkeitszustand.
## Slot-Auswahl
Nach Auswahl eines Tages mit potentiell buchbaren Slots erscheint darunter eine Liste der Slots. Jeder Eintrag enthält Startzeit, Titel und Status. Belegte Slots sind als deaktivierte Schaltflächen dargestellt und können nicht ausgewählt werden. Freie Slots öffnen den Dialog zur Terminanfrage.
## Dialog zur Terminanfrage
Für einen freien Slot öffnet sich ein Dialog. Er zeigt Projekt, Datum, Uhrzeit und Terminart. Der Dialog enthält derzeit folgende Eingabefelder:
- Vor- und Nachname – Pflichtfeld,
- E-Mail-Adresse – Pflichtfeld,
- Telefonnummer – freiwillig,
- Alter – freiwillig,
- Nachricht – freiwillig,
- Ansprechperson bei Agentur für Arbeit oder Jobcenter – freiwillig,
- Bestätigung des dargestellten Datenschutzhinweises – Pflichtfeld.
Die Eingaben werden lokal validiert. Der Absende-Button wird erst nach Zustimmung zum Datenschutzhinweis aktiv. Beim Absenden prüft das Mockup die Felder, schließt den Eingabedialog und zeigt eine lokale Bestätigung an.
## Nicht vorhandene Funktionalität
Ein Absenden löst keine reale Aktion aus. Es gibt keine Speicherung, keine Slot-Reservierung, keine Double-Opt-in-E-Mail, keine Frist, keine Benachrichtigung und keine Sperrung gegen parallele Buchungen.
## Checkpoints
- [ ] Es ist entschieden, welche Terminstatus Besuchende sehen dürfen.
- [ ] Es ist bestätigt, welche Daten tatsächlich erhoben werden dürfen und müssen.
- [ ] Es ist entschieden, ob die dargestellte Zustimmung vor der Anfrage erforderlich ist und wie sie formuliert wird.
- [ ] Der künftige Ablauf nach dem Absenden ist fachlich festgelegt.
- [ ] Die Anforderungen für Stornierung, Umbuchung und Fehlerfälle sind dokumentiert.
@@ -0,0 +1,40 @@
# Technischer Stand – Terminbuchungskalender (Mockup)
## Komponentenform
Der Kalender ist eine browsernative JavaScript-Komponente als ES-Modul. Sie wird an einem Element mit dem Attribut `data-sufw-booking` initialisiert. Das Attribut `data-project` legt das Projekt fest; ein anderer oder fehlender Wert wird derzeit als `perspektivwerk` behandelt.
Die Darstellung ist mit eigenem CSS umgesetzt. Die Klassen beginnen mit `sufw-booking`, damit die Stile möglichst wenig mit ihrer Umgebung kollidieren.
## Zustandsverwaltung
Der Zustand existiert ausschließlich im Arbeitsspeicher des Browsers:
- ausgewähltes Projekt,
- aktuell dargestellter Monat,
- ausgewählter Tag,
- lokal geöffneter Dialog und dessen Formulareingaben.
Eine erneute Initialisierung oder ein Neuladen der Seite setzt diesen Zustand zurück.
## Datenquelle
Slots sind als statisches JavaScript-Array im Mockup hinterlegt. Jeder Slot enthält eine Kennung, ein Projekt, ein Datum, eine Startzeit, einen Titel und einen Status. Die Komponente filtert diese Daten nach Projekt und Datum.
Es gibt keine Netzwerkanfrage, keine Datenbankverbindung und keine serverseitige Geschäftslogik.
## Eingabeverarbeitung
Die Formularvalidierung findet vollständig im Browser statt. Sie prüft insbesondere Pflichtangaben, ein einfaches E-Mail-Format, optionale Telefon- und Altersangaben sowie Längenbegrenzungen. Die Slotdaten werden bei der HTML-Ausgabe maskiert. Die Formulareingaben werden nicht gespeichert oder weitergegeben.
## Zugänglichkeit und Browserverhalten
Die Komponente verwendet semantische Schaltflächen, Beschriftungen, `aria-live`-Bereiche, Statusinformationen und einen Dialog. Browser ohne Unterstützung für `dialog.showModal()` erhalten eine einfache offene Dialogvariante. Eine vollständige Prüfung mit Hilfstechnologien ist noch nicht erfolgt.
## Checkpoints
- [ ] Die künftige Datenquelle für Slots ist entschieden.
- [ ] Die serverseitigen Zustände und Regeln für Reservierung und Buchung sind definiert.
- [ ] Die Schnittstelle zwischen Oberfläche und Backend ist dokumentiert.
- [ ] Die Barrierefreiheit ist mit Tastatur, Screenreader und mobilen Geräten geprüft.
- [ ] Die Browserunterstützung des Endprodukts ist festgelegt.
@@ -0,0 +1,30 @@
# Datenmodell – Terminbuchungskalender (Mockup)
## Gegenwärtiges Mockup-Datenmodell
Das Mockup besitzt kein persistentes Datenmodell. Es verwendet nur flüchtige Beispielobjekte für Slots. Ein Beispiel-Slot enthält:
- `id`: technisch eindeutige Beispielkennung,
- `project`: `perspektivwerk` oder `produktionsschule`,
- `date`: Datum im Format `YYYY-MM-DD`,
- `start`: Startzeit im Format `HH:MM`,
- `title`: sichtbare Bezeichnung der Terminart,
- `status`: `available` oder `booked`.
Die Slots sind die einzige dauerhafte Information im Quellcode. Sie sind Testdaten und keine tatsächlichen Verfügbarkeiten.
## Flüchtige Anfragedaten
Der Anfrage-Dialog verarbeitet Name, E-Mail-Adresse, Telefonnummer, Alter, Nachricht, Ansprechperson und Zustimmungsstatus nur im Browser. Diese Werte werden weder als Buchung noch als Anfrageobjekt angelegt und nach dem Schließen des Dialogs verworfen.
## Fachlich noch nicht modelliert
Das Mockup enthält keine Entitäten für Buchung, Reservierung, Bestätigung, Stornierung, Terminraum, Mitarbeitende, Kalenderfeed, E-Mail oder Löschfristen. Auch Beziehungen zwischen Terminarten, Dauer, Ressourcen und Verfügbarkeitsregeln sind nicht modelliert.
## Checkpoints
- [ ] Es ist definiert, was einen buchbaren Slot fachlich eindeutig identifiziert.
- [ ] Es ist entschieden, welche Buchungszustände benötigt werden.
- [ ] Es ist festgelegt, welche Daten zu einer Anfrage gespeichert werden dürfen.
- [ ] Es ist geklärt, ob Räume, Mitarbeitende oder weitere Ressourcen modelliert werden müssen.
- [ ] Es existiert ein fachlich bestätigtes Datenmodell einschließlich Aufbewahrungs- und Löschregeln.
@@ -0,0 +1,23 @@
# Benutzerrollen – Terminbuchungskalender (Mockup)
## Aktive Rolle im Mockup: Besuchende
Das Mockup bildet ausschließlich die Rolle einer besuchenden Person ab. Diese Person kann einen Kalender ansehen, einen Tag wählen, freie und belegte Slots unterscheiden und für einen freien Slot eine lokale Terminanfrage simulieren.
Die Rolle ist nicht authentifiziert. Es gibt keine Kontoerstellung, keine Anmeldung und keine Rechteprüfung.
## Konfiguration statt Rolle: Projektzuordnung
Die Projektwerte `perspektivwerk` und `produktionsschule` sind keine Benutzerrollen. Sie bestimmen nur, welche Beispiel-Slots eine Kalenderinstanz anzeigt.
## Nicht vorhandene Rollen
Das Mockup enthält keine Funktionen für Mitarbeitende, Koordination, Terminverwaltung, Datenschutzbearbeitung oder Administration. Es zeigt daher auch keine internen Buchungsdaten und erlaubt keine Änderung von Slots.
## Checkpoints
- [ ] Die tatsächlichen Benutzergruppen des Endprodukts sind benannt.
- [ ] Für jede Benutzergruppe sind erlaubte Aktionen und sichtbare Daten festgelegt.
- [ ] Es ist entschieden, ob Mitarbeitende ausschließlich lesend oder auch verwaltend arbeiten.
- [ ] Es ist geregelt, wer Datenschutzanfragen bearbeiten darf.
- [ ] Das Berechtigungsmodell ist vor einer produktiven Buchungsfunktion fachlich und technisch bestätigt.
@@ -0,0 +1,28 @@
# Datenschutz und Sicherheit – Terminbuchungskalender (Mockup)
## Datenschutzstatus des Mockups
Das Mockup speichert und überträgt keine personenbezogenen Daten. Formularangaben werden nur für die unmittelbare Browservalidierung verarbeitet. Nach dem Schließen des Dialogs besteht im Mockup keine Buchung und keine dauerhafte Aufzeichnung der Eingaben.
Der sichtbare Hinweis auf Art. 13 DSGVO ist gegenwärtig ein Platzhalter für die geplante Nutzerführung. Er stellt weder eine geprüfte Datenschutzerklärung noch eine dokumentierte Rechtsgrundlage dar.
## Sicherheitsstatus des Mockups
Es gibt keine API und damit keine serverseitige Eingabeverarbeitung, Authentifizierung, Autorisierung oder Datenbankabfrage. Das Mockup kann daher weder produktive Zugriffskontrollen noch Schutzmaßnahmen gegen Mehrfachbuchung, Bot-Anfragen oder E-Mail-Missbrauch demonstrieren.
Die Ausgabe der statischen Slotdaten in dynamisch erzeugtes HTML erfolgt maskiert. Dies ist eine lokale Schutzmaßnahme, ersetzt aber keine vollständige Eingabevalidierung und Ausgabekodierung im Endprodukt.
## Anforderungen vor produktivem Einsatz
Sobald personenbezogene Daten verarbeitet werden, müssen Zweck, Rechtsgrundlage, Datenminimierung, Speicherfrist, Löschprozess, Betroffenenrechte, Berechtigungskonzept und technische Schutzmaßnahmen fachlich und rechtlich abgestimmt sein.
Die endgültige Lösung benötigt außerdem serverseitige Validierung, CSRF-Schutz, Rate Limits, einen datenschutzgerechten Bot-Schutz, geschützte Protokollierung und kontrollierte Fehlermeldungen. E-Mail-Bestätigungen, Tokens und Kalenderdaten dürfen nicht allein im Browser umgesetzt werden.
## Checkpoints
- [ ] Die Datenschutzinformation nach Art. 13 DSGVO ist fachlich und rechtlich abgestimmt.
- [ ] Rechtsgrundlage, Zweck und minimal erforderliche Eingabedaten sind bestätigt.
- [ ] Speicherfristen und Löschkonzept sind definiert.
- [ ] Zugriffs- und Berechtigungskonzept für Buchungsdaten ist bestätigt.
- [ ] Sicherheitsanforderungen für API, E-Mail und Missbrauchsschutz sind definiert.
- [ ] Vor Produktivsetzung ist eine gesonderte Sicherheitsanalyse durchgeführt.
@@ -0,0 +1,34 @@
# Produktbeschreibung – Terminbuchungskalender (SOLL)
## Stand und Geltung
Dieses Zielbild verarbeitet die Ergebnisse des ersten Bedarfsgesprächs vom 27.08.2026. Es beschreibt ausschließlich das geplante Produkt **Terminbuchungskalender**. Technische Einbettung und konkrete Betriebsumgebung sind noch nicht entschieden und nicht Gegenstand dieser Produktbeschreibung.
## Produktziel
Der Terminbuchungskalender ermöglicht Klientinnen und Klienten, verfügbare Termine für Perspektivwerk (PW) oder Produktionsschule (PS) zu finden, verbindlich anzufragen und nach Bestätigung wahrzunehmen. Er bildet den gesamten Ablauf vom Sichtbarwerden eines Termins bis zur Bestätigung, Erinnerung, gegebenenfalls Stornierung durch die durchführende Person und Auswertung bestätigter Teilnahmen ab.
## Zwei getrennte Kalenderinstanzen
Für PW und PS wird jeweils eine eigene Kalenderinstanz angezeigt. Besuchende sehen nur den Kalender des jeweiligen Angebots; ein Umschalten zwischen PW und PS innerhalb der Kalenderoberfläche ist nicht vorgesehen. Beide Instanzen werden aus derselben fachlichen und technischen Basis erzeugt, besitzen aber eigene Termine, eigene bestätigte Teilnahmen und je einen eigenen abonnierbaren iCalendar-Kalender.
## Nutzen für Besuchende
Besuchende können Terminart, Zeitpunkt, Dauer, Format und Verfügbarkeit erkennen. Sie können mehrere Termine nacheinander anfragen, ohne erforderliche Anmeldedaten jeweils erneut einzugeben. Eine bestätigte Anfrage wird per E-Mail bestätigt; die Nachricht weist gegebenenfalls auf weitere bereits bestätigte Termine hin, nennt eine Kontaktperson für Durchführung und Stornierungen des Termins und wird zwei Tage vor Beginn durch eine Erinnerung ergänzt.
## Nutzen für die Durchführung
Personen mit Zugriff auf die Verwaltungsoberfläche können Termine und ihre Konfiguration pflegen, Teilnahmen verwalten und bestätigte Teilnehmende eines frei wählbaren Zeitraums exportieren. Änderungen an der Konfiguration sollen in einer Live-Vorschau des Kalenders unmittelbar sichtbar sein.
## Abgrenzung und offene Grundentscheidung
Eine eigenständige Benutzerverwaltung ist nach aktuellem Bedarf nicht vorgesehen. Der Zugriff auf die Verwaltungsoberfläche erfolgt über einen Backend-Link. Diese Vorgabe ist wegen der darin verarbeiteten personenbezogenen Daten sicherheits- und datenschutzrechtlich vor Produktivsetzung verbindlich zu bewerten.
Noch offen ist, ob der Kalender innerhalb eines bestehenden Systems oder als eigenständiges Tool mit eigener Laufzeit, Datenhaltung und Verwaltungsoberfläche betrieben wird.
## Checkpoints
- [ ] Das Zielbild ist mit PW und PS fachlich abgestimmt.
- [ ] Die Entscheidung über die Betriebsarchitektur ist getroffen.
- [ ] Das Zugriffsmodell für die Verwaltungsoberfläche ist trotz fehlender Benutzerverwaltung freigegeben.
- [ ] Die fachliche Bedeutung von „bestätigt“ und „verbindlich“ ist eindeutig festgelegt.
@@ -0,0 +1,47 @@
# Geplanter Funktionsumfang – Terminbuchungskalender (SOLL)
## Kalender und Terminanzeige
Der Kalender zeigt nur Termine der zugeordneten Instanz PW oder PS. Jeder Termin besitzt eine einstellbare Kapazität von 1 bis 100 Teilnahmen. Solange freie Kapazität vorhanden ist, ist er buchbar; nach Erreichen der Kapazität wird er als nicht weiter buchbar geführt.
Die Dauer eines Termins muss sichtbar sein. Termine bis einschließlich vier Stunden benötigen keine besondere Verlaufsdarstellung. Längere Termine erhalten einen sichtbaren Balken über den gesamten betroffenen Tag. Mehrtägige Termine erhalten einen zusammenhängenden Balken über alle betroffenen Kalendertage.
Für jeden Termin wird sichtbar, ob er vor Ort oder telefonisch stattfindet. Beginn, Ende, Name und Verfügbarkeit sind aus der Kalender- und Detailansicht nachvollziehbar.
## Anmeldung und Bestätigung
Eine Anmeldung erfordert mindestens Name, E-Mail-Adresse und Telefonnummer, ein weiteres Pflichtfeld kann ausserdem aktiviert werden. Die Zustimmung zu den Datenschutzhinweisen bleibt vor dem Absenden erforderlich.
Nach der Anfrage erfolgt die vorgesehene Bestätigung per E-Mail. Nach Bestätigung zählt die Teilnahme zur Kapazität und erscheint im iCalendar-Kalender der jeweiligen Instanz.
Bei einer neuen Bestätigung prüft das System historische und bereits bestätigte Anmeldungen derselben Person anhand von Name und E-Mail-Adresse. Bestehen weitere bestätigte Termine, nennt die Bestätigungsmail diese Termine.
## Besonderheit PS: Probearbeiten
Für die PS-Terminart „Probearbeiten“ ist die vorherige Teilnahme an einer Einführungsveranstaltung Voraussetzung. Die Anmeldung verlangt dafür eine Bestätigung per Checkbox und die Angabe des Datums der Einführungsveranstaltung. Die fachliche und technische Prüfung dieser Angabe ist noch zu konkretisieren.
## Mehrfachbuchung und Stornierung
Besuchende sollen mehrere Termine unmittelbar nacheinander buchen können. Die bereits eingegebenen Anmeldedaten bleiben dafür im Browser verfügbar und werden für die nächste Anmeldung vorausgefüllt. Art und Dauer dieser lokalen Speicherung sind noch festzulegen.
Eine Selbststornierung durch Besuchende ist aktuell nicht vorgesehen. Die Bestätigungsmail enthält die Kontaktangabe der durchführenden Person. Diese entfernt die angemeldete Person bei Bedarf aus dem Termin; die frei werdende Kapazität wird wieder verfügbar.
## Erinnerungen, Kalender und Export
Zwei Tage vor Beginn einer bestätigten Teilnahme wird eine Erinnerungsmail versendet. Für PW und PS existiert jeweils ein abonnierbarer iCalendar-Kalender mit allen bestätigten Terminen; dieser wird bei jeder neuen Bestätigung oder Stornierung aktualisiert.
Die Verwaltungsoberfläche bietet je Instanz einen Export aller bestätigten Teilnahmen für einen frei definierbaren Zeitraum.
## Qualitätssicherung
Alle Prozesse und Funktionen des Kalenders werden durch technische Tests abgedeckt. Dazu gehören mindestens Unit-Tests für Geschäftsregeln sowie Integrations- und End-to-End-Tests für die zentralen Buchungsabläufe.
## Checkpoints
- [ ] Der genaue Bestätigungsablauf einschließlich möglichem Double-Opt-in ist festgelegt.
- [ ] Die Nutzung von Signal Messenger ist fachlich, rechtlich und technisch entschieden.
- [ ] Die Kriterien zur Erkennung einer Mehrfachanmeldung sind bestätigt.
- [ ] Die Prüfung der Einführungsveranstaltung für Probearbeiten ist definiert.
- [ ] Die Darstellung mehrtägiger und langer Termine ist fachlich abgenommen.
- [ ] Die Regeln für frei werdende Kapazitäten nach Löschung einer Teilnahme sind festgelegt.
- [ ] Die Inhalte von Bestätigungs- und Erinnerungsmail sind abgestimmt.
@@ -0,0 +1,41 @@
# Technisches Zielbild – Terminbuchungskalender (SOLL)
## Architekturprinzip
Der Kalender wird als wiederverwendbare technische Basis für zwei getrennte Instanzen PW und PS umgesetzt. Die Oberfläche darf keine fachlichen Daten, Geheimnisse oder Buchungsentscheidungen dauerhaft im Browser halten. Termine, Kapazitäten, Anmeldungen, Bestätigungen, Erinnerungen, Exporte und Kalenderfeeds benötigen eine serverseitige Geschäftslogik und persistente Datenhaltung.
Die konkrete Betriebsform bleibt offen: Integration in ein bestehendes System oder eigenständiges Tool mit eigener Laufzeit, Datenbankmodell und Verwaltungsoberfläche. Unabhängig davon müssen Oberfläche, Geschäftslogik, Datenzugriff, Benachrichtigung und Kalenderfeed voneinander getrennt bleiben.
## Konfiguration und Vorschau
Die Verwaltungsoberfläche erlaubt das Anlegen, Bearbeiten und Löschen von Terminen sowie die Pflege ihrer Parameter. Während einer Konfigurationsänderung wird eine Live-Vorschau der jeweiligen Kalenderinstanz angezeigt, damit sich die sichtbare Wirkung ohne Zwischenschritt prüfen lässt.
## Buchungslogik und Konsistenz
Eine Buchung muss die Kapazität atomar prüfen und nach Bestätigung erhöhen. Gleichzeitige Anfragen dürfen nicht zu mehr bestätigten Teilnahmen als der definierten Kapazität führen. Entfernt eine durchführende Person eine Teilnahme, wird die Kapazität korrekt neu berechnet oder freigegeben.
Bestätigung, Erinnerungen und iCalendar-Aktualisierung sind aus bestätigten Buchungszuständen abzuleiten. Wiederholte technische Ausführung derselben Aufgabe darf nicht zu mehrfachen Erinnerungen oder doppelten Bestätigungen führen.
## Schnittstellen
Erforderlich sind mindestens Schnittstellen oder Dienste für:
- Termin- und Konfigurationsdaten,
- Anfragen, Bestätigungen und Kapazitätsprüfung,
- Versand von Bestätigungs- und Erinnerungsnachrichten,
- abonnierbare iCalendar-Feeds je Instanz,
- Export bestätigter Teilnahmen nach Zeitraum,
- Prüfung bzw. Anzeige historischer Teilnahmen bei Mehrfachanmeldungen.
## Teststrategie
Geschäftsregeln erhalten Unit-Tests. Integrations-Tests prüfen Datenzugriff, Kapazitätsgrenzen, Bestätigung, Kalenderfeed, Export und Benachrichtigungsplanung. End-to-End-Tests prüfen den sichtbaren Ablauf von Kalender, Anmeldung, Bedingungen für Probearbeiten und Rückmeldungen.
## Checkpoints
- [ ] Betriebsform, Datenbank und Verwaltungsoberfläche sind entschieden.
- [ ] Die Schnittstellen zwischen Oberfläche und Geschäftslogik sind spezifiziert.
- [ ] Die Atomarität der Kapazitätsprüfung ist technisch nachweisbar getestet.
- [ ] Versand, Erinnerung und iCalendar-Aktualisierung sind idempotent umgesetzt.
- [ ] Die Live-Vorschau ist Teil der Konfigurationsoberfläche getestet.
- [ ] Für jede fachliche Regel existiert ein automatisierter Test.
+38
View File
@@ -0,0 +1,38 @@
# Datenmodell – Terminbuchungskalender (SOLL)
## Termin
Ein Termin ist die zentrale fachliche Einheit. Er enthält mindestens:
- eindeutige Termin-ID,
- Name bzw. Terminart,
- Beginn und Ende,
- Zuordnung zu PW oder PS,
- Kapazität von 1 bis 100,
- Anmeldebedingung „Einführungsveranstaltung besucht“,
- durchführende Person mit Name und E-Mail-Adresse,
- Format `vor Ort` oder `telefonisch`.
Aus Beginn und Ende wird die visuelle Dauer im Kalender abgeleitet. Die durchführende Person dient auch als Kontakt für die Stornierung durch Klientinnen und Klienten.
## Anmeldung und bestätigte Teilnahme
Eine Anmeldung benötigt mindestens Name, E-Mail-Adresse, Telefonnummer sowie die Zuordnung zum Termin. Für Probearbeiten kommt die Bestätigung der Einführungsveranstaltung mit dem angegebenen Datum hinzu.
Das Datenmodell benötigt unterscheidbare Zustände mindestens für Anfrage, ausstehende Bestätigung, bestätigt, entfernt bzw. storniert und abgelaufen.
## Historie und Mehrfachanmeldungen
Aus den gespeicherten Anmeldungen können an Hand des Status frühere oder paralleler Anmeldungen erkannt werden um diese gegebenenfalls zu verhindern, um als Information in der Bestätigungsmail oder beim Export als Liste verwendet zu werden.
## Ableitbare Daten
Die freie Kapazität eines Termins ergibt sich aus Kapazität und Anzahl bestätigter, nicht entfernter Teilnahmen. Die iCalendar-Feeds und Exporte enthalten ausschließlich bestätigte Termine beziehungsweise Teilnahmen im ausgewählten Zeitraum.
## Checkpoints
- [ ] Terminarten, Dauerregeln und mögliche Mehrtagestermine sind vollständig modelliert.
- [ ] Zustände und Übergänge einer Anmeldung sind fachlich bestätigt.
- [ ] Die Definition einer „gleichen Person“ für Mehrfacherkennung ist abgestimmt.
- [ ] Die Historie hat einen dokumentierten Zweck, Zugriffskreis und Löschzeitpunkt.
- [ ] Der Exportinhalt und die iCalendar-Felddaten sind verbindlich festgelegt.
@@ -0,0 +1,27 @@
# Benutzerrollen – Terminbuchungskalender (SOLL)
## Besuchende
Besuchende sehen ausschließlich die ihnen zugeordnete Kalenderinstanz. Sie können Terminangebote einsehen, freie Kapazitäten erkennen, eine Anmeldung abgeben und bei mehreren aufeinanderfolgenden Anmeldungen ihre zuvor eingegebenen Daten erneut verwenden. Sie erhalten Bestätigungs- und Erinnerungsnachrichten.
Besuchende verwalten keine Termine selbst. Für eine Stornierung kontaktieren sie die in der Bestätigung genannte durchführende Person.
## Durchführende Person
Die durchführende Person ist einem Termin mit Name und E-Mail-Adresse zugeordnet. Sie wird als Kontakt für Stornierungen genannt und kann eine angemeldete Person aus einem Termin entfernen. Die genaue Abgrenzung gegenüber der allgemeinen Verwaltungsrolle ist noch zu entscheiden.
## Verwaltungszugriff
Aktuell ist keine Benutzerverwaltung vorgesehen. Personen, die den Backend-Link erhalten, können Termine anlegen, bearbeiten und löschen sowie Teilnahmen verwalten und Exporte erzeugen. Diese Rolle ist deshalb organisatorisch als besonders schutzbedürftig zu behandeln.
## Nicht als Rollen zu verstehen
PW und PS sind getrennte Kalenderinstanzen, keine Benutzerrollen. Es ist noch festzulegen, ob ein Verwaltungszugriff nur eine Instanz oder beide Instanzen bearbeiten darf.
## Checkpoints
- [ ] Die Befugnisse der durchführenden Person sind eindeutig abgegrenzt.
- [ ] Es ist entschieden, ob Verwaltungszugriffe für PW und PS getrennt werden.
- [ ] Der Umgang mit verloren gegangenen oder weitergegebenen Backend-Links ist festgelegt.
- [ ] Die Berechtigung zum Export personenbezogener Daten ist geregelt.
- [ ] Das Rollen- und Zugriffskonzept ist vor Produktivsetzung freigegeben.
@@ -0,0 +1,35 @@
# Datenschutz und Sicherheit – Terminbuchungskalender (SOLL)
## Personenbezogene Daten und Zweckbindung
Das Endprodukt verarbeitet mindestens Name, E-Mail-Adresse, Telefonnummer, Terminbezug, Zustimmungsstatus und bei bestätigten Teilnahmen historische Zuordnungen zu Veranstaltungs-IDs. Bei Probearbeiten kommt die Angabe zur Einführungsveranstaltung hinzu. Diese Daten dürfen ausschließlich für Terminplanung, Bestätigung, Erinnerung, Durchführung, Stornierungsbearbeitung und die vereinbarte Auswertung verwendet werden.
## Transparenz und Einwilligung
Vor dem Absenden werden Datenschutzhinweise nach Art. 13 DSGVO bereitgestellt und die bestätigte Kenntnisnahme dokumentiert. Rechtsgrundlage, genaue Formulierung, Nachweisform und Speicherfrist müssen vor Produktivsetzung abgestimmt werden.
## Lokale Wiederverwendung von Anmeldedaten
Für mehrere aufeinanderfolgende Buchungen dürfen Anmeldedaten im Browser vorgehalten werden. Es ist zu entscheiden, ob dies nur während der Sitzung oder darüber hinaus erfolgt, wie Besuchende informiert werden und wie sie die lokalen Daten entfernen können. Eine dauerhafte Speicherung im Browser darf nicht ohne dokumentierte Rechts- und Sicherheitsabwägung erfolgen.
## Schutz der Buchungsprozesse
Alle Eingaben werden serverseitig validiert. Buchungs- und Bestätigungsvorgänge benötigen Schutz gegen CSRF, automatisierte Anfragen und Kapazitätsüberbuchung. Bestätigungstoken, E-Mail-Adressen, Kalenderfeed-Adressen und Exporte sind vertraulich zu behandeln. Fehlermeldungen dürfen keine personenbezogenen Daten oder interne Zustände preisgeben.
## Verwaltungszugriff und Export
Der geplante Zugriff allein über einen Backend-Link ist ein Risiko, weil damit Termine und personenbezogene Teilnahmedaten verändert oder exportiert werden können. Vor Produktivsetzung ist mindestens festzulegen, wie der Link geschützt, weitergegeben, widerrufen und protokolliert wird. Die fehlende Benutzerverwaltung ist eine bewusste fachliche Vorgabe, kein Ersatz für Zugriffsschutz.
## Nachrichten und iCalendar
E-Mail-Bestätigungen und Erinnerungen enthalten nur die für den Termin erforderlichen Daten. Die zusätzliche oder alternative Nutzung von Signal Messenger erfordert eine eigene Prüfung von Einwilligung, Empfängerzuordnung, Auftragsverarbeitung, Datenübermittlung und betrieblichen Zuständigkeiten. iCalendar-Feeds dürfen keine nicht erforderlichen personenbezogenen Informationen offenlegen und benötigen nicht erratbare, widerrufbare Adressen.
## Checkpoints
- [ ] Art.-13-Information, Rechtsgrundlage und Einwilligungsnachweis sind abgestimmt.
- [ ] Datenminimierung und Pflicht-/freiwillige Felder sind bestätigt.
- [ ] Löschfristen für Anmeldungen, Historie, Exporte und Protokolle sind definiert.
- [ ] Der Schutz des Backend-Links ist organisatorisch und technisch geregelt.
- [ ] Der Signal-Messenger-Einsatz ist entschieden und gegebenenfalls datenschutzrechtlich geprüft.
- [ ] Schutzmaßnahmen gegen Missbrauch und Überbuchung sind getestet.
- [ ] Vor Produktivsetzung ist eine gesonderte Sicherheitsanalyse durchgeführt.