diff --git a/AGENTS.md b/AGENTS.md index 2b55a42..6a805e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ Die Terminbuchungskomponente muss in zwei gleichwertigen Varianten funktionieren - **Apache-Vorschau:** als Bestandteil der statischen Seite im Webpfad `public/perspektivwerk/`, erreichbar unter `http://localhost:8080/perspektivwerk/`. - **Contao-Einbindung:** als einbettbare Komponente in einem Contao-Inhaltselement vom Typ HTML. -Beide Varianten verwenden dieselben browsernativen JavaScript- und CSS-Assets aus `public/booking/`. Die Apache-Vorschau referenziert sie über `/booking/booking.js` und `/booking/booking.css`. Für Contao wird das kombinierte Snippet aus `integration/contao-html-element.html` in ein neues Inhaltselement vom Typ **HTML** eingefügt. Es muss als Roh-HTML im Feld „HTML-Code“ gespeichert werden, nicht als Textinhalt eines TinyMCE-Text-Elements. Ein zweites Kopieren in den Contao-Dateibereich `files/` ist nicht erforderlich. +Contao verwendet die browsernativen JavaScript- und CSS-Assets aus `public/booking/`: `booking.js` und `booking.css`, eingebunden über `/booking/booking.js` und `/booking/booking.css`. Für die Apache-Vorschau liegen identische, statisch übertragbare Kopien unter `public/perspektivwerk/js/`, die relativ über `js/booking.js` und `js/booking.css` eingebunden werden. Für Contao wird das kombinierte Snippet aus `integration/contao-html-element.html` in ein neues Inhaltselement vom Typ **HTML** eingefügt. Es muss als Roh-HTML im Feld „HTML-Code“ gespeichert werden, nicht als Textinhalt eines TinyMCE-Text-Elements. Ein zweites Kopieren in den Contao-Dateibereich `files/` ist nicht erforderlich. ### Die Oberfläche / UI diff --git a/development-system/README.md b/development-system/README.md index d2632b7..5d068dc 100644 --- a/development-system/README.md +++ b/development-system/README.md @@ -58,13 +58,13 @@ 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 `public/booking/booking.js` als browsernatives ES-Modul. +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/`. ## Zwei Einbindungswege Die Terminbuchungskomponente hat eine Vorschau auf einem Webseiten-Nachbau der Orginalseite: -**Apache-Vorschau:** auf der statischen Perspektivwerk-Seite unter . Die Seite liegt in `public/perspektivwerk/`; ihre Referenzen auf die Komponente verwenden die öffentlichen Pfade `/booking/booking.css` und `/booking/booking.js`. +**Apache-Vorschau:** auf der statischen Perspektivwerk-Seite unter . Die Seite liegt in `public/perspektivwerk/`; ihre Referenzen auf die Komponente verwenden die relativen Pfade `js/booking.css` und `js/booking.js`. 2. **Contao:** als eigenständiges Inhaltselement vom Typ **HTML** auf einer Contao-Seite. Es enthält das kombinierte Snippet mit CSS, Komponentenplatzhalter und JavaScript. `public/` ist der Apache-Webroot der lokalen Entwicklungsumgebung. Daher ist kein Kopieren der Komponentenassets nach `files/terminbuchung/` erforderlich. @@ -93,7 +93,8 @@ 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 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. +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. + ## 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. diff --git a/development-system/public/perspektivwerk/index.html b/development-system/public/perspektivwerk/index.html index 73f46be..308243c 100644 --- a/development-system/public/perspektivwerk/index.html +++ b/development-system/public/perspektivwerk/index.html @@ -6,7 +6,7 @@ PerspektivWerk | SUFW Dresden - + @@ -26,6 +26,6 @@

Sie haben Fragen zu dieser Maßnahme?

Haben Sie Interesse an unserem Angebot, melden Sie sich bei uns. Wir beraten Sie gern vorab zu den weiteren Schritten.

Kontakt: (0176) 60328929 · perspektivwerk@sufw.de · Dresdner Straße 16, 01326 Dresden

Sächsisches Umschulungs- und Fortbildungswerk Dresden e. V.
- + diff --git a/development-system/public/perspektivwerk/js/booking.css b/development-system/public/perspektivwerk/js/booking.css new file mode 100644 index 0000000..3508ea7 --- /dev/null +++ b/development-system/public/perspektivwerk/js/booking.css @@ -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}} diff --git a/development-system/public/perspektivwerk/js/booking.js b/development-system/public/perspektivwerk/js/booking.js new file mode 100644 index 0000000..1e0ba03 --- /dev/null +++ b/development-system/public/perspektivwerk/js/booking.js @@ -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 = ` +
+
+

Terminbuchung – Vorschau

+

${project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule'}

+

Wählen Sie einen Tag. Freie Termine sind mit „frei“ gekennzeichnet.

+
+
Termin verfügbar Termin belegt
+
+ +

${monthFormatter.format(currentMonth)}

+ +
+ +
+ ${createMonthDates(currentMonth).map((date) => { + if (!date) + return ''; + 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 ``; + }).join('')} +
+
+

Termine am ${selectedLabel}

+ ${slots.length ? `
    ${slots.map((slot) => `
  • `).join('')}
` : '

Für diesen Tag liegen in der Vorschau keine Termine vor.

'} +

+
+
`; + 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(); + } +} diff --git a/development-system/tests/Deploy-Perspektivwerk.Tests.ps1 b/development-system/tests/Deploy-Perspektivwerk.Tests.ps1 new file mode 100644 index 0000000..6ee96e2 --- /dev/null +++ b/development-system/tests/Deploy-Perspektivwerk.Tests.ps1 @@ -0,0 +1,32 @@ +$projectRoot = Split-Path -Parent $PSScriptRoot +$deployScriptPath = Join-Path $projectRoot 'scripts\Deploy-Perspektivwerk.ps1' + +Describe 'Perspektivwerk deployment' { + It 'provides a deployment script for the static page' { + (Test-Path -LiteralPath $deployScriptPath) | Should Be $true + } + + 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/' + } +} diff --git a/development-system/tests/PerspektivwerkRoute.Tests.ps1 b/development-system/tests/PerspektivwerkRoute.Tests.ps1 index e16fbc1..e11f1cd 100644 --- a/development-system/tests/PerspektivwerkRoute.Tests.ps1 +++ b/development-system/tests/PerspektivwerkRoute.Tests.ps1 @@ -1,5 +1,9 @@ $projectRoot = Split-Path -Parent $PSScriptRoot $mockupPath = Join-Path $projectRoot 'public\perspektivwerk\index.html' +$bookingScriptPath = Join-Path $projectRoot 'public\perspektivwerk\js\booking.js' +$bookingStylesPath = Join-Path $projectRoot 'public\perspektivwerk\js\booking.css' +$contaoBookingScriptPath = Join-Path $projectRoot 'public\booking\booking.js' +$contaoBookingStylesPath = Join-Path $projectRoot 'public\booking\booking.css' $htaccessPath = Join-Path $projectRoot 'public\.htaccess' Describe 'Perspektivwerk static route' { @@ -10,9 +14,18 @@ Describe 'Perspektivwerk static route' { It 'embeds the appointment booking component with its public assets' { $mockup = Get-Content -LiteralPath $mockupPath -Raw - $mockup | Should Match 'href="/booking/booking\.css"' + (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 'src="/booking/booking\.js"' + $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 'routes /perspektivwerk to the static entry point before the Contao fallback' {