Compare commits
11
Commits
037ea11de5
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4eac5bafd5 | ||
|
|
b58d96cd5e | ||
|
|
6c16e16354 | ||
|
|
de5895f558 | ||
|
|
6ef38aa7d2 | ||
|
|
a6e6e42a43 | ||
|
|
72c032a5ca | ||
|
|
e122aa8650 | ||
|
|
a78e84515a | ||
|
|
39b891cd63 | ||
|
|
7a735915d1 |
No files matched your search
+18
@@ -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
|
||||
@@ -5,6 +5,7 @@
|
||||
/public/share
|
||||
/public/system
|
||||
/public/vendor
|
||||
/public/booking-config.php
|
||||
/system
|
||||
/bin/console
|
||||
/node_modules/
|
||||
|
||||
@@ -58,7 +58,137 @@ 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 in `public/booking/booking.js` als browsernatives ES-Modul. Für die statische Perspektivwerk-Vorschau liegen identische Kopien unter `public/perspektivwerk/js/`.
|
||||
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
|
||||
|
||||
@@ -71,7 +201,10 @@ Die Terminbuchungskomponente hat eine Vorschau auf einem Webseiten-Nachbau der O
|
||||
|
||||
## Statische Buchungsvorschau in Contao einbinden
|
||||
|
||||
Die erste Komponente ist absichtlich datenbankfrei. Sie zeigt Test-Slots, rendert einen Monatskalender und unterscheidet freie von belegten Terminen. Ein Klick auf einen freien Slot öffnet eine modale Eingabemaske für Name, E-Mail, Telefon, Alter, Nachricht, Ansprechperson bei AfA/Jobcenter sowie die DSGVO-Einwilligung. Die Eingaben werden derzeit ausschließlich im Browser validiert und weder gespeichert noch versendet.
|
||||
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:
|
||||
|
||||
@@ -93,19 +226,43 @@ Contao maskiert nicht freigegebene HTML-Tags auch im Inhaltselement **HTML**. Vo
|
||||
|
||||
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 Beispieltermine stehen in `public/booking/booking.js`; `public/perspektivwerk/js/booking.js` ist die für den statischen Upload bestimmte Kopie. Ein Test stellt sicher, dass beide Dateien gleich bleiben. Der spätere serverseitige Endpunkt muss sie ersetzen; Datenbankzugänge oder SQL gehören niemals in das HTML oder das Browser-JavaScript.
|
||||
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.ps1](scripts/Deploy-Perspektivwerk.ps1) überträgt ausschließlich den Inhalt von `public/perspektivwerk/` per SCP und korrigiert anschließend die Lesbarkeit auf dem Server per SSH. Dieser Ordner enthält auch `js/booking.js` und `js/booking.css`. Es lädt weder Contao noch die Datenbank oder andere Projektdateien hoch.
|
||||
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.
|
||||
|
||||
```powershell
|
||||
.\scripts\Deploy-Perspektivwerk.ps1
|
||||
```bat
|
||||
scripts\Deploy-Perspektivwerk.bat
|
||||
```
|
||||
|
||||
Der Hostname `boogie` wird aus der lokalen SSH-Konfiguration verwendet. Der vollständige Inhalt wird per SCP-Aufruf nach `/home/boogie/html/perspektivwerk/` übertragen. 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.
|
||||
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 gibt das Skript den vollständigen Zielpfad aus. Damit lässt sich direkt prüfen, ob `booking.js` unter `/home/boogie/html/perspektivwerk/js/booking.js` landet. 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.
|
||||
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
|
||||
|
||||
|
||||
@@ -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');
|
||||
@@ -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
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
Für Produktionsschule data-project="produktionsschule" setzen.
|
||||
-->
|
||||
<link rel="stylesheet" href="/booking/booking.css">
|
||||
<div data-sufw-booking data-project="perspektivwerk"></div>
|
||||
<div data-sufw-booking data-project="perspektivwerk" data-api-url="/booking/api.php"></div>
|
||||
<script type="module" src="/booking/booking.js"></script>
|
||||
@@ -3,7 +3,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test tests/booking.test.mjs"
|
||||
"test": "node --test tests/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "^5.3.3"
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
@@ -1,313 +0,0 @@
|
||||
/* Browsernatives ES-Modul; wird ohne TypeScript-Compiler ausgeliefert. */
|
||||
const demoSlots = [
|
||||
{ id: 'pw-2026-08-24-0900', project: 'perspektivwerk', date: '2026-08-24', start: '09:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-08-24-1100', project: 'perspektivwerk', date: '2026-08-24', start: '11:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-08-24-1400', project: 'perspektivwerk', date: '2026-08-24', start: '14:00', title: 'Soz. Päd. Modul', status: 'booked' },
|
||||
{ id: 'pw-2026-08-18-1000', project: 'perspektivwerk', date: '2026-08-18', start: '10:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-08-18-1300', project: 'perspektivwerk', date: '2026-08-18', start: '13:00', title: 'Soz. Päd. Modul', status: 'booked' },
|
||||
{ id: 'pw-2026-08-18-1500', project: 'perspektivwerk', date: '2026-08-18', start: '15:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-08-26-0900', project: 'perspektivwerk', date: '2026-08-26', start: '09:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-08-26-1100', project: 'perspektivwerk', date: '2026-08-26', start: '11:00', title: 'Psy Modul', status: 'booked' },
|
||||
{ id: 'pw-2026-08-26-1400', project: 'perspektivwerk', date: '2026-08-26', start: '14:00', title: 'Soz. Päd. Modul', status: 'available' },
|
||||
{ id: 'pw-2026-08-20-0900', project: 'perspektivwerk', date: '2026-08-20', start: '09:00', title: 'Psy Modul', status: 'available' },
|
||||
{ id: 'pw-2026-08-20-1100', project: 'perspektivwerk', date: '2026-08-20', start: '11:00', title: 'Erstgespräch', status: 'booked' },
|
||||
{ id: 'pw-2026-08-20-1500', project: 'perspektivwerk', date: '2026-08-20', start: '15:00', title: 'Psy Modul', status: 'available' },
|
||||
{ id: 'pw-2026-08-28-0900', project: 'perspektivwerk', date: '2026-08-28', start: '09:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-08-28-1100', project: 'perspektivwerk', date: '2026-08-28', start: '11:00', title: 'Soz. Päd. Modul', status: 'available' },
|
||||
{ id: 'pw-2026-08-28-1400', project: 'perspektivwerk', date: '2026-08-28', start: '14:00', title: 'Erstgespräch', status: 'booked' },
|
||||
{ id: 'pw-2026-08-31-0800', project: 'perspektivwerk', date: '2026-08-31', start: '08:00', title: 'Praxis Modul', status: 'available' },
|
||||
{ id: 'pw-2026-09-02-0900', project: 'perspektivwerk', date: '2026-09-02', start: '09:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-09-02-1100', project: 'perspektivwerk', date: '2026-09-02', start: '11:00', title: 'Soz. Päd. Modul', status: 'booked' },
|
||||
{ id: 'pw-2026-09-02-1400', project: 'perspektivwerk', date: '2026-09-02', start: '14:00', title: 'Psy Modul', status: 'available' },
|
||||
{ id: 'pw-2026-09-07-0900', project: 'perspektivwerk', date: '2026-09-07', start: '09:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-09-07-1300', project: 'perspektivwerk', date: '2026-09-07', start: '13:00', title: 'Soz. Päd. Modul', status: 'available' },
|
||||
{ id: 'pw-2026-09-10-0900', project: 'perspektivwerk', date: '2026-09-10', start: '09:00', title: 'Psy Modul', status: 'available' },
|
||||
{ id: 'pw-2026-09-10-1100', project: 'perspektivwerk', date: '2026-09-10', start: '11:00', title: 'Erstgespräch', status: 'booked' },
|
||||
{ id: 'pw-2026-09-10-1500', project: 'perspektivwerk', date: '2026-09-10', start: '15:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-09-15-0900', project: 'perspektivwerk', date: '2026-09-15', start: '09:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-09-15-1100', project: 'perspektivwerk', date: '2026-09-15', start: '11:00', title: 'Soz. Päd. Modul', status: 'available' },
|
||||
{ id: 'pw-2026-09-15-1400', project: 'perspektivwerk', date: '2026-09-15', start: '14:00', title: 'Psy Modul', status: 'booked' },
|
||||
{ id: 'pw-2026-09-18-0900', project: 'perspektivwerk', date: '2026-09-18', start: '09:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-09-18-1300', project: 'perspektivwerk', date: '2026-09-18', start: '13:00', title: 'Soz. Päd. Modul', status: 'booked' },
|
||||
{ id: 'pw-2026-09-22-1000', project: 'perspektivwerk', date: '2026-09-22', start: '10:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-09-22-1400', project: 'perspektivwerk', date: '2026-09-22', start: '14:00', title: 'Psy Modul', status: 'available' },
|
||||
{ id: 'pw-2026-09-25-0900', project: 'perspektivwerk', date: '2026-09-25', start: '09:00', title: 'Soz. Päd. Modul', status: 'available' },
|
||||
{ id: 'pw-2026-09-25-1100', project: 'perspektivwerk', date: '2026-09-25', start: '11:00', title: 'Erstgespräch', status: 'booked' },
|
||||
{ id: 'pw-2026-09-29-0900', project: 'perspektivwerk', date: '2026-09-29', start: '09:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-09-29-1100', project: 'perspektivwerk', date: '2026-09-29', start: '11:00', title: 'Psy Modul', status: 'available' },
|
||||
{ id: 'pw-2026-09-29-1400', project: 'perspektivwerk', date: '2026-09-29', start: '14:00', title: 'Soz. Päd. Modul', status: 'booked' },
|
||||
{ id: 'ps-2026-08-25-1000', project: 'produktionsschule', date: '2026-08-25', start: '10:00', title: 'Kennenlerntermin', status: 'available' },
|
||||
{ id: 'ps-2026-08-25-1300', project: 'produktionsschule', date: '2026-08-25', start: '13:00', title: 'Kennenlerntermin', status: 'booked' },
|
||||
{ id: 'ps-2026-08-31-0800', project: 'produktionsschule', date: '2026-08-31', start: '08:00', title: 'Probearbeiten', status: 'available' }
|
||||
];
|
||||
export function getSlotsForDate(project, date) {
|
||||
return demoSlots.filter((slot) => slot.project === project && slot.date === date);
|
||||
}
|
||||
export function validateBookingField(fieldName, value) {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
if (fieldName === 'name')
|
||||
return text ? '' : 'Bitte geben Sie Ihren Namen ein.';
|
||||
if (fieldName === 'email')
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text) ? '' : 'Bitte geben Sie eine gültige E-Mail-Adresse ein.';
|
||||
if (fieldName === 'phone')
|
||||
return !text || /^[0-9+()\-/\s]{6,}$/.test(text) ? '' : 'Bitte geben Sie eine gültige Telefonnummer ein.';
|
||||
if (fieldName === 'age')
|
||||
return !text || (/^\d+$/.test(text) && Number(text) >= 12 && Number(text) <= 112) ? '' : 'Bitte geben Sie ein Alter zwischen 12 und 112 Jahren ein.';
|
||||
if (fieldName === 'message')
|
||||
return text.length <= 2000 ? '' : 'Die Nachricht darf höchstens 2.000 Zeichen enthalten.';
|
||||
if (fieldName === 'caseWorker')
|
||||
return text.length <= 160 ? '' : 'Die Angabe darf höchstens 160 Zeichen enthalten.';
|
||||
if (fieldName === 'privacy')
|
||||
return value ? '' : 'Bitte bestätigen Sie die Datenschutzhinweise.';
|
||||
return '';
|
||||
}
|
||||
export function validateBookingForm({ name = '', email = '', phone = '', age = '', message = '', caseWorker = '', privacyAccepted = false }) {
|
||||
const values = { name, email, phone, age, message, caseWorker, privacy: privacyAccepted };
|
||||
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 Terminanfrage für ${appointmentTitle} wurde erfolgreich übermittelt.`;
|
||||
}
|
||||
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 weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
function parseDate(value) {
|
||||
return new Date(`${value}T12:00:00`);
|
||||
}
|
||||
export function formatDateKey(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
function createMonthDates(month) {
|
||||
const first = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
const padding = (first.getDay() + 6) % 7;
|
||||
const days = new Array(padding).fill(null);
|
||||
const lastDay = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
|
||||
for (let day = 1; day <= lastDay; day += 1) {
|
||||
days.push(new Date(month.getFullYear(), month.getMonth(), day));
|
||||
}
|
||||
return days;
|
||||
}
|
||||
function monthForProject(project) {
|
||||
const firstSlot = demoSlots.find((slot) => slot.project === project);
|
||||
return firstSlot ? new Date(parseDate(firstSlot.date).getFullYear(), parseDate(firstSlot.date).getMonth(), 1) : new Date();
|
||||
}
|
||||
function escapeHtml(value) {
|
||||
return value.replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[character]);
|
||||
}
|
||||
function showFieldError(form, fieldName, error) {
|
||||
const field = form.elements.namedItem(fieldName);
|
||||
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');
|
||||
message.style.display = 'block';
|
||||
message.style.marginTop = '.35rem';
|
||||
message.style.color = '#6c0808';
|
||||
message.style.fontWeight = '400';
|
||||
const host = fieldName === 'privacy' ? 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, slot) {
|
||||
const projectTitle = slot.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule';
|
||||
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">Termin angefragt</p>
|
||||
<h2 id="sufw-booking-confirmation-title">Vielen Dank für Ihre Anfrage</h2>
|
||||
<p>${escapeHtml(bookingConfirmationMessage(slot.title))}</p>
|
||||
</header>
|
||||
<p class="sufw-booking__appointment"><strong>${escapeHtml(projectTitle)}</strong><br>${escapeHtml(dateFormatter.format(parseDate(slot.date)))} · ${escapeHtml(slot.start)} Uhr · ${escapeHtml(slot.title)}</p>
|
||||
<p>Wir senden Ihnen eine E-Mail zur Bestätigung Ihres Termins.</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, slot) {
|
||||
const projectTitle = slot.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule';
|
||||
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 anfragen</p>
|
||||
<h2 id="sufw-booking-form-title">Ihre Angaben</h2>
|
||||
<p class="sufw-booking__appointment"><strong>${escapeHtml(projectTitle)}</strong><br>${escapeHtml(dateFormatter.format(parseDate(slot.date)))} · ${escapeHtml(slot.start)} Uhr · ${escapeHtml(slot.title)}</p>
|
||||
</header>
|
||||
<p class="sufw-booking__required"><span aria-hidden="true">*</span> Pflichtfeld</p>
|
||||
<div class="sufw-booking__form-grid">
|
||||
<label>Vor- und Nachname <span aria-hidden="true">*</span><input name="name" type="text" autocomplete="name" required></label>
|
||||
<label>E-Mail-Adresse <span aria-hidden="true">*</span><input name="email" type="email" autocomplete="email" required></label>
|
||||
<label>Telefonnummer <span class="sufw-booking__optional">freiwillig</span><input name="phone" type="tel" autocomplete="tel"></label>
|
||||
<label>Alter <span class="sufw-booking__optional">freiwillig</span><input name="age" type="number" min="12" max="112" inputmode="numeric"></label>
|
||||
</div>
|
||||
<label>Nachricht <span class="sufw-booking__optional">freiwillig</span><textarea name="message" rows="4"></textarea></label>
|
||||
<label>Ansprechperson bei Agentur für Arbeit oder Jobcenter <span class="sufw-booking__optional">freiwillig</span><input name="caseWorker" type="text"></label>
|
||||
<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 Terminanfrage 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>Terminanfrage absenden</button></div>
|
||||
</form>`;
|
||||
const form = dialog.querySelector('form');
|
||||
const privacy = form.elements.namedItem('privacy');
|
||||
const submitButton = dialog.querySelector('[data-submit-booking]');
|
||||
const actions = dialog.querySelector('.sufw-booking__form-actions');
|
||||
dialog.style.overflowY = 'auto';
|
||||
actions.style.position = 'sticky';
|
||||
actions.style.bottom = '0';
|
||||
actions.style.zIndex = '2';
|
||||
actions.style.display = 'flex';
|
||||
actions.style.visibility = 'visible';
|
||||
actions.style.opacity = '1';
|
||||
actions.style.backgroundColor = '#fff';
|
||||
actions.style.paddingTop = '.75rem';
|
||||
const fieldNames = ['name', 'email', 'phone', 'age', 'message', 'caseWorker', 'privacy'];
|
||||
const validateField = (fieldName) => {
|
||||
const field = form.elements.namedItem(fieldName);
|
||||
const value = fieldName === 'privacy' ? field.checked : field.value;
|
||||
const error = validateBookingField(fieldName, value);
|
||||
showFieldError(form, fieldName, error);
|
||||
return error;
|
||||
};
|
||||
const updateSubmitButton = () => {
|
||||
submitButton.disabled = !canSubmitBookingForm(privacy.checked);
|
||||
submitButton.hidden = false;
|
||||
submitButton.style.display = 'inline-block';
|
||||
submitButton.style.visibility = 'visible';
|
||||
submitButton.style.opacity = '1';
|
||||
submitButton.style.position = 'relative';
|
||||
submitButton.style.zIndex = '3';
|
||||
submitButton.style.minHeight = '2.75rem';
|
||||
};
|
||||
fieldNames.forEach((fieldName) => {
|
||||
const field = form.elements.namedItem(fieldName);
|
||||
const eventName = fieldName === 'privacy' ? 'change' : 'input';
|
||||
field.addEventListener(eventName, () => validateField(fieldName));
|
||||
if (fieldName !== 'privacy')
|
||||
field.addEventListener('blur', () => validateField(fieldName));
|
||||
});
|
||||
privacy.addEventListener('change', updateSubmitButton);
|
||||
updateSubmitButton();
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
const errors = Object.fromEntries(fieldNames
|
||||
.map((fieldName) => [fieldName, validateField(fieldName)])
|
||||
.filter(([, error]) => error));
|
||||
if (Object.keys(errors).length) {
|
||||
const firstInvalid = form.elements.namedItem(Object.keys(errors)[0]);
|
||||
firstInvalid.focus();
|
||||
return;
|
||||
}
|
||||
dialog.close();
|
||||
openConfirmationDialog(container, slot);
|
||||
});
|
||||
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', '');
|
||||
dialog.querySelector('input[name="name"]').focus();
|
||||
}
|
||||
}
|
||||
function renderComponent(container) {
|
||||
const configuredProject = container.dataset.project;
|
||||
const project = configuredProject === 'produktionsschule' ? 'produktionsschule' : 'perspektivwerk';
|
||||
let currentMonth = monthForProject(project);
|
||||
let selectedDate = demoSlots.find((slot) => slot.project === project)?.date ?? '';
|
||||
const render = () => {
|
||||
const slots = selectedDate ? getSlotsForDate(project, selectedDate) : [];
|
||||
const selectedLabel = selectedDate ? dateFormatter.format(parseDate(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. Freie Termine sind mit „frei“ gekennzeichnet.</p>
|
||||
</header>
|
||||
<div class="sufw-booking__legend" aria-label="Legende"><span><i class="sufw-booking__marker sufw-booking__marker--available"></i> Termin verfügbar</span><span><i class="sufw-booking__marker sufw-booking__marker--booked"></i> Termin belegt</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(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(currentMonth)}">
|
||||
${createMonthDates(currentMonth).map((date) => {
|
||||
if (!date)
|
||||
return '<span class="sufw-booking__empty" aria-hidden="true"></span>';
|
||||
const key = formatDateKey(date);
|
||||
const daySlots = getSlotsForDate(project, key);
|
||||
const available = daySlots.some((slot) => slot.status === 'available');
|
||||
const booked = daySlots.length > 0 && !available;
|
||||
const selected = key === selectedDate;
|
||||
const state = available ? 'available' : booked ? 'booked' : 'none';
|
||||
const label = daySlots.length ? `${dateFormatter.format(date)}: ${available ? 'freie Termine verfügbar' : 'nur belegte Termine'}` : dateFormatter.format(date);
|
||||
return `<button type="button" role="gridcell" class="sufw-booking__day sufw-booking__day--${state}${selected ? ' is-selected' : ''}" data-date="${key}" aria-pressed="${selected}" aria-label="${label}"><time datetime="${key}">${date.getDate()}</time>${daySlots.length ? `<small>${available ? 'frei' : 'belegt'}</small>` : ''}</button>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
<section class="sufw-booking__slots" aria-labelledby="sufw-slot-title">
|
||||
<h3 id="sufw-slot-title">Termine am ${selectedLabel}</h3>
|
||||
${slots.length ? `<ul>${slots.map((slot) => `<li><button type="button" class="sufw-booking__slot" data-slot-id="${slot.id}" ${slot.status === 'booked' ? 'disabled aria-disabled="true"' : ''}><strong>${slot.start} Uhr</strong><span>${slot.title}</span><em>${slot.status === 'available' ? 'Verfügbar' : 'Belegt'}</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', () => {
|
||||
currentMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1);
|
||||
render();
|
||||
});
|
||||
container.querySelector('[data-action="next"]')?.addEventListener('click', () => {
|
||||
currentMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1);
|
||||
render();
|
||||
});
|
||||
container.querySelectorAll('[data-date]').forEach((button) => button.addEventListener('click', () => {
|
||||
selectedDate = button.dataset.date ?? '';
|
||||
render();
|
||||
}));
|
||||
container.querySelectorAll('[data-slot-id]').forEach((button) => button.addEventListener('click', () => {
|
||||
const slot = demoSlots.find((entry) => entry.id === button.dataset.slotId);
|
||||
if (slot)
|
||||
openBookingDialog(container, slot);
|
||||
}));
|
||||
};
|
||||
render();
|
||||
}
|
||||
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,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).'¬ice='.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 & 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 & 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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);
|
||||
}
|
||||
+27
@@ -159,6 +159,15 @@
|
||||
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;
|
||||
@@ -257,6 +266,24 @@
|
||||
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;
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[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";
|
||||
@@ -21,7 +21,10 @@
|
||||
<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 & 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 & Belastungstest</h2><p>Arbeitsproben und praktische Erprobungen machen fachliche, soziale und arbeitsbezogene Kompetenzen sichtbar.</p></article><article><h2>3. Auswertung & 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"></div>
|
||||
<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>
|
||||
|
||||
@@ -159,6 +159,15 @@
|
||||
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;
|
||||
@@ -257,6 +266,24 @@
|
||||
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;
|
||||
|
||||
@@ -1,109 +1,74 @@
|
||||
/* Browsernatives ES-Modul; wird ohne TypeScript-Compiler ausgeliefert. */
|
||||
const demoSlots = [
|
||||
{ id: 'pw-2026-08-24-0900', project: 'perspektivwerk', date: '2026-08-24', start: '09:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-08-24-1100', project: 'perspektivwerk', date: '2026-08-24', start: '11:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-08-24-1400', project: 'perspektivwerk', date: '2026-08-24', start: '14:00', title: 'Soz. Päd. Modul', status: 'booked' },
|
||||
{ id: 'pw-2026-08-18-1000', project: 'perspektivwerk', date: '2026-08-18', start: '10:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-08-18-1300', project: 'perspektivwerk', date: '2026-08-18', start: '13:00', title: 'Soz. Päd. Modul', status: 'booked' },
|
||||
{ id: 'pw-2026-08-18-1500', project: 'perspektivwerk', date: '2026-08-18', start: '15:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-08-26-0900', project: 'perspektivwerk', date: '2026-08-26', start: '09:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-08-26-1100', project: 'perspektivwerk', date: '2026-08-26', start: '11:00', title: 'Psy Modul', status: 'booked' },
|
||||
{ id: 'pw-2026-08-26-1400', project: 'perspektivwerk', date: '2026-08-26', start: '14:00', title: 'Soz. Päd. Modul', status: 'available' },
|
||||
{ id: 'pw-2026-08-20-0900', project: 'perspektivwerk', date: '2026-08-20', start: '09:00', title: 'Psy Modul', status: 'available' },
|
||||
{ id: 'pw-2026-08-20-1100', project: 'perspektivwerk', date: '2026-08-20', start: '11:00', title: 'Erstgespräch', status: 'booked' },
|
||||
{ id: 'pw-2026-08-20-1500', project: 'perspektivwerk', date: '2026-08-20', start: '15:00', title: 'Psy Modul', status: 'available' },
|
||||
{ id: 'pw-2026-08-28-0900', project: 'perspektivwerk', date: '2026-08-28', start: '09:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-08-28-1100', project: 'perspektivwerk', date: '2026-08-28', start: '11:00', title: 'Soz. Päd. Modul', status: 'available' },
|
||||
{ id: 'pw-2026-08-28-1400', project: 'perspektivwerk', date: '2026-08-28', start: '14:00', title: 'Erstgespräch', status: 'booked' },
|
||||
{ id: 'pw-2026-08-31-0800', project: 'perspektivwerk', date: '2026-08-31', start: '08:00', title: 'Praxis Modul', status: 'available' },
|
||||
{ id: 'pw-2026-09-02-0900', project: 'perspektivwerk', date: '2026-09-02', start: '09:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-09-02-1100', project: 'perspektivwerk', date: '2026-09-02', start: '11:00', title: 'Soz. Päd. Modul', status: 'booked' },
|
||||
{ id: 'pw-2026-09-02-1400', project: 'perspektivwerk', date: '2026-09-02', start: '14:00', title: 'Psy Modul', status: 'available' },
|
||||
{ id: 'pw-2026-09-07-0900', project: 'perspektivwerk', date: '2026-09-07', start: '09:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-09-07-1300', project: 'perspektivwerk', date: '2026-09-07', start: '13:00', title: 'Soz. Päd. Modul', status: 'available' },
|
||||
{ id: 'pw-2026-09-10-0900', project: 'perspektivwerk', date: '2026-09-10', start: '09:00', title: 'Psy Modul', status: 'available' },
|
||||
{ id: 'pw-2026-09-10-1100', project: 'perspektivwerk', date: '2026-09-10', start: '11:00', title: 'Erstgespräch', status: 'booked' },
|
||||
{ id: 'pw-2026-09-10-1500', project: 'perspektivwerk', date: '2026-09-10', start: '15:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-09-15-0900', project: 'perspektivwerk', date: '2026-09-15', start: '09:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-09-15-1100', project: 'perspektivwerk', date: '2026-09-15', start: '11:00', title: 'Soz. Päd. Modul', status: 'available' },
|
||||
{ id: 'pw-2026-09-15-1400', project: 'perspektivwerk', date: '2026-09-15', start: '14:00', title: 'Psy Modul', status: 'booked' },
|
||||
{ id: 'pw-2026-09-18-0900', project: 'perspektivwerk', date: '2026-09-18', start: '09:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-09-18-1300', project: 'perspektivwerk', date: '2026-09-18', start: '13:00', title: 'Soz. Päd. Modul', status: 'booked' },
|
||||
{ id: 'pw-2026-09-22-1000', project: 'perspektivwerk', date: '2026-09-22', start: '10:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-09-22-1400', project: 'perspektivwerk', date: '2026-09-22', start: '14:00', title: 'Psy Modul', status: 'available' },
|
||||
{ id: 'pw-2026-09-25-0900', project: 'perspektivwerk', date: '2026-09-25', start: '09:00', title: 'Soz. Päd. Modul', status: 'available' },
|
||||
{ id: 'pw-2026-09-25-1100', project: 'perspektivwerk', date: '2026-09-25', start: '11:00', title: 'Erstgespräch', status: 'booked' },
|
||||
{ id: 'pw-2026-09-29-0900', project: 'perspektivwerk', date: '2026-09-29', start: '09:00', title: 'Erstgespräch', status: 'available' },
|
||||
{ id: 'pw-2026-09-29-1100', project: 'perspektivwerk', date: '2026-09-29', start: '11:00', title: 'Psy Modul', status: 'available' },
|
||||
{ id: 'pw-2026-09-29-1400', project: 'perspektivwerk', date: '2026-09-29', start: '14:00', title: 'Soz. Päd. Modul', status: 'booked' },
|
||||
{ id: 'ps-2026-08-25-1000', project: 'produktionsschule', date: '2026-08-25', start: '10:00', title: 'Kennenlerntermin', status: 'available' },
|
||||
{ id: 'ps-2026-08-25-1300', project: 'produktionsschule', date: '2026-08-25', start: '13:00', title: 'Kennenlerntermin', status: 'booked' },
|
||||
{ id: 'ps-2026-08-31-0800', project: 'produktionsschule', date: '2026-08-31', start: '08:00', title: 'Probearbeiten', status: 'available' }
|
||||
];
|
||||
export function getSlotsForDate(project, date) {
|
||||
return demoSlots.filter((slot) => slot.project === project && slot.date === date);
|
||||
}
|
||||
export function validateBookingField(fieldName, value) {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
if (fieldName === 'name')
|
||||
return text ? '' : 'Bitte geben Sie Ihren Namen ein.';
|
||||
if (fieldName === 'email')
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text) ? '' : 'Bitte geben Sie eine gültige E-Mail-Adresse ein.';
|
||||
if (fieldName === 'phone')
|
||||
return !text || /^[0-9+()\-/\s]{6,}$/.test(text) ? '' : 'Bitte geben Sie eine gültige Telefonnummer ein.';
|
||||
if (fieldName === 'age')
|
||||
return !text || (/^\d+$/.test(text) && Number(text) >= 12 && Number(text) <= 112) ? '' : 'Bitte geben Sie ein Alter zwischen 12 und 112 Jahren ein.';
|
||||
if (fieldName === 'message')
|
||||
return text.length <= 2000 ? '' : 'Die Nachricht darf höchstens 2.000 Zeichen enthalten.';
|
||||
if (fieldName === 'caseWorker')
|
||||
return text.length <= 160 ? '' : 'Die Angabe darf höchstens 160 Zeichen enthalten.';
|
||||
if (fieldName === 'privacy')
|
||||
return value ? '' : 'Bitte bestätigen Sie die Datenschutzhinweise.';
|
||||
return '';
|
||||
}
|
||||
export function validateBookingForm({ name = '', email = '', phone = '', age = '', message = '', caseWorker = '', privacyAccepted = false }) {
|
||||
const values = { name, email, phone, age, message, caseWorker, privacy: privacyAccepted };
|
||||
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 Terminanfrage für ${appointmentTitle} wurde erfolgreich übermittelt.`;
|
||||
}
|
||||
/* 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'];
|
||||
function parseDate(value) {
|
||||
return new Date(`${value}T12:00:00`);
|
||||
}
|
||||
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) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
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) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[character]); }
|
||||
|
||||
function createMonthDates(month) {
|
||||
const first = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
const padding = (first.getDay() + 6) % 7;
|
||||
const days = new Array(padding).fill(null);
|
||||
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) {
|
||||
days.push(new Date(month.getFullYear(), month.getMonth(), day));
|
||||
for (let day = 1; day <= lastDay; day += 1) dates.push(new Date(month.getFullYear(), month.getMonth(), day));
|
||||
return dates;
|
||||
}
|
||||
return days;
|
||||
}
|
||||
function monthForProject(project) {
|
||||
const firstSlot = demoSlots.find((slot) => slot.project === project);
|
||||
return firstSlot ? new Date(parseDate(firstSlot.date).getFullYear(), parseDate(firstSlot.date).getMonth(), 1) : new Date();
|
||||
}
|
||||
function escapeHtml(value) {
|
||||
return value.replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[character]);
|
||||
|
||||
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) {
|
||||
@@ -111,11 +76,7 @@ function showFieldError(form, fieldName, error) {
|
||||
message.id = errorId;
|
||||
message.className = 'sufw-booking__field-error';
|
||||
message.setAttribute('role', 'alert');
|
||||
message.style.display = 'block';
|
||||
message.style.marginTop = '.35rem';
|
||||
message.style.color = '#6c0808';
|
||||
message.style.fontWeight = '400';
|
||||
const host = fieldName === 'privacy' ? field.closest('label') : field;
|
||||
const host = fieldName === 'privacy' || fieldName === 'introductionAttended' ? field.closest('label') : field;
|
||||
host.insertAdjacentElement('afterend', message);
|
||||
field.setAttribute('aria-describedby', errorId);
|
||||
}
|
||||
@@ -123,191 +84,109 @@ function showFieldError(form, fieldName, error) {
|
||||
message.hidden = !error;
|
||||
field.setAttribute('aria-invalid', error ? 'true' : 'false');
|
||||
}
|
||||
function openConfirmationDialog(container, slot) {
|
||||
const projectTitle = slot.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule';
|
||||
|
||||
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">Termin angefragt</p>
|
||||
<h2 id="sufw-booking-confirmation-title">Vielen Dank für Ihre Anfrage</h2>
|
||||
<p>${escapeHtml(bookingConfirmationMessage(slot.title))}</p>
|
||||
</header>
|
||||
<p class="sufw-booking__appointment"><strong>${escapeHtml(projectTitle)}</strong><br>${escapeHtml(dateFormatter.format(parseDate(slot.date)))} · ${escapeHtml(slot.start)} Uhr · ${escapeHtml(slot.title)}</p>
|
||||
<p>Wir senden Ihnen eine E-Mail zur Bestätigung Ihres Termins.</p>
|
||||
<div class="sufw-booking__form-actions"><button type="button" data-close-confirmation>Schließen</button></div>
|
||||
</section>`;
|
||||
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();
|
||||
if (typeof dialog.showModal === 'function') dialog.showModal(); else { dialog.setAttribute('open', ''); dialog.querySelector('[data-close-confirmation]').focus(); }
|
||||
}
|
||||
else {
|
||||
dialog.setAttribute('open', '');
|
||||
dialog.querySelector('[data-close-confirmation]').focus();
|
||||
}
|
||||
}
|
||||
function openBookingDialog(container, slot) {
|
||||
const projectTitle = slot.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule';
|
||||
|
||||
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 anfragen</p>
|
||||
<h2 id="sufw-booking-form-title">Ihre Angaben</h2>
|
||||
<p class="sufw-booking__appointment"><strong>${escapeHtml(projectTitle)}</strong><br>${escapeHtml(dateFormatter.format(parseDate(slot.date)))} · ${escapeHtml(slot.start)} Uhr · ${escapeHtml(slot.title)}</p>
|
||||
</header>
|
||||
<p class="sufw-booking__required"><span aria-hidden="true">*</span> Pflichtfeld</p>
|
||||
<div class="sufw-booking__form-grid">
|
||||
<label>Vor- und Nachname <span aria-hidden="true">*</span><input name="name" type="text" autocomplete="name" required></label>
|
||||
<label>E-Mail-Adresse <span aria-hidden="true">*</span><input name="email" type="email" autocomplete="email" required></label>
|
||||
<label>Telefonnummer <span class="sufw-booking__optional">freiwillig</span><input name="phone" type="tel" autocomplete="tel"></label>
|
||||
<label>Alter <span class="sufw-booking__optional">freiwillig</span><input name="age" type="number" min="12" max="112" inputmode="numeric"></label>
|
||||
</div>
|
||||
<label>Nachricht <span class="sufw-booking__optional">freiwillig</span><textarea name="message" rows="4"></textarea></label>
|
||||
<label>Ansprechperson bei Agentur für Arbeit oder Jobcenter <span class="sufw-booking__optional">freiwillig</span><input name="caseWorker" type="text"></label>
|
||||
<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 Terminanfrage 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>Terminanfrage absenden</button></div>
|
||||
</form>`;
|
||||
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 privacy = form.elements.namedItem('privacy');
|
||||
const submitButton = dialog.querySelector('[data-submit-booking]');
|
||||
const actions = dialog.querySelector('.sufw-booking__form-actions');
|
||||
dialog.style.overflowY = 'auto';
|
||||
actions.style.position = 'sticky';
|
||||
actions.style.bottom = '0';
|
||||
actions.style.zIndex = '2';
|
||||
actions.style.display = 'flex';
|
||||
actions.style.visibility = 'visible';
|
||||
actions.style.opacity = '1';
|
||||
actions.style.backgroundColor = '#fff';
|
||||
actions.style.paddingTop = '.75rem';
|
||||
const fieldNames = ['name', 'email', 'phone', 'age', 'message', 'caseWorker', 'privacy'];
|
||||
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' ? field.checked : field.value;
|
||||
const value = fieldName === 'privacy' || fieldName === 'introductionAttended' ? field.checked : field.value;
|
||||
const error = validateBookingField(fieldName, value);
|
||||
showFieldError(form, fieldName, error);
|
||||
return error;
|
||||
};
|
||||
const updateSubmitButton = () => {
|
||||
submitButton.disabled = !canSubmitBookingForm(privacy.checked);
|
||||
submitButton.hidden = false;
|
||||
submitButton.style.display = 'inline-block';
|
||||
submitButton.style.visibility = 'visible';
|
||||
submitButton.style.opacity = '1';
|
||||
submitButton.style.position = 'relative';
|
||||
submitButton.style.zIndex = '3';
|
||||
submitButton.style.minHeight = '2.75rem';
|
||||
};
|
||||
fieldNames.forEach((fieldName) => {
|
||||
const field = form.elements.namedItem(fieldName);
|
||||
const eventName = fieldName === 'privacy' ? 'change' : 'input';
|
||||
field.addEventListener(eventName, () => validateField(fieldName));
|
||||
if (fieldName !== 'privacy')
|
||||
field.addEventListener('blur', () => validateField(fieldName));
|
||||
field.addEventListener(field.type === 'checkbox' ? 'change' : 'input', () => validateField(fieldName));
|
||||
if (field.type !== 'checkbox') field.addEventListener('blur', () => validateField(fieldName));
|
||||
});
|
||||
privacy.addEventListener('change', updateSubmitButton);
|
||||
updateSubmitButton();
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
const errors = Object.fromEntries(fieldNames
|
||||
.map((fieldName) => [fieldName, validateField(fieldName)])
|
||||
.filter(([, error]) => error));
|
||||
if (Object.keys(errors).length) {
|
||||
const firstInvalid = form.elements.namedItem(Object.keys(errors)[0]);
|
||||
firstInvalid.focus();
|
||||
return;
|
||||
}
|
||||
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();
|
||||
openConfirmationDialog(container, slot);
|
||||
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', '');
|
||||
dialog.querySelector('input[name="name"]').focus();
|
||||
}
|
||||
if (typeof dialog.showModal === 'function') dialog.showModal(); else { dialog.setAttribute('open', ''); form.elements.namedItem('name').focus(); }
|
||||
}
|
||||
|
||||
function renderComponent(container) {
|
||||
const configuredProject = container.dataset.project;
|
||||
const project = configuredProject === 'produktionsschule' ? 'produktionsschule' : 'perspektivwerk';
|
||||
let currentMonth = monthForProject(project);
|
||||
let selectedDate = demoSlots.find((slot) => slot.project === project)?.date ?? '';
|
||||
const render = () => {
|
||||
const slots = selectedDate ? getSlotsForDate(project, selectedDate) : [];
|
||||
const selectedLabel = selectedDate ? dateFormatter.format(parseDate(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. Freie Termine sind mit „frei“ gekennzeichnet.</p>
|
||||
</header>
|
||||
<div class="sufw-booking__legend" aria-label="Legende"><span><i class="sufw-booking__marker sufw-booking__marker--available"></i> Termin verfügbar</span><span><i class="sufw-booking__marker sufw-booking__marker--booked"></i> Termin belegt</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(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(currentMonth)}">
|
||||
${createMonthDates(currentMonth).map((date) => {
|
||||
if (!date)
|
||||
return '<span class="sufw-booking__empty" aria-hidden="true"></span>';
|
||||
const key = formatDateKey(date);
|
||||
const daySlots = getSlotsForDate(project, key);
|
||||
const available = daySlots.some((slot) => slot.status === 'available');
|
||||
const booked = daySlots.length > 0 && !available;
|
||||
const selected = key === selectedDate;
|
||||
const state = available ? 'available' : booked ? 'booked' : 'none';
|
||||
const label = daySlots.length ? `${dateFormatter.format(date)}: ${available ? 'freie Termine verfügbar' : 'nur belegte Termine'}` : dateFormatter.format(date);
|
||||
return `<button type="button" role="gridcell" class="sufw-booking__day sufw-booking__day--${state}${selected ? ' is-selected' : ''}" data-date="${key}" aria-pressed="${selected}" aria-label="${label}"><time datetime="${key}">${date.getDate()}</time>${daySlots.length ? `<small>${available ? 'frei' : 'belegt'}</small>` : ''}</button>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
<section class="sufw-booking__slots" aria-labelledby="sufw-slot-title">
|
||||
<h3 id="sufw-slot-title">Termine am ${selectedLabel}</h3>
|
||||
${slots.length ? `<ul>${slots.map((slot) => `<li><button type="button" class="sufw-booking__slot" data-slot-id="${slot.id}" ${slot.status === 'booked' ? 'disabled aria-disabled="true"' : ''}><strong>${slot.start} Uhr</strong><span>${slot.title}</span><em>${slot.status === 'available' ? 'Verfügbar' : 'Belegt'}</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', () => {
|
||||
currentMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1);
|
||||
render();
|
||||
});
|
||||
container.querySelector('[data-action="next"]')?.addEventListener('click', () => {
|
||||
currentMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1);
|
||||
render();
|
||||
});
|
||||
container.querySelectorAll('[data-date]').forEach((button) => button.addEventListener('click', () => {
|
||||
selectedDate = button.dataset.date ?? '';
|
||||
render();
|
||||
}));
|
||||
container.querySelectorAll('[data-slot-id]').forEach((button) => button.addEventListener('click', () => {
|
||||
const slot = demoSlots.find((entry) => entry.id === button.dataset.slotId);
|
||||
if (slot)
|
||||
openBookingDialog(container, slot);
|
||||
}));
|
||||
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);
|
||||
};
|
||||
render();
|
||||
}
|
||||
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();
|
||||
}
|
||||
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.
|
||||
@@ -1,45 +0,0 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$perspektivwerkSource = Join-Path $projectRoot 'public/perspektivwerk'
|
||||
$remoteTarget = 'boogie:/home/boogie/html/perspektivwerk/'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $perspektivwerkSource -PathType Container)) {
|
||||
throw "Der zu übertragende Ordner '$perspektivwerkSource' wurde nicht gefunden."
|
||||
}
|
||||
|
||||
$sourceItems = @(Get-ChildItem -LiteralPath $perspektivwerkSource -Force)
|
||||
if ($sourceItems.Count -eq 0) {
|
||||
throw "Der zu übertragende Ordner '$perspektivwerkSource' enthält keine Dateien."
|
||||
}
|
||||
|
||||
if (-not (Get-Command scp -ErrorAction SilentlyContinue)) {
|
||||
throw 'OpenSSH scp wurde nicht gefunden. Installiere den OpenSSH-Client oder füge ihn zum PATH hinzu.'
|
||||
}
|
||||
|
||||
if (-not (Get-Command ssh -ErrorAction SilentlyContinue)) {
|
||||
throw 'OpenSSH ssh wurde nicht gefunden. Installiere den OpenSSH-Client oder füge ihn zum PATH hinzu.'
|
||||
}
|
||||
|
||||
Write-Host 'Zielverzeichnisse auf dem Server:'
|
||||
Write-Host " $perspektivwerkSource\* -> $remoteTarget"
|
||||
|
||||
Write-Host "Übertrage ausschließlich die statische Perspektivwerk-Seite nach $remoteTarget"
|
||||
Write-Host 'SCP-Upload und anschließende Rechtekorrektur benötigen jeweils eine SSH-Verbindung.'
|
||||
|
||||
& scp -r @sourceItems $remoteTarget
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Der Upload nach '$remoteTarget' ist fehlgeschlagen (scp Exit-Code $LASTEXITCODE)."
|
||||
}
|
||||
|
||||
Write-Host 'Setze Lese- und Durchsuchrechte auf dem hochgeladenen Ordner.'
|
||||
& ssh boogie 'chmod -R a+rX /home/boogie/html/perspektivwerk'
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Der Upload war erfolgreich, aber die Rechtekorrektur ist fehlgeschlagen (ssh Exit-Code $LASTEXITCODE)."
|
||||
}
|
||||
|
||||
Write-Host 'Upload und Rechtekorrektur abgeschlossen: /home/boogie/html/perspektivwerk/ (inklusive js/booking.js).'
|
||||
@@ -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;
|
||||
@@ -1,32 +1,16 @@
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$deployScriptPath = Join-Path $projectRoot 'scripts\Deploy-Perspektivwerk.ps1'
|
||||
$deployBatchPath = Join-Path $projectRoot 'scripts\Deploy-Perspektivwerk.bat'
|
||||
$htaccessPath = Join-Path $projectRoot 'public\.htaccess'
|
||||
|
||||
Describe 'Perspektivwerk deployment' {
|
||||
It 'provides a deployment script for the static page' {
|
||||
(Test-Path -LiteralPath $deployScriptPath) | Should Be $true
|
||||
}
|
||||
It 'deploys the complete Perspektivwerk document root and its sibling configuration file' {
|
||||
$script = Get-Content -LiteralPath $deployBatchPath -Raw
|
||||
$htaccess = Get-Content -LiteralPath $htaccessPath -Raw
|
||||
|
||||
It 'uploads the static page and its nested booking assets with one scp invocation' {
|
||||
$script = Get-Content -LiteralPath $deployScriptPath -Raw
|
||||
|
||||
$script | Should Match 'public[\\/]perspektivwerk'
|
||||
$script | Should Not Match 'public[\\/]booking'
|
||||
$script | Should Match 'boogie:/home/boogie/html/perspektivwerk/'
|
||||
([regex]::Matches($script, '& scp')).Count | Should Be 1
|
||||
$script | Should Not Match '& rsync'
|
||||
}
|
||||
|
||||
It 'repairs read and traversal permissions with a separate SSH invocation' {
|
||||
$script = Get-Content -LiteralPath $deployScriptPath -Raw
|
||||
|
||||
([regex]::Matches($script, '& ssh')).Count | Should Be 1
|
||||
$script | Should Match 'chmod -R a\+rX /home/boogie/html/perspektivwerk'
|
||||
}
|
||||
|
||||
It 'prints the complete static-page target directory' {
|
||||
$script = Get-Content -LiteralPath $deployScriptPath -Raw
|
||||
|
||||
$script | Should Match 'Zielverzeichnisse auf dem Server'
|
||||
$script | Should Match 'boogie:/home/boogie/html/perspektivwerk/'
|
||||
$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'
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ $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'
|
||||
$contaoBookingScriptPath = Join-Path $projectRoot 'public\booking\booking.js'
|
||||
$contaoBookingStylesPath = Join-Path $projectRoot 'public\booking\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' {
|
||||
@@ -17,26 +17,30 @@ Describe 'Perspektivwerk static route' {
|
||||
(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'
|
||||
$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 Contao and static-preview asset copies identical' {
|
||||
(Test-Path -LiteralPath $contaoBookingScriptPath) | Should Be $true
|
||||
(Test-Path -LiteralPath $contaoBookingStylesPath) | Should Be $true
|
||||
(Get-FileHash -LiteralPath $bookingScriptPath).Hash | Should Be (Get-FileHash -LiteralPath $contaoBookingScriptPath).Hash
|
||||
(Get-FileHash -LiteralPath $bookingStylesPath).Hash | Should Be (Get-FileHash -LiteralPath $contaoBookingStylesPath).Hash
|
||||
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 $contaoBookingScriptPath -Raw
|
||||
$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 $contaoBookingStylesPath -Raw
|
||||
$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'
|
||||
@@ -45,9 +49,12 @@ Describe 'Perspektivwerk static route' {
|
||||
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);
|
||||
});
|
||||
@@ -1,73 +1,53 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { bookingConfirmationMessage, canSubmitBookingForm, formatDateKey, getSlotsForDate, validateBookingField, validateBookingForm } from '../public/booking/booking.js';
|
||||
import { bookingConfirmationMessage, canSubmitBookingForm, formatDateKey, formatEventDuration, getSlotsForDate, requiresIntroductionDetails, validateBookingForm } from '../public/perspektivwerk/booking/booking.js';
|
||||
|
||||
test('liefert für einen Beispieltag verfügbare und belegte Slots', () => {
|
||||
const slots = getSlotsForDate('perspektivwerk', '2026-08-25');
|
||||
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' },
|
||||
];
|
||||
|
||||
assert.equal(slots.length, 3);
|
||||
assert.equal(slots.filter((slot) => slot.status === 'available').length, 2);
|
||||
assert.equal(slots.filter((slot) => slot.status === 'booked').length, 1);
|
||||
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-08-20'), []);
|
||||
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('liefert Termine im aktuellen und folgenden Monat', () => {
|
||||
assert.ok(getSlotsForDate('perspektivwerk', '2026-08-24').length >= 2);
|
||||
assert.ok(getSlotsForDate('perspektivwerk', '2026-08-28').length >= 2);
|
||||
assert.ok(getSlotsForDate('perspektivwerk', '2026-09-02').length >= 2);
|
||||
assert.ok(getSlotsForDate('perspektivwerk', '2026-09-15').length >= 2);
|
||||
assert.ok(getSlotsForDate('perspektivwerk', '2026-09-29').length >= 2);
|
||||
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('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.',
|
||||
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', privacyAccepted: true }),
|
||||
{}
|
||||
);
|
||||
assert.deepEqual(validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', phone: '+49 351 123456', privacyAccepted: true }), {});
|
||||
});
|
||||
|
||||
test('lehnt ein eingegebenes Alter außerhalb von 12 bis 112 ab', () => {
|
||||
assert.equal(
|
||||
validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', age: '11', privacyAccepted: true }).age,
|
||||
'Bitte geben Sie ein Alter zwischen 12 und 112 Jahren ein.'
|
||||
);
|
||||
assert.equal(
|
||||
validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', age: '113', privacyAccepted: true }).age,
|
||||
'Bitte geben Sie ein Alter zwischen 12 und 112 Jahren ein.'
|
||||
);
|
||||
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('akzeptiert ein leeres oder gültiges optionales Alter', () => {
|
||||
assert.deepEqual(validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', age: '', privacyAccepted: true }), {});
|
||||
assert.deepEqual(validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', age: '12', privacyAccepted: true }), {});
|
||||
assert.deepEqual(validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', age: '112', privacyAccepted: true }), {});
|
||||
});
|
||||
|
||||
test('validiert optionale Telefonnummern direkt am Feld', () => {
|
||||
assert.equal(validateBookingField('phone', 'abc'), 'Bitte geben Sie eine gültige Telefonnummer ein.');
|
||||
assert.equal(validateBookingField('phone', '+49 351 123456'), '');
|
||||
assert.equal(validateBookingField('phone', ''), '');
|
||||
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', () => {
|
||||
@@ -75,9 +55,8 @@ test('gibt den Absenden-Button erst nach DSGVO-Einwilligung frei', () => {
|
||||
assert.equal(canSubmitBookingForm(true), true);
|
||||
});
|
||||
|
||||
test('zeigt nach dem Absenden eine Bestätigung ohne Mockup-Hinweis', () => {
|
||||
test('zeigt nach dem Absenden den nächsten Bestätigungsschritt ohne Mockup-Hinweis', () => {
|
||||
const message = bookingConfirmationMessage('Erstgespräch');
|
||||
|
||||
assert.match(message, /erfolgreich übermittelt/);
|
||||
assert.match(message, /E-Mail/);
|
||||
assert.doesNotMatch(message, /Vorschau|Mockup/i);
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user