Erstellen einer ersten test-Komponente die in der Mockup-HTML-Seite und in Contao eingebunden werden kann

This commit is contained in:
Robert Wartenberg committed 2026-08-20 12:28:57 +02:00
1 parent e96c73000d
commit 76e45ac9ad
12 files changed
+217 -114

No files matched your search

+40 -5
View File
@@ -1,6 +1,6 @@
# Perspektivwerk Terminbuchung – lokales Entwicklungssystem
Dieser Ordner enthält eine lokale Contao-4.13.58-Entwicklungsumgebung mit Apache, MariaDB sowie die im Frontend verbreiteten Komponenten Bootstrap 5, jQuery und Slick Carousel.
Dieser Ordner enthält eine lokale Contao-4.13.58-Entwicklungsumgebung mit Apache, MariaDB und Bootstrap 5. Die Buchungskomponente verwendet browsernative DOM-APIs und keine jQuery-Abhängigkeit.
## Contao-Version und Sicherheitsstatus
@@ -10,7 +10,7 @@ Contao 4.13.58 ist ausschließlich als lokale Kompatibilitätsumgebung vorgesehe
## Start
Voraussetzungen: Docker Desktop (Compose v2) und optional Node.js 20+ für die TypeScript-Assets.
Voraussetzungen: Docker Desktop (Compose v2) und optional Node.js 20+ zum Ausführen der JavaScript-Regressionstests.
```powershell
docker compose up --build
@@ -39,11 +39,11 @@ Danach den versionierten Seitenzustand importieren:
- Contao-Login: <http://localhost:8080/contao/login>
- Datenbankinspektor: <http://localhost:8081> (Server `db`, Benutzer `perspektivwerk`, Passwort `perspektivwerk`, Datenbank `perspektivwerk`)
Für die Frontend-Logik:
Für die Frontend-Regressionstests:
```powershell
npm install
npm run build
npm test
```
**Seite öffnen:**
@@ -58,7 +58,42 @@ http://localhost:8080/perspektivwerk/
Die genaue Contao-, PHP- und Template-Version der öffentlichen Seite ist nicht verlässlich aus dem ausgelieferten HTML ableitbar. Das Setup bildet daher die wahrscheinlichste Komponentenfamilie nach; es ist kein Dump der Produktivinstallation. IONOS/Hosting, Matomo-Konfiguration, Inhalte, Templates und Erweiterungen müssen bei Bedarf separat ergänzt werden.
Die Terminlogik liegt zunächst in `src/booking.ts`. Eine spätere Google-Calendar- oder Exchange-Anbindung sollte über eine Backend-API erfolgen, damit keine Secrets im Browser landen.
Die Terminlogik liegt zunächst in `public/booking/booking.js` als browsernatives ES-Modul.
## Zwei Einbindungswege
Die Terminbuchungskomponente hat eine Vorschau auf einem Webseiten-Nachbau der Orginalseite:
**Apache-Vorschau:** auf der statischen Perspektivwerk-Seite unter <http://localhost:8080/perspektivwerk/>. Die Seite liegt in `public/perspektivwerk/`; ihre Referenzen auf die Komponente verwenden die öffentlichen Pfade `/booking/booking.css` und `/booking/booking.js`.
2. **Contao:** als eigenständiges Inhaltselement vom Typ **HTML** auf einer Contao-Seite. Es enthält das kombinierte Snippet mit CSS, Komponentenplatzhalter und JavaScript.
`public/` ist der Apache-Webroot der lokalen Entwicklungsumgebung. Daher ist kein Kopieren der Komponentenassets nach `files/terminbuchung/` erforderlich.
## Statische Buchungsvorschau in Contao einbinden
Die erste Komponente ist absichtlich datenbankfrei. Sie zeigt Test-Slots, rendert einen Monatskalender und unterscheidet freie von belegten Terminen. Eine Buchung wird noch nicht gespeichert.
Die Komponente benötigt keinen TypeScript-Compiler und keinen Build-Schritt. Regressionstests ausführen:
```powershell
npm run test
```
### Einbinden in Contao
Ein **neues** Inhaltselement vom Typ **HTML** anlegen. Im Feld **HTML-Code** den vollständigen Inhalt aus [integration/contao-html-element.html](integration/contao-html-element.html) einfügen und speichern. Nicht das Inhaltselement **Text** und keinen TinyMCE-Editor verwenden: Diese können `link`- und `script`-Tags als Text maskieren. Für die Produktionsschule wird `data-project="produktionsschule"` gesetzt.
In der Seitenvorschau muss der HTML-Quelltext die Tags als `<link ...>` und `<script ...>` enthalten. Erscheinen stattdessen `&lt;link` oder `&lt;script`, wurde nicht das unmaskierte Feld **HTML-Code** verwendet und die Komponente kann nicht starten.
### Erforderliche Contao-Sicherheitseinstellung
Contao maskiert nicht freigegebene HTML-Tags auch im Inhaltselement **HTML**. Vor dem Einfügen der Komponente unter **System → Einstellungen → Sicherheit** die folgenden Einträge ergänzen:
- **Erlaubte HTML-Tags:** `script`, `link`
- **Erlaubte HTML-Attribute:** für `script` die Attribute `src`, `type`; für `link` die Attribute `href`, `rel`; für den Komponentenplatzhalter auf `div` entweder `data-*` oder mindestens `data-sufw-booking`, `data-project`.
Danach den Contao-Cache über **Systemwartung** leeren und das Inhaltselement erneut speichern. Keine globale Freigabe mit `*` verwenden. Wer ein HTML-Inhaltselement bearbeiten darf, kann mit dem freigegebenen `script`-Tag JavaScript auf der Website ausführen; diese Bearbeitungsrechte müssen daher auf vertrauenswürdige Backend-Nutzer beschränkt bleiben.
Die Beispieltermine stehen ausschließlich in `public/booking/booking.js`. Der spätere serverseitige Endpunkt muss sie ersetzen; Datenbankzugänge oder SQL gehören niemals in das HTML oder das Browser-JavaScript.
## Versionierter Contao-Seitenzustand
@@ -0,0 +1,9 @@
<!--
Contao: Neues Inhaltselement vom Typ "HTML" anlegen.
Diesen vollständigen Block unverändert in das Feld "HTML-Code" einfügen.
Nicht in ein Text-/TinyMCE-Element einfügen.
Für Produktionsschule data-project="produktionsschule" setzen.
-->
<link rel="stylesheet" href="/booking/booking.css">
<div data-sufw-booking data-project="perspektivwerk"></div>
<script type="module" src="/booking/booking.js"></script>
+1 -53
View File
@@ -6,13 +6,7 @@
"": {
"name": "perspektivwerk-booking-frontend",
"dependencies": {
"bootstrap": "^5.3.3",
"jquery": "^3.7.1",
"slick-carousel": "^1.8.1"
},
"devDependencies": {
"@types/jquery": "^3.5.32",
"typescript": "^5.7.2"
"bootstrap": "^5.3.3"
}
},
"node_modules/@popperjs/core": {
@@ -26,23 +20,6 @@
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@types/jquery": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.34.tgz",
"integrity": "sha512-3m3939S3erqmTLJANS/uy0B6V7BorKx7RorcGZVjZ62dF5PAGbKEDZK1CuLtKombJkFA2T1jl8LAIIs7IV6gBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/sizzle": "*"
}
},
"node_modules/@types/sizzle": {
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz",
"integrity": "sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==",
"dev": true,
"license": "MIT"
},
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
@@ -61,35 +38,6 @@
"peerDependencies": {
"@popperjs/core": "^2.11.8"
}
},
"node_modules/jquery": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==",
"license": "MIT"
},
"node_modules/slick-carousel": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/slick-carousel/-/slick-carousel-1.8.1.tgz",
"integrity": "sha512-XB9Ftrf2EEKfzoQXt3Nitrt/IPbT+f1fgqBdoxO3W/+JYvtEOW6EgxnWfr9GH6nmULv7Y2tPmEX3koxThVmebA==",
"license": "MIT",
"peerDependencies": {
"jquery": ">=1.8.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+2 -9
View File
@@ -3,16 +3,9 @@
"private": true,
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
"test": "node --test tests/booking.test.mjs"
},
"dependencies": {
"bootstrap": "^5.3.3",
"jquery": "^3.7.1",
"slick-carousel": "^1.8.1"
},
"devDependencies": {
"@types/jquery": "^3.5.32",
"typescript": "^5.7.2"
"bootstrap": "^5.3.3"
}
}
@@ -0,0 +1 @@
.sufw-booking{--sufw-booking-green:#08752a;--sufw-booking-text:#202020;--sufw-booking-muted:#5a5a5a;--sufw-booking-line:#c7c7c7;--sufw-booking-pale:#f3f6f3;box-sizing:border-box;max-width:860px;margin:2rem auto;padding:1.25rem;color:var(--sufw-booking-text);background:#fff;border:1px solid var(--sufw-booking-line);font:1rem/1.5 Arial,"Helvetica Neue",sans-serif}.sufw-booking *{box-sizing:border-box}.sufw-booking__header h2,.sufw-booking__slots h3{margin:.15rem 0 .5rem;color:var(--sufw-booking-green);font-size:1.4rem}.sufw-booking__header p{margin:.3rem 0}.sufw-booking__eyebrow{color:var(--sufw-booking-green);font-size:.875rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em}.sufw-booking__legend{display:flex;gap:1rem;flex-wrap:wrap;margin:1.25rem 0;font-size:.9rem}.sufw-booking__marker{display:inline-block;width:.8rem;height:.8rem;margin-right:.35rem;border-radius:50%}.sufw-booking__marker--available{background:var(--sufw-booking-green)}.sufw-booking__marker--booked{background:#535353}.sufw-booking__month-nav{display:grid;grid-template-columns:3rem 1fr 3rem;align-items:center;margin-top:1rem;text-align:center}.sufw-booking__month-nav h3{margin:0;font-size:1.1rem;text-transform:capitalize}.sufw-booking__nav,.sufw-booking__day,.sufw-booking__slot{font:inherit}.sufw-booking__nav{min-width:2.75rem;min-height:2.75rem;border:1px solid var(--sufw-booking-green);color:var(--sufw-booking-green);background:#fff;font-size:1.7rem;cursor:pointer}.sufw-booking__nav:hover,.sufw-booking__nav:focus-visible{background:var(--sufw-booking-pale)}.sufw-booking__weekdays,.sufw-booking__calendar{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:.3rem}.sufw-booking__weekdays{margin-top:1rem;text-align:center;font-weight:700;font-size:.8rem}.sufw-booking__day{display:grid;place-items:center;gap:.1rem;min-height:3.8rem;padding:.25rem;border:1px solid transparent;background:#fff;color:var(--sufw-booking-text);cursor:pointer}.sufw-booking__day:hover{border-color:var(--sufw-booking-green);background:var(--sufw-booking-pale)}.sufw-booking__day--available{background:#dff3e4;border-color:var(--sufw-booking-green)}.sufw-booking__day--booked{background:#ececec;border-color:#777}.sufw-booking__day.is-selected{outline:3px solid #1f4fbc;outline-offset:1px}.sufw-booking__day small{font-size:.7rem}.sufw-booking__empty{min-height:3.8rem}.sufw-booking__slots{margin-top:1.5rem;padding-top:1.25rem;border-top:1px solid var(--sufw-booking-line)}.sufw-booking__slots ul{padding:0;margin:0;list-style:none}.sufw-booking__slot{display:grid;grid-template-columns:5rem 1fr auto;gap:.7rem;width:100%;margin:.45rem 0;padding:.8rem;text-align:left;color:var(--sufw-booking-text);background:#fff;border:1px solid var(--sufw-booking-green);cursor:pointer}.sufw-booking__slot:hover,.sufw-booking__slot:focus-visible{background:var(--sufw-booking-pale)}.sufw-booking__slot em{color:var(--sufw-booking-green);font-style:normal;font-weight:700}.sufw-booking__slot:disabled{border-color:#777;background:#eee;color:#555;cursor:not-allowed}.sufw-booking__slot:disabled em{color:#555}.sufw-booking__empty-state,.sufw-booking__status{margin:.7rem 0}.sufw-booking__status{font-weight:700}@media (max-width:520px){.sufw-booking{margin:1rem 0;padding:1rem}.sufw-booking__weekdays{font-size:.7rem}.sufw-booking__day{min-height:3.4rem;padding:.15rem}.sufw-booking__slot{grid-template-columns:1fr;gap:.15rem}.sufw-booking__slot strong{font-size:1.1rem}}
@@ -0,0 +1,110 @@
/* Browsernatives ES-Modul; wird ohne TypeScript-Compiler ausgeliefert. */
const demoSlots = [
{ id: 'pw-2026-08-25-1000', project: 'perspektivwerk', date: '2026-08-25', start: '10:00', title: 'Erstgespräch', status: 'available' },
{ id: 'pw-2026-08-25-1300', project: 'perspektivwerk', date: '2026-08-25', start: '13:00', title: 'Soz. Päd. Modul', status: 'booked' },
{ id: 'pw-2026-08-25-1500', project: 'perspektivwerk', date: '2026-08-25', start: '15:00', title: 'Erstgespräch', status: 'available' },
{ id: 'pw-2026-08-27-0900', project: 'perspektivwerk', date: '2026-08-27', start: '09:00', title: 'Psy Modul', status: 'available' },
{ id: 'pw-2026-08-31-0800', project: 'perspektivwerk', date: '2026-08-31', start: '08:00', title: 'Praxis Modul', status: 'available' },
{ 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);
}
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`);
}
function formatDateKey(date) {
return date.toISOString().slice(0, 10);
}
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 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 – Vorschau</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 in der Vorschau 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 status = container.querySelector('.sufw-booking__status');
if (status)
status.textContent = 'Vorschau: Die eigentliche Buchungsmaske wird im nächsten Schritt ergänzt.';
}));
};
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();
}
}
@@ -6,6 +6,7 @@
<meta name="description" content="PerspektivWerk – Brücke zwischen Diagnostik, Begleitung und betrieblicher Praxis.">
<title>PerspektivWerk | SUFW Dresden</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="/booking/booking.css">
</head>
<body>
<a class="skip-link" href="#main">Navigation überspringen</a>
@@ -19,8 +20,12 @@
<section class="intro page-width"><h1>PerspektivWerk – Brücke zwischen Diagnostik, Begleitung und betrieblicher Praxis</h1><img class="project-logo" src="media/logo-perspektivwerk.jpg" alt="PerspektivWerk"><div class="intro-copy"><p>Unser Fachpersonal arbeitet mit Ihnen gemeinsam daran, <strong>Ihre aktuelle Lebenssituation zu verstehen.</strong> Ziel ist es, aus den Informationen individuelle und zu Ihnen <strong>passende Wege ins Arbeitsleben</strong> sichtbar zu machen.</p><p>Dazu gehört es, herauszufinden, welche <strong>Herausforderungen Sie im Alltag</strong> bestreiten müssen und wo es Unterstützungsbedarf gibt. Vor allem aber auch, wo Ihre Fähigkeiten, Interessen und Entwicklungsmöglichkeiten liegen. Dabei arbeiten wir eng mit den beratenden Fachkräften der Agentur für Arbeit und des Jobcenters zusammen.</p></div></section>
<section class="team-section"><div class="team-copy"><img src="media/wir-sind-fuer-sie-da.svg" alt="Wir sind für Sie da"><p>Unser Team, bestehend aus sozialpädagogischen, pädagogischen und psychologischen Mitarbeiterinnen und Mitarbeitern, <strong>unterstützt Ihren Prozess der beruflichen Orientierung.</strong> Wir arbeiten eng mit den Bearbeiterinnen und Beratern der Agentur für Arbeit, dem Jobcenter und weiteren Beratungsstellen zusammen.</p><p>Die Teilnahme am <strong>PerspektivWerk ist kostenlos.</strong> Sie bestimmen, an welchen Angeboten Sie teilnehmen.</p></div><div class="team-image"><img src="media/perspektivwerk-psych.jpg" alt="Beratungsgespräch im PerspektivWerk"></div></section>
<section class="content page-width" id="projekte"><div class="content-grid"><article><h2>Wer kann zu uns kommen?</h2><p>Menschen zwischen 15 und 65 Jahren, die sich beruflich neu orientieren möchten, beim Einstieg Unterstützung suchen oder ihre Möglichkeiten gemeinsam klären wollen.</p></article><article><h2>1. Orientierung &amp; Ersteinschätzung</h2><p>Sozialpädagogische und psychologische Module helfen dabei, persönliche Situation, Motivation, Belastbarkeit und Stärken einzuschätzen.</p></article><article><h2>2. Praktische Erprobung &amp; Belastungstest</h2><p>Arbeitsproben und praktische Erprobungen machen fachliche, soziale und arbeitsbezogene Kompetenzen sichtbar.</p></article><article><h2>3. Auswertung &amp; Perspektiventwicklung</h2><p>Wir führen die Ergebnisse zusammen und entwickeln konkrete, realistische nächste Schritte.</p></article></div></section>
<section class="page-width" aria-label="Terminbuchung PerspektivWerk">
<div data-sufw-booking data-project="perspektivwerk"></div>
</section>
<section class="contact page-width" id="kontakt"><h2>Sie haben Fragen zu dieser Maßnahme?</h2><p>Haben Sie Interesse an unserem Angebot, melden Sie sich bei uns. Wir beraten Sie gern vorab zu den weiteren Schritten.</p><p><strong>Kontakt:</strong> <a href="tel:+4917660328929">(0176) 60328929</a> · <a href="mailto:perspektivwerk@sufw.de">perspektivwerk@sufw.de</a> · Dresdner Straße 16, 01326 Dresden</p></section>
</main>
<footer><div class="page-width">Sächsisches Umschulungs- und Fortbildungswerk Dresden e. V.</div></footer>
<script type="module" src="/booking/booking.js"></script>
</body>
</html>
-21
View File
@@ -1,21 +0,0 @@
/**
* Dünne, frameworkfreie Buchungslogik. Sie kann später gegen eine Backend-API
* oder eine Kalender-Integration ausgetauscht werden, ohne das UI neu zu bauen.
*/
export type BookingProject = 'perspektivwerk' | 'produktionsschule';
export interface BookingOption {
id: string;
project: BookingProject;
title: string;
duration: string;
}
export const bookingOptions: BookingOption[] = [
{ id: 'pw-erstgespraech', project: 'perspektivwerk', title: 'Erstgespräch Perspektivwerk', duration: '1 Stunde' },
{ id: 'pw-sozpaed', project: 'perspektivwerk', title: 'Soz. Päd. Modul Perspektivwerk', duration: '4 Stunden' },
{ id: 'pw-psy', project: 'perspektivwerk', title: 'Psy Modul Perspektivwerk', duration: '4 Stunden' },
{ id: 'pw-praxis', project: 'perspektivwerk', title: 'Praxis Modul Perspektivwerk', duration: '1 Woche, ab Montag 08:00 Uhr' },
{ id: 'ps-kennenlernen', project: 'produktionsschule', title: 'Voranmeldung Kennenlerntermin Produktionsschule', duration: '1 Stunde, dienstags' },
{ id: 'ps-probearbeiten', project: 'produktionsschule', title: 'Probearbeiten', duration: '1 Woche, ab Montag 08:00 Uhr' }
];
@@ -7,6 +7,14 @@ Describe 'Perspektivwerk static route' {
(Test-Path -LiteralPath $mockupPath) | Should Be $true
}
It 'embeds the appointment booking component with its public assets' {
$mockup = Get-Content -LiteralPath $mockupPath -Raw
$mockup | Should Match 'href="/booking/booking\.css"'
$mockup | Should Match 'data-sufw-booking'
$mockup | Should Match 'src="/booking/booking\.js"'
}
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]')
+16
View File
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getSlotsForDate } from '../public/booking/booking.js';
test('liefert für einen Beispieltag verfügbare und belegte Slots', () => {
const slots = getSlotsForDate('perspektivwerk', '2026-08-25');
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 für einen Tag ohne Termin keine Slots', () => {
assert.deepEqual(getSlotsForDate('produktionsschule', '2026-08-20'), []);
});
-11
View File
@@ -1,11 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "Bundler",
"strict": true,
"noEmitOnError": true,
"outDir": "public/perspektivwerk/js"
},
"include": ["src/**/*.ts"]
}