diff --git a/.gitignore b/.gitignore
index d1ae980..6b86b95 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,5 @@
/app/Config/database.php
/vendors/*
+/mysql.local.php
+/storage/runtime/
diff --git a/README.md b/README.md
index 12d5f23..a8346e1 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,69 @@
-# fotobox-webspite
+# Fotobox-Webseite
+Diese Anwendung stellt eine komplette Vermietungsseite fuer eine Fotobox bereit. Sie enthaelt:
+
+- eine neu aufgebaute Landingpage auf Basis der Inhalte von `https://ctb-it.de/fotobox/`
+- eine oeffentliche Buchungsanfrage mit zwei Terminen, Live-Preisberechnung und Zahlungsart-Auswahl
+- einen Admin-Bereich fuer Anfragen, manuelle Kundenbestellungen, Statuspflege und Rechnungs-PDFs
+- eine MySQL-faehige Datenhaltung mit JSON-Fallback
+
+## Starten
+
+Die Anwendung benoetigt nur PHP 8.3 oder neuer.
+
+```bash
+php -S 127.0.0.1:8000
+```
+
+Danach ist die Seite unter `http://127.0.0.1:8000` erreichbar.
+
+## Admin-Zugang
+
+- Benutzername: `admin`
+- Passwort: Standardmaessig `fotobox-admin`
+
+Falls du das Passwort aendern willst, setze die Umgebungsvariable `FOTOBOX_ADMIN_PASSWORD`.
+
+## Datenhaltung
+
+Standardmaessig nutzt die App JSON-Dateien:
+
+- `storage/bookings.json`
+- `storage/invoices.json`
+
+Sobald du `mysql.local.php` mit echten Zugangsdaten befuellst, `enabled => true` setzt und optional ein `table_prefix` definierst, schaltet die App automatisch auf MySQL um.
+
+## MySQL vorbereiten
+
+Im Repository liegt als Vorlage:
+
+- `mysql.local.php.example`
+- `docs/mysql-schema.sql`
+
+Lokal liegt ausserhalb des Git-Trackings:
+
+- `mysql.local.php`
+
+Die Datei `mysql.local.php` ist bereits in `.gitignore` ausgeschlossen und kann von dir mit echten Zugangsdaten befuellt werden.
+Standardmaessig verwendet die App das Prefix `fb_`, also z. B. `fb_bookings` und `fb_invoices`.
+
+## Wichtige Annahmen
+
+- Mietbeginn und Mietende werden inklusiv berechnet.
+- Standardpreis: `99,99 EUR` pro Kalendertag.
+- Zahlungsarten: `Rechnung / Ueberweisung` und `PayPal`.
+- Die PayPal-Auswahl ist im Prozess und in der Verwaltung abgebildet; fuer einen echten Live-Payment-Flow brauchst du spaeter zusaetzliche API-Zugangsdaten.
+
+## Verwaltung
+
+Im Admin-Bereich kannst du:
+
+- neue Kundenbestellungen manuell anlegen
+- Anfragen bestaetigen oder stornieren
+- Zahlungsstatus pflegen
+- Rechnungen mit Kundendaten erzeugen
+- Rechnungen als PDF oeffnen
+
+## Tests
+
+Eine kurze Checkliste liegt in [docs/manual-test.md](docs/manual-test.md).
diff --git a/assets/app.js b/assets/app.js
new file mode 100644
index 0000000..d0939cc
--- /dev/null
+++ b/assets/app.js
@@ -0,0 +1,50 @@
+const forms = document.querySelectorAll('.booking-form');
+
+const formatCurrency = (cents) =>
+ new Intl.NumberFormat('de-DE', {
+ style: 'currency',
+ currency: 'EUR',
+ }).format(cents / 100);
+
+const calculateDays = (start, end) => {
+ if (!start || !end) return null;
+ const startDate = new Date(start);
+ const endDate = new Date(end);
+ if (Number.isNaN(startDate.valueOf()) || Number.isNaN(endDate.valueOf())) return null;
+ const milliseconds = endDate.getTime() - startDate.getTime();
+ const days = Math.floor(milliseconds / 86400000) + 1;
+ return days > 0 ? days : null;
+};
+
+forms.forEach((form) => {
+ const startInput = form.querySelector('[data-booking-start]');
+ const endInput = form.querySelector('[data-booking-end]');
+ const daysOutput = form.querySelector('[data-summary-days]');
+ const totalOutput = form.querySelector('[data-summary-total]');
+ const rateInput = form.querySelector('input[name="price_per_day_cents"]');
+ const defaultRate = Number(form.dataset.dayRate || 9999);
+
+ const render = () => {
+ const days = calculateDays(startInput?.value, endInput?.value);
+ const rate = Number(rateInput?.value || defaultRate);
+
+ if (!days || rate < 0) {
+ if (daysOutput) daysOutput.textContent = 'Noch nicht gewaehlt';
+ if (totalOutput) totalOutput.textContent = formatCurrency(defaultRate);
+ return;
+ }
+
+ if (daysOutput) {
+ daysOutput.textContent = `${days} ${days === 1 ? 'Tag' : 'Tage'}`;
+ }
+
+ if (totalOutput) {
+ totalOutput.textContent = formatCurrency(days * rate);
+ }
+ };
+
+ startInput?.addEventListener('input', render);
+ endInput?.addEventListener('input', render);
+ rateInput?.addEventListener('input', render);
+ render();
+});
diff --git a/assets/styles.css b/assets/styles.css
new file mode 100644
index 0000000..a495026
--- /dev/null
+++ b/assets/styles.css
@@ -0,0 +1,698 @@
+:root {
+ --bg: #f4efe7;
+ --bg-panel: rgba(255, 255, 255, 0.72);
+ --card: #fdf9f2;
+ --surface: #ffffff;
+ --line: rgba(55, 35, 18, 0.12);
+ --text: #1f160f;
+ --muted: #6d5a4d;
+ --accent: #bd5f2d;
+ --accent-deep: #7a3412;
+ --accent-soft: #f1d1ba;
+ --success: #1f6c46;
+ --error: #8a2630;
+ --shadow: 0 24px 60px rgba(57, 35, 18, 0.14);
+ --radius-lg: 28px;
+ --radius-md: 18px;
+ --radius-sm: 12px;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ font-family: "Avenir Next", "Segoe UI", sans-serif;
+ color: var(--text);
+ background:
+ radial-gradient(circle at top left, rgba(203, 135, 77, 0.25), transparent 28%),
+ radial-gradient(circle at 85% 15%, rgba(122, 52, 18, 0.12), transparent 22%),
+ linear-gradient(180deg, #faf4eb 0%, var(--bg) 100%);
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+button,
+input,
+select,
+textarea {
+ font: inherit;
+}
+
+.page-shell {
+ width: min(1240px, calc(100% - 32px));
+ margin: 0 auto;
+ padding: 24px 0 72px;
+}
+
+.site-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ padding: 18px 22px;
+ position: sticky;
+ top: 18px;
+ z-index: 20;
+ margin-bottom: 32px;
+ background: rgba(252, 248, 241, 0.82);
+ border: 1px solid rgba(255, 255, 255, 0.7);
+ border-radius: 999px;
+ backdrop-filter: blur(16px);
+ box-shadow: 0 12px 30px rgba(58, 39, 24, 0.08);
+}
+
+.brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 14px;
+ font-family: Georgia, "Times New Roman", serif;
+}
+
+.brand strong,
+h1,
+h2,
+h3 {
+ font-family: Georgia, "Times New Roman", serif;
+ letter-spacing: -0.02em;
+}
+
+.brand small {
+ display: block;
+ color: var(--muted);
+ font-size: 0.8rem;
+ font-family: "Avenir Next", "Segoe UI", sans-serif;
+}
+
+.brand-mark {
+ display: grid;
+ place-items: center;
+ width: 46px;
+ height: 46px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, var(--accent) 0%, var(--accent-deep) 100%);
+ color: white;
+ font-weight: 700;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35);
+}
+
+.site-nav {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 18px;
+ color: var(--muted);
+}
+
+.site-nav form {
+ margin: 0;
+}
+
+.primary-link,
+.button-primary,
+.button-secondary,
+.ghost-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ border-radius: 999px;
+ padding: 14px 22px;
+ border: 0;
+ cursor: pointer;
+ transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
+}
+
+.primary-link,
+.button-primary {
+ background: linear-gradient(135deg, var(--accent) 0%, var(--accent-deep) 100%);
+ color: #fff;
+ box-shadow: 0 18px 40px rgba(137, 63, 27, 0.24);
+}
+
+.button-secondary {
+ background: rgba(255, 255, 255, 0.72);
+ color: var(--text);
+ border: 1px solid var(--line);
+}
+
+.ghost-button {
+ background: transparent;
+ color: var(--muted);
+}
+
+.button-block {
+ width: 100%;
+}
+
+.primary-link:hover,
+.button-primary:hover,
+.button-secondary:hover,
+.ghost-button:hover {
+ transform: translateY(-1px);
+}
+
+main {
+ display: grid;
+ gap: 28px;
+}
+
+.hero-section,
+.feature-strip,
+.content-grid,
+.pricing-panel,
+.availability-section,
+.booking-section,
+.faq-section,
+.admin-section,
+.admin-login-section {
+ background: var(--bg-panel);
+ border: 1px solid rgba(255, 255, 255, 0.7);
+ box-shadow: var(--shadow);
+ border-radius: var(--radius-lg);
+}
+
+.hero-section {
+ display: grid;
+ grid-template-columns: 1.2fr 0.9fr;
+ gap: 28px;
+ padding: 42px;
+}
+
+.eyebrow {
+ margin: 0 0 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.18em;
+ font-size: 0.78rem;
+ color: var(--accent-deep);
+ font-weight: 700;
+}
+
+.hero-copy h1,
+.admin-login-card h1,
+.section-header h1 {
+ margin: 0;
+ font-size: clamp(2.6rem, 5vw, 4.8rem);
+ line-height: 0.96;
+}
+
+.hero-text,
+.pricing-panel p,
+.booking-copy p,
+.admin-login-card p {
+ color: var(--muted);
+ font-size: 1.05rem;
+ line-height: 1.65;
+}
+
+.hero-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 14px;
+ margin: 28px 0 26px;
+}
+
+.hero-points,
+.check-list,
+.compact-list {
+ margin: 0;
+ padding-left: 20px;
+ color: var(--muted);
+ display: grid;
+ gap: 10px;
+}
+
+.hero-card {
+ display: grid;
+ gap: 16px;
+ padding: 18px;
+ background:
+ linear-gradient(145deg, rgba(255, 244, 232, 0.92), rgba(246, 225, 209, 0.85)),
+ var(--card);
+ border-radius: 24px;
+ min-height: 520px;
+}
+
+.hero-card-panel {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 16px 18px;
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.55);
+ color: var(--muted);
+}
+
+.hero-card-panel strong {
+ color: var(--text);
+}
+
+.hero-card-visual {
+ position: relative;
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+ border-radius: 24px;
+ min-height: 320px;
+ background: linear-gradient(180deg, rgba(118, 59, 31, 0.15), rgba(33, 18, 11, 0.24));
+}
+
+.camera-glow {
+ position: absolute;
+ width: 240px;
+ height: 240px;
+ border-radius: 50%;
+ background: radial-gradient(circle, rgba(255, 228, 195, 0.9) 0%, rgba(241, 179, 119, 0.15) 58%, transparent 72%);
+}
+
+.camera-body {
+ position: relative;
+ width: 280px;
+ height: 200px;
+ border-radius: 30px;
+ background: linear-gradient(180deg, #2a2320 0%, #13100f 100%);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 18px 42px rgba(0, 0, 0, 0.35);
+}
+
+.camera-body::before {
+ content: "";
+ position: absolute;
+ inset: -22px 40px auto auto;
+ width: 90px;
+ height: 70px;
+ border-radius: 24px 24px 8px 8px;
+ background: linear-gradient(180deg, #28211e 0%, #141111 100%);
+}
+
+.camera-lens {
+ position: absolute;
+ inset: 34px auto auto 78px;
+ width: 124px;
+ height: 124px;
+ border-radius: 50%;
+ background:
+ radial-gradient(circle at 42% 42%, rgba(95, 170, 221, 0.95) 0%, rgba(41, 86, 111, 0.9) 22%, rgba(4, 7, 10, 0.95) 58%, #050607 100%);
+ border: 12px solid #4a403a;
+}
+
+.camera-screen {
+ position: absolute;
+ inset: 48px 28px auto auto;
+ width: 56px;
+ height: 92px;
+ border-radius: 16px;
+ background: linear-gradient(180deg, rgba(255, 188, 125, 0.85), rgba(255, 240, 224, 0.22));
+ box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.12);
+}
+
+.feature-strip {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 18px;
+ padding: 24px;
+}
+
+.feature-strip article,
+.content-block,
+.pricing-aside,
+.booking-form-card,
+.table-card,
+.admin-login-card,
+.booking-info-card,
+.availability-card {
+ background: rgba(255, 255, 255, 0.7);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+}
+
+.feature-strip article {
+ padding: 24px;
+}
+
+.feature-strip h2,
+.content-block h2,
+.pricing-panel h2,
+.booking-copy h2,
+.faq-section h2,
+.table-card h2 {
+ margin: 0 0 12px;
+ font-size: clamp(1.6rem, 3vw, 2.3rem);
+}
+
+.feature-strip p,
+.content-block span,
+.content-block p,
+.pricing-aside span,
+.availability-card span,
+.availability-card small,
+.faq-list p,
+.table-card p,
+.detail-list dd,
+.stack-form span,
+.stack-form label,
+.booking-form span,
+.form-note {
+ color: var(--muted);
+ line-height: 1.6;
+}
+
+.content-grid,
+.booking-section,
+.detail-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 22px;
+ padding: 24px;
+}
+
+.content-block,
+.table-card,
+.admin-login-card {
+ padding: 28px;
+}
+
+.step-list {
+ display: grid;
+ gap: 18px;
+ margin: 24px 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.step-list li {
+ display: grid;
+ gap: 6px;
+ padding: 16px 18px;
+ border-radius: var(--radius-sm);
+ background: rgba(246, 232, 221, 0.72);
+}
+
+.pricing-panel,
+.availability-section,
+.faq-section {
+ padding: 28px;
+}
+
+.pricing-panel {
+ display: grid;
+ grid-template-columns: 1.15fr 0.85fr;
+ gap: 24px;
+}
+
+.pricing-aside {
+ display: grid;
+ gap: 14px;
+ padding: 24px;
+}
+
+.pricing-aside strong,
+.price-summary strong,
+.stat-card strong {
+ font-size: 1.45rem;
+}
+
+.availability-list,
+.faq-list,
+.stats-grid {
+ display: grid;
+ gap: 16px;
+}
+
+.availability-list {
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ margin-top: 20px;
+}
+
+.availability-card {
+ padding: 20px;
+ display: grid;
+ gap: 8px;
+}
+
+.availability-card-empty {
+ grid-column: 1 / -1;
+}
+
+.booking-section {
+ align-items: start;
+}
+
+.booking-info-card {
+ display: grid;
+ gap: 8px;
+ padding: 20px;
+}
+
+.booking-form-card {
+ padding: 28px;
+}
+
+.form-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+}
+
+.booking-form,
+.stack-form {
+ display: grid;
+ gap: 18px;
+}
+
+label {
+ display: grid;
+ gap: 8px;
+ font-size: 0.96rem;
+}
+
+input,
+select,
+textarea {
+ width: 100%;
+ border-radius: 14px;
+ border: 1px solid rgba(69, 42, 26, 0.16);
+ background: rgba(255, 255, 255, 0.92);
+ color: var(--text);
+ padding: 14px 16px;
+}
+
+textarea {
+ resize: vertical;
+}
+
+.price-summary {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+ padding: 18px;
+ border-radius: 18px;
+ background: rgba(245, 223, 202, 0.55);
+}
+
+.flash {
+ margin-bottom: 18px;
+ padding: 14px 16px;
+ border-radius: 16px;
+ border: 1px solid transparent;
+}
+
+.flash-success {
+ background: rgba(31, 108, 70, 0.12);
+ border-color: rgba(31, 108, 70, 0.2);
+ color: var(--success);
+}
+
+.flash-error {
+ background: rgba(138, 38, 48, 0.1);
+ border-color: rgba(138, 38, 48, 0.18);
+ color: var(--error);
+}
+
+.faq-list {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ margin-top: 18px;
+}
+
+.faq-list article {
+ padding: 20px;
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.58);
+ border: 1px solid var(--line);
+}
+
+.admin-login-section {
+ display: grid;
+ place-items: center;
+ padding: 56px 24px;
+}
+
+.admin-login-card {
+ width: min(520px, 100%);
+}
+
+.admin-section {
+ padding: 28px;
+}
+
+.narrow-section {
+ max-width: 1100px;
+}
+
+.section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 22px;
+}
+
+.stats-grid {
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ margin-bottom: 22px;
+}
+
+.stat-card {
+ padding: 20px;
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.72);
+ border: 1px solid var(--line);
+ display: grid;
+ gap: 8px;
+}
+
+.admin-grid {
+ display: grid;
+ grid-template-columns: 1.3fr 1fr;
+ gap: 20px;
+}
+
+.table-card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 16px;
+}
+
+.table-wrap {
+ overflow-x: auto;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+th,
+td {
+ padding: 14px 12px;
+ border-bottom: 1px solid rgba(61, 41, 26, 0.1);
+ text-align: left;
+ vertical-align: top;
+}
+
+th {
+ color: var(--muted);
+ font-size: 0.82rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.detail-list {
+ display: grid;
+ gap: 14px;
+ margin: 0;
+}
+
+.detail-list div {
+ display: grid;
+ gap: 4px;
+}
+
+.detail-list dt {
+ color: var(--muted);
+ font-size: 0.85rem;
+}
+
+.detail-list dd {
+ margin: 0;
+}
+
+@media (max-width: 1080px) {
+ .hero-section,
+ .content-grid,
+ .booking-section,
+ .pricing-panel,
+ .admin-grid,
+ .detail-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .stats-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 780px) {
+ .page-shell {
+ width: min(100% - 24px, 1240px);
+ padding-top: 16px;
+ }
+
+ .site-header {
+ border-radius: 28px;
+ padding: 18px;
+ align-items: flex-start;
+ flex-direction: column;
+ position: static;
+ }
+
+ .feature-strip,
+ .faq-list,
+ .form-grid,
+ .price-summary {
+ grid-template-columns: 1fr;
+ }
+
+ .stats-grid {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .hero-section,
+ .feature-strip,
+ .content-grid,
+ .pricing-panel,
+ .availability-section,
+ .booking-section,
+ .faq-section,
+ .admin-section {
+ padding: 20px;
+ }
+
+ .hero-copy h1,
+ .admin-login-card h1,
+ .section-header h1 {
+ font-size: 2.35rem;
+ }
+}
+
+@media (max-width: 520px) {
+ .stats-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .hero-actions,
+ .section-header {
+ flex-direction: column;
+ align-items: stretch;
+ }
+}
diff --git a/config.php b/config.php
new file mode 100644
index 0000000..29707c1
--- /dev/null
+++ b/config.php
@@ -0,0 +1,45 @@
+ [
+ 'name' => 'Fotobox Moments',
+ 'tagline' => 'Fotobox-Vermietung fuer Hochzeiten, Geburtstage und Firmenevents',
+ 'email' => 'hallo@fotobox-moments.local',
+ 'phone' => '+49 170 1234567',
+ 'website' => 'https://fotobox-moments.local',
+ 'address' => [
+ 'street' => 'Musterstrasse 12',
+ 'postal_code' => '12345',
+ 'city' => 'Musterstadt',
+ ],
+ 'bank' => [
+ 'account_holder' => 'Fotobox Moments',
+ 'iban' => 'DE00 0000 0000 0000 0000 00',
+ 'bic' => 'DEMOXXX',
+ 'bank_name' => 'Musterbank',
+ ],
+ 'tax_notice' => 'Gemaess Paragraph 19 UStG wird keine Umsatzsteuer berechnet.',
+ ],
+ 'pricing' => [
+ 'default_day_rate_cents' => 9999,
+ 'currency' => 'EUR',
+ ],
+ 'admin' => [
+ 'username' => 'admin',
+ 'password' => getenv('FOTOBOX_ADMIN_PASSWORD') ?: 'fotobox-admin',
+ ],
+ 'database' => [
+ 'credentials_file' => __DIR__ . '/mysql.local.php',
+ 'table_prefix' => 'fb_',
+ 'tables' => [
+ 'bookings' => 'bookings',
+ 'invoices' => 'invoices',
+ ],
+ ],
+ 'storage' => [
+ 'bookings' => __DIR__ . '/storage/bookings.json',
+ 'invoices' => __DIR__ . '/storage/invoices.json',
+ ],
+];
diff --git a/docs/manual-test.md b/docs/manual-test.md
new file mode 100644
index 0000000..14caa20
--- /dev/null
+++ b/docs/manual-test.md
@@ -0,0 +1,10 @@
+# Manuelle Tests
+
+1. Startseite unter `/` oeffnen und pruefen, ob Hero, Buchungsformular, FAQ und Verfuegbarkeitsliste sichtbar sind.
+2. Im Buchungsformular Start- und Enddatum setzen und kontrollieren, ob Mietdauer und Gesamtpreis automatisch mit `99,99 EUR` pro Kalendertag berechnet werden.
+3. Eine Anfrage absenden und sicherstellen, dass sie in `storage/bookings.json` erscheint oder bei aktivierter MySQL-Verbindung in `fotobox_bookings`.
+4. `/admin` oeffnen, mit `admin` und dem konfigurierten Passwort anmelden und die neue Anfrage im Dashboard sehen.
+5. Im Admin-Bereich eine manuelle Bestellung fuer einen Kunden anlegen und pruefen, ob Konflikte bei bereits geblockten Zeitraeumen erkannt werden.
+6. Eine bestehende Buchung oeffnen, Status und Zahlungsstatus anpassen und speichern.
+7. Fuer eine Buchung eine Rechnung erzeugen und kontrollieren, ob eine Rechnungsnummer sowie ein PDF unter `/admin/invoice/pdf?id=...` abrufbar sind.
+8. Optional `mysql.local.php` aktivieren und nach erneutem Start pruefen, ob die Datenbanktabellen automatisch angelegt werden.
diff --git a/docs/mysql-schema.sql b/docs/mysql-schema.sql
new file mode 100644
index 0000000..4c18482
--- /dev/null
+++ b/docs/mysql-schema.sql
@@ -0,0 +1,13 @@
+CREATE TABLE IF NOT EXISTS `fb_bookings` (
+ `id` VARCHAR(191) NOT NULL PRIMARY KEY,
+ `payload` LONGTEXT NOT NULL,
+ `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS `fb_invoices` (
+ `id` VARCHAR(191) NOT NULL PRIMARY KEY,
+ `payload` LONGTEXT NOT NULL,
+ `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/index.php b/index.php
new file mode 100644
index 0000000..e8ae079
--- /dev/null
+++ b/index.php
@@ -0,0 +1,7 @@
+ false,
+ 'host' => '127.0.0.1',
+ 'port' => 3306,
+ 'database' => 'fotobox',
+ 'username' => 'fotobox_user',
+ 'password' => 'change-me',
+ 'table_prefix' => 'fb_',
+];
diff --git a/src/Repository/JsonRepository.php b/src/Repository/JsonRepository.php
new file mode 100644
index 0000000..2612ebe
--- /dev/null
+++ b/src/Repository/JsonRepository.php
@@ -0,0 +1,98 @@
+filePath);
+ if (!is_dir($directory)) {
+ mkdir($directory, 0777, true);
+ }
+
+ if (!file_exists($this->filePath)) {
+ file_put_contents($this->filePath, json_encode([], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+ }
+ }
+
+ public function all(): array
+ {
+ return $this->read();
+ }
+
+ public function find(string $id, string $key = 'id'): ?array
+ {
+ foreach ($this->read() as $record) {
+ if (($record[$key] ?? null) === $id) {
+ return $record;
+ }
+ }
+
+ return null;
+ }
+
+ public function transaction(callable $callback): mixed
+ {
+ $handle = fopen($this->filePath, 'c+');
+ if ($handle === false) {
+ throw new RuntimeException('Datenspeicher kann nicht geoeffnet werden.');
+ }
+
+ try {
+ if (!flock($handle, LOCK_EX)) {
+ throw new RuntimeException('Datenspeicher kann nicht gesperrt werden.');
+ }
+
+ $records = $this->decodeStream($handle);
+ $result = $callback($records);
+
+ rewind($handle);
+ ftruncate($handle, 0);
+ fwrite($handle, json_encode($records, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+ fflush($handle);
+ flock($handle, LOCK_UN);
+
+ return $result;
+ } finally {
+ fclose($handle);
+ }
+ }
+
+ private function read(): array
+ {
+ $handle = fopen($this->filePath, 'c+');
+ if ($handle === false) {
+ throw new RuntimeException('Datenspeicher kann nicht gelesen werden.');
+ }
+
+ try {
+ if (!flock($handle, LOCK_SH)) {
+ throw new RuntimeException('Datenspeicher kann nicht gesperrt werden.');
+ }
+
+ $records = $this->decodeStream($handle);
+ flock($handle, LOCK_UN);
+
+ return $records;
+ } finally {
+ fclose($handle);
+ }
+ }
+
+ private function decodeStream($handle): array
+ {
+ rewind($handle);
+ $content = stream_get_contents($handle);
+ if ($content === false || trim($content) === '') {
+ return [];
+ }
+
+ $decoded = json_decode($content, true);
+ if (!is_array($decoded)) {
+ throw new RuntimeException('Datenspeicher ist ungueltig.');
+ }
+
+ return $decoded;
+ }
+}
diff --git a/src/Repository/MySqlJsonRepository.php b/src/Repository/MySqlJsonRepository.php
new file mode 100644
index 0000000..edbf771
--- /dev/null
+++ b/src/Repository/MySqlJsonRepository.php
@@ -0,0 +1,133 @@
+ensureTable();
+ }
+
+ public function all(): array
+ {
+ $statement = $this->pdo->query(
+ sprintf('SELECT payload FROM `%s` ORDER BY created_at DESC, id DESC', $this->tableName)
+ );
+
+ if ($statement === false) {
+ throw new RuntimeException('Die MySQL-Daten konnten nicht gelesen werden.');
+ }
+
+ $records = [];
+ foreach ($statement->fetchAll(PDO::FETCH_COLUMN) as $payload) {
+ $decoded = json_decode((string) $payload, true);
+ if (is_array($decoded)) {
+ $records[] = $decoded;
+ }
+ }
+
+ return $records;
+ }
+
+ public function find(string $id, string $key = 'id'): ?array
+ {
+ foreach ($this->all() as $record) {
+ if (($record[$key] ?? null) === $id) {
+ return $record;
+ }
+ }
+
+ return null;
+ }
+
+ public function transaction(callable $callback): mixed
+ {
+ $this->pdo->beginTransaction();
+
+ try {
+ $statement = $this->pdo->query(
+ sprintf('SELECT id, payload FROM `%s` FOR UPDATE', $this->tableName)
+ );
+
+ if ($statement === false) {
+ throw new RuntimeException('Die MySQL-Daten konnten nicht gesperrt werden.');
+ }
+
+ $rows = $statement->fetchAll(PDO::FETCH_ASSOC);
+ $records = [];
+ $existingIds = [];
+
+ foreach ($rows as $row) {
+ $decoded = json_decode((string) $row['payload'], true);
+ if (is_array($decoded)) {
+ $records[] = $decoded;
+ $existingIds[] = (string) $row['id'];
+ }
+ }
+
+ $result = $callback($records);
+ $this->syncRecords($records, $existingIds);
+ $this->pdo->commit();
+
+ return $result;
+ } catch (Throwable $exception) {
+ $this->pdo->rollBack();
+ throw $exception;
+ }
+ }
+
+ private function ensureTable(): void
+ {
+ $sql = sprintf(
+ 'CREATE TABLE IF NOT EXISTS `%s` (
+ id VARCHAR(191) NOT NULL PRIMARY KEY,
+ payload LONGTEXT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
+ $this->tableName
+ );
+
+ $this->pdo->exec($sql);
+ }
+
+ private function syncRecords(array $records, array $existingIds): void
+ {
+ $currentIds = [];
+
+ foreach ($records as $record) {
+ $id = (string) ($record['id'] ?? '');
+ if ($id === '') {
+ throw new RuntimeException('Ein Datensatz ohne ID kann nicht gespeichert werden.');
+ }
+
+ $currentIds[] = $id;
+ $statement = $this->pdo->prepare(
+ sprintf(
+ 'INSERT INTO `%s` (id, payload) VALUES (:id, :payload)
+ ON DUPLICATE KEY UPDATE payload = VALUES(payload), updated_at = CURRENT_TIMESTAMP',
+ $this->tableName
+ )
+ );
+
+ $statement->execute([
+ 'id' => $id,
+ 'payload' => json_encode($record, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
+ ]);
+ }
+
+ $deleteIds = array_diff($existingIds, $currentIds);
+ if ($deleteIds === []) {
+ return;
+ }
+
+ $placeholders = implode(', ', array_fill(0, count($deleteIds), '?'));
+ $delete = $this->pdo->prepare(
+ sprintf('DELETE FROM `%s` WHERE id IN (%s)', $this->tableName, $placeholders)
+ );
+ $delete->execute(array_values($deleteIds));
+ }
+}
diff --git a/src/Repository/RecordRepositoryInterface.php b/src/Repository/RecordRepositoryInterface.php
new file mode 100644
index 0000000..65e0715
--- /dev/null
+++ b/src/Repository/RecordRepositoryInterface.php
@@ -0,0 +1,12 @@
+normalizeBookingInput($input, false);
+
+ return $this->bookingRepository->transaction(function (array &$records) use ($payload): array {
+ $this->assertAvailability($records, $payload['start_date'], $payload['end_date']);
+ $booking = $this->buildBookingRecord($payload, 'customer_form');
+ array_unshift($records, $booking);
+
+ return $booking;
+ });
+ }
+
+ public function createAdminBooking(array $input): array
+ {
+ $payload = $this->normalizeBookingInput($input, true);
+
+ return $this->bookingRepository->transaction(function (array &$records) use ($payload): array {
+ if ($this->isBlockingStatus($payload['status'])) {
+ $this->assertAvailability($records, $payload['start_date'], $payload['end_date']);
+ }
+
+ $booking = $this->buildBookingRecord($payload, 'admin_manual');
+ array_unshift($records, $booking);
+
+ return $booking;
+ });
+ }
+
+ public function updateBooking(string $bookingId, array $input): array
+ {
+ return $this->bookingRepository->transaction(function (array &$records) use ($bookingId, $input): array {
+ $index = $this->findBookingIndex($records, $bookingId);
+ if ($index === null) {
+ throw new RuntimeException('Der Auftrag wurde nicht gefunden.');
+ }
+
+ $booking = $records[$index];
+ $status = (string) ($input['status'] ?? $booking['status']);
+ $paymentStatus = (string) ($input['payment_status'] ?? $booking['payment_status']);
+
+ if (!array_key_exists($status, $this->getStatusOptions())) {
+ throw new RuntimeException('Der gewaehlte Status ist ungueltig.');
+ }
+
+ if (!array_key_exists($paymentStatus, $this->getPaymentStatusOptions())) {
+ throw new RuntimeException('Der gewaehlte Zahlungsstatus ist ungueltig.');
+ }
+
+ if ($this->isBlockingStatus($status)) {
+ $this->assertAvailability($records, $booking['start_date'], $booking['end_date'], $booking['id']);
+ }
+
+ $booking['status'] = $status;
+ $booking['status_label'] = $this->getStatusOptions()[$status];
+ $booking['payment_status'] = $paymentStatus;
+ $booking['payment_status_label'] = $this->getPaymentStatusOptions()[$paymentStatus];
+ $booking['internal_notes'] = trim((string) ($input['internal_notes'] ?? $booking['internal_notes']));
+ $booking['notes_customer'] = trim((string) ($input['notes_customer'] ?? $booking['notes_customer']));
+ $booking['updated_at'] = gmdate('c');
+
+ $records[$index] = $booking;
+
+ return $booking;
+ });
+ }
+
+ public function createInvoiceForBooking(string $bookingId, array $input): string
+ {
+ $booking = $this->findBooking($bookingId);
+ if ($booking === null) {
+ throw new RuntimeException('Der Auftrag wurde nicht gefunden.');
+ }
+
+ if (!in_array($booking['status'], ['reserved', 'confirmed', 'completed'], true)) {
+ throw new RuntimeException('Fuer diesen Auftrag kann noch keine Rechnung erstellt werden.');
+ }
+
+ if (!empty($booking['invoice_id'])) {
+ return $booking['invoice_id'];
+ }
+
+ $invoice = $this->invoiceRepository->transaction(function (array &$records) use ($booking, $input): array {
+ $issueDate = gmdate('Y-m-d');
+ $dueDate = (new DateTimeImmutable($issueDate))->modify('+14 days')->format('Y-m-d');
+ $invoiceNumber = $this->nextInvoiceNumber($records);
+ $invoiceId = $this->generateId('inv');
+ $company = $this->config['company'];
+
+ $lineItems = [
+ [
+ 'label' => 'Fotobox-Miete ' . formatDate($booking['start_date']) . ' bis ' . formatDate($booking['end_date']),
+ 'quantity' => $booking['total_days'],
+ 'unit_price_cents' => $booking['price_per_day_cents'],
+ 'total_cents' => $booking['subtotal_cents'],
+ ],
+ ];
+
+ $invoice = [
+ 'id' => $invoiceId,
+ 'invoice_number' => $invoiceNumber,
+ 'booking_id' => $booking['id'],
+ 'status' => 'issued',
+ 'status_label' => 'Ausgestellt',
+ 'issue_date' => $issueDate,
+ 'due_date' => (string) ($input['due_date'] ?? $dueDate),
+ 'created_at' => gmdate('c'),
+ 'currency' => $this->config['pricing']['currency'],
+ 'company' => $company,
+ 'customer_snapshot' => $booking['customer'],
+ 'payment_method' => $booking['payment_method'],
+ 'payment_method_label' => $booking['payment_method_label'],
+ 'line_items' => $lineItems,
+ 'subtotal_cents' => $booking['subtotal_cents'],
+ 'total_cents' => $booking['subtotal_cents'],
+ 'notes' => trim((string) ($input['invoice_notes'] ?? 'Vielen Dank fuer deinen Auftrag.')),
+ ];
+
+ array_unshift($records, $invoice);
+
+ return $invoice;
+ });
+
+ $this->bookingRepository->transaction(function (array &$records) use ($bookingId, $invoice): void {
+ $index = $this->findBookingIndex($records, $bookingId);
+ if ($index === null) {
+ throw new RuntimeException('Der Auftrag wurde beim Verknuepfen der Rechnung nicht gefunden.');
+ }
+
+ $records[$index]['invoice_id'] = $invoice['id'];
+ if ($records[$index]['status'] === 'requested') {
+ $records[$index]['status'] = 'confirmed';
+ $records[$index]['status_label'] = $this->getStatusOptions()['confirmed'];
+ }
+ $records[$index]['updated_at'] = gmdate('c');
+ });
+
+ return $invoice['id'];
+ }
+
+ public function getBookings(): array
+ {
+ $bookings = $this->bookingRepository->all();
+ usort($bookings, static fn(array $a, array $b): int => strcmp($b['created_at'], $a['created_at']));
+
+ return $bookings;
+ }
+
+ public function getInvoices(): array
+ {
+ $invoices = $this->invoiceRepository->all();
+ usort($invoices, static fn(array $a, array $b): int => strcmp($b['created_at'], $a['created_at']));
+
+ return $invoices;
+ }
+
+ public function getHighlightedBookings(): array
+ {
+ $bookings = array_values(array_filter(
+ $this->getBookings(),
+ fn(array $booking): bool => $this->isBlockingStatus($booking['status'])
+ ));
+
+ usort($bookings, static fn(array $a, array $b): int => strcmp($a['start_date'], $b['start_date']));
+
+ return array_slice($bookings, 0, 4);
+ }
+
+ public function getDashboardStats(): array
+ {
+ $bookings = $this->getBookings();
+ $invoices = $this->getInvoices();
+
+ $activeRevenue = 0;
+ $openRequests = 0;
+ $confirmed = 0;
+ foreach ($bookings as $booking) {
+ if (in_array($booking['status'], ['requested', 'reserved'], true)) {
+ $openRequests++;
+ }
+
+ if (in_array($booking['status'], ['confirmed', 'completed'], true)) {
+ $confirmed++;
+ $activeRevenue += $booking['subtotal_cents'];
+ }
+ }
+
+ $unpaidInvoices = 0;
+ foreach ($invoices as $invoice) {
+ if ($invoice['status'] !== 'paid') {
+ $unpaidInvoices++;
+ }
+ }
+
+ return [
+ 'bookings_total' => count($bookings),
+ 'open_requests' => $openRequests,
+ 'confirmed_bookings' => $confirmed,
+ 'revenue_cents' => $activeRevenue,
+ 'invoice_total' => count($invoices),
+ 'invoice_open' => $unpaidInvoices,
+ ];
+ }
+
+ public function getAdminDefaults(): array
+ {
+ return [
+ 'price_per_day_cents' => $this->config['pricing']['default_day_rate_cents'],
+ 'status' => 'confirmed',
+ 'payment_status' => 'unpaid',
+ 'payment_method' => 'invoice_transfer',
+ 'delivery_mode' => 'self_pickup',
+ ];
+ }
+
+ public function findBooking(string $bookingId): ?array
+ {
+ return $this->bookingRepository->find($bookingId);
+ }
+
+ public function findInvoice(string $invoiceId): ?array
+ {
+ return $this->invoiceRepository->find($invoiceId);
+ }
+
+ public function getStatusOptions(): array
+ {
+ return [
+ 'requested' => 'Neue Anfrage',
+ 'reserved' => 'Reserviert',
+ 'confirmed' => 'Bestaetigt',
+ 'completed' => 'Abgeschlossen',
+ 'cancelled' => 'Storniert',
+ ];
+ }
+
+ public function getPaymentStatusOptions(): array
+ {
+ return [
+ 'unpaid' => 'Offen',
+ 'paid' => 'Bezahlt',
+ 'refunded' => 'Erstattet',
+ ];
+ }
+
+ private function normalizeBookingInput(array $input, bool $adminMode): array
+ {
+ $customerName = trim((string) ($input['customer_name'] ?? ''));
+ $email = trim((string) ($input['email'] ?? ''));
+ $phone = trim((string) ($input['phone'] ?? ''));
+ $street = trim((string) ($input['street'] ?? ''));
+ $postalCode = trim((string) ($input['postal_code'] ?? ''));
+ $city = trim((string) ($input['city'] ?? ''));
+ $eventType = trim((string) ($input['event_type'] ?? ''));
+ $eventLocation = trim((string) ($input['event_location'] ?? ''));
+ $company = trim((string) ($input['company'] ?? ''));
+ $startDate = trim((string) ($input['start_date'] ?? ''));
+ $endDate = trim((string) ($input['end_date'] ?? ''));
+ $paymentMethod = trim((string) ($input['payment_method'] ?? 'invoice_transfer'));
+ $deliveryMode = trim((string) ($input['delivery_mode'] ?? 'self_pickup'));
+ $notesCustomer = trim((string) ($input['notes_customer'] ?? ''));
+ $internalNotes = trim((string) ($input['internal_notes'] ?? ''));
+ $status = trim((string) ($input['status'] ?? ($adminMode ? 'confirmed' : 'requested')));
+ $paymentStatus = trim((string) ($input['payment_status'] ?? 'unpaid'));
+ $pricePerDay = (int) ($input['price_per_day_cents'] ?? $this->config['pricing']['default_day_rate_cents']);
+
+ foreach ([
+ 'Name' => $customerName,
+ 'E-Mail' => $email,
+ 'Telefon' => $phone,
+ 'Strasse' => $street,
+ 'PLZ' => $postalCode,
+ 'Ort' => $city,
+ 'Startdatum' => $startDate,
+ 'Enddatum' => $endDate,
+ ] as $label => $value) {
+ if ($value === '') {
+ throw new RuntimeException($label . ' ist ein Pflichtfeld.');
+ }
+ }
+
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ throw new RuntimeException('Bitte gib eine gueltige E-Mail-Adresse an.');
+ }
+
+ if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
+ throw new RuntimeException('Bitte waehle gueltige Mietdaten aus.');
+ }
+
+ $totalDays = $this->calculateRentalDays($startDate, $endDate);
+ if ($totalDays < 1) {
+ throw new RuntimeException('Das Mietende darf nicht vor dem Mietbeginn liegen.');
+ }
+
+ if (!array_key_exists($status, $this->getStatusOptions())) {
+ throw new RuntimeException('Der Buchungsstatus ist ungueltig.');
+ }
+
+ if (!array_key_exists($paymentStatus, $this->getPaymentStatusOptions())) {
+ throw new RuntimeException('Der Zahlungsstatus ist ungueltig.');
+ }
+
+ $paymentLabels = [
+ 'invoice_transfer' => 'Ueberweisung auf Rechnung',
+ 'paypal' => 'PayPal',
+ ];
+
+ $deliveryLabels = [
+ 'self_pickup' => 'Selbstabholung',
+ 'delivery_setup' => 'Lieferung und Aufbau',
+ 'on_site_support' => 'Lieferung, Aufbau und Vor-Ort-Betreuung',
+ ];
+
+ if (!array_key_exists($paymentMethod, $paymentLabels)) {
+ throw new RuntimeException('Die gewaehlte Zahlungsart ist ungueltig.');
+ }
+
+ if (!array_key_exists($deliveryMode, $deliveryLabels)) {
+ throw new RuntimeException('Die gewaehlte Lieferart ist ungueltig.');
+ }
+
+ if ($pricePerDay < 0) {
+ throw new RuntimeException('Der Tagespreis ist ungueltig.');
+ }
+
+ return [
+ 'customer_name' => $customerName,
+ 'company' => $company,
+ 'email' => $email,
+ 'phone' => $phone,
+ 'street' => $street,
+ 'postal_code' => $postalCode,
+ 'city' => $city,
+ 'event_type' => $eventType,
+ 'event_location' => $eventLocation,
+ 'start_date' => $startDate,
+ 'end_date' => $endDate,
+ 'total_days' => $totalDays,
+ 'payment_method' => $paymentMethod,
+ 'payment_method_label' => $paymentLabels[$paymentMethod],
+ 'delivery_mode' => $deliveryMode,
+ 'delivery_mode_label' => $deliveryLabels[$deliveryMode],
+ 'notes_customer' => $notesCustomer,
+ 'internal_notes' => $internalNotes,
+ 'status' => $status,
+ 'status_label' => $this->getStatusOptions()[$status],
+ 'payment_status' => $paymentStatus,
+ 'payment_status_label' => $this->getPaymentStatusOptions()[$paymentStatus],
+ 'price_per_day_cents' => $pricePerDay,
+ 'subtotal_cents' => $totalDays * $pricePerDay,
+ ];
+ }
+
+ private function buildBookingRecord(array $payload, string $source): array
+ {
+ $now = gmdate('c');
+
+ return [
+ 'id' => $this->generateId('ord'),
+ 'reference' => 'FB-' . gmdate('Ymd') . '-' . strtoupper(substr(bin2hex(random_bytes(3)), 0, 6)),
+ 'source' => $source,
+ 'status' => $payload['status'],
+ 'status_label' => $payload['status_label'],
+ 'payment_status' => $payload['payment_status'],
+ 'payment_status_label' => $payload['payment_status_label'],
+ 'payment_method' => $payload['payment_method'],
+ 'payment_method_label' => $payload['payment_method_label'],
+ 'delivery_mode' => $payload['delivery_mode'],
+ 'delivery_mode_label' => $payload['delivery_mode_label'],
+ 'start_date' => $payload['start_date'],
+ 'end_date' => $payload['end_date'],
+ 'total_days' => $payload['total_days'],
+ 'price_per_day_cents' => $payload['price_per_day_cents'],
+ 'subtotal_cents' => $payload['subtotal_cents'],
+ 'notes_customer' => $payload['notes_customer'],
+ 'internal_notes' => $payload['internal_notes'],
+ 'invoice_id' => null,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ 'customer' => [
+ 'name' => $payload['customer_name'],
+ 'company' => $payload['company'],
+ 'email' => $payload['email'],
+ 'phone' => $payload['phone'],
+ 'street' => $payload['street'],
+ 'postal_code' => $payload['postal_code'],
+ 'city' => $payload['city'],
+ 'event_type' => $payload['event_type'],
+ 'event_location' => $payload['event_location'],
+ ],
+ ];
+ }
+
+ private function assertAvailability(array $records, string $startDate, string $endDate, ?string $ignoreId = null): void
+ {
+ foreach ($records as $record) {
+ if ($ignoreId !== null && $record['id'] === $ignoreId) {
+ continue;
+ }
+
+ if (!$this->isBlockingStatus((string) $record['status'])) {
+ continue;
+ }
+
+ if ($startDate <= $record['end_date'] && $endDate >= $record['start_date']) {
+ throw new RuntimeException(
+ 'Die Fotobox ist im gewaehlten Zeitraum bereits blockiert. Bitte waehle einen anderen Termin.'
+ );
+ }
+ }
+ }
+
+ private function calculateRentalDays(string $startDate, string $endDate): int
+ {
+ $start = new DateTimeImmutable($startDate);
+ $end = new DateTimeImmutable($endDate);
+
+ return (int) $start->diff($end)->format('%r%a') + 1;
+ }
+
+ private function nextInvoiceNumber(array $records): string
+ {
+ $year = gmdate('Y');
+ $count = 0;
+ foreach ($records as $record) {
+ if (str_starts_with((string) ($record['invoice_number'] ?? ''), 'RE-' . $year . '-')) {
+ $count++;
+ }
+ }
+
+ return sprintf('RE-%s-%04d', $year, $count + 1);
+ }
+
+ private function findBookingIndex(array $records, string $bookingId): ?int
+ {
+ foreach ($records as $index => $record) {
+ if (($record['id'] ?? null) === $bookingId) {
+ return $index;
+ }
+ }
+
+ return null;
+ }
+
+ private function isBlockingStatus(string $status): bool
+ {
+ return in_array($status, self::BLOCKING_STATUSES, true);
+ }
+
+ private function generateId(string $prefix): string
+ {
+ return $prefix . '_' . strtolower(bin2hex(random_bytes(6)));
+ }
+}
diff --git a/src/Services/InvoicePdfService.php b/src/Services/InvoicePdfService.php
new file mode 100644
index 0000000..4ed3cd7
--- /dev/null
+++ b/src/Services/InvoicePdfService.php
@@ -0,0 +1,127 @@
+ $line !== null));
+ $lines[] = '';
+ $lines[] = 'Zahlungsart: ' . $invoice['payment_method_label'];
+ $lines[] = 'Gesamtbetrag: ' . formatCurrency($invoice['total_cents']);
+ $lines[] = $company['tax_notice'];
+ $lines[] = '';
+ $lines[] = 'Bankverbindung:';
+ $lines[] = $company['bank']['account_holder'];
+ $lines[] = 'IBAN: ' . $company['bank']['iban'];
+ $lines[] = 'BIC: ' . $company['bank']['bic'];
+ $lines[] = $company['bank']['bank_name'];
+ $lines[] = '';
+ $lines[] = $invoice['notes'];
+
+ $operations = [];
+ $y = 800;
+ foreach ($lines as $line) {
+ $fontSize = str_starts_with((string) $line, 'Rechnung:') ? 16 : 11;
+ $operations[] = sprintf(
+ 'BT /F1 %d Tf 1 0 0 1 50 %d Tm (%s) Tj ET',
+ $fontSize,
+ $y,
+ $this->escapePdfText((string) $line)
+ );
+ $y -= $fontSize === 16 ? 24 : 16;
+ }
+
+ $stream = implode("\n", $operations);
+
+ return $this->buildPdf($stream);
+ }
+
+ private function buildPdf(string $stream): string
+ {
+ $objects = [];
+ $objects[] = '<< /Type /Catalog /Pages 2 0 R >>';
+ $objects[] = '<< /Type /Pages /Kids [3 0 R] /Count 1 >>';
+ $objects[] = '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>';
+ $objects[] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>';
+ $objects[] = '<< /Length ' . strlen($stream) . ' >>' . "\nstream\n" . $stream . "\nendstream";
+
+ $pdf = "%PDF-1.4\n";
+ $offsets = [0];
+
+ foreach ($objects as $index => $object) {
+ $offsets[] = strlen($pdf);
+ $pdf .= ($index + 1) . " 0 obj\n" . $object . "\nendobj\n";
+ }
+
+ $xrefOffset = strlen($pdf);
+ $pdf .= 'xref' . "\n";
+ $pdf .= '0 ' . (count($objects) + 1) . "\n";
+ $pdf .= "0000000000 65535 f \n";
+
+ for ($i = 1; $i <= count($objects); $i++) {
+ $pdf .= sprintf('%010d 00000 n ', $offsets[$i]) . "\n";
+ }
+
+ $pdf .= 'trailer << /Size ' . (count($objects) + 1) . ' /Root 1 0 R >>' . "\n";
+ $pdf .= 'startxref' . "\n" . $xrefOffset . "\n%%EOF";
+
+ return $pdf;
+ }
+
+ private function escapePdfText(string $value): string
+ {
+ $value = str_replace('\\', '\\\\', $value);
+ $value = str_replace('(', '\\(', $value);
+ $value = str_replace(')', '\\)', $value);
+
+ $replacements = [
+ 'ä' => 'ae',
+ 'ö' => 'oe',
+ 'ü' => 'ue',
+ 'Ä' => 'Ae',
+ 'Ö' => 'Oe',
+ 'Ü' => 'Ue',
+ 'ß' => 'ss',
+ '€' => 'EUR',
+ ];
+
+ return strtr($value, $replacements);
+ }
+}
diff --git a/src/Support/functions.php b/src/Support/functions.php
new file mode 100644
index 0000000..ebdf9be
--- /dev/null
+++ b/src/Support/functions.php
@@ -0,0 +1,72 @@
+format('d.m.Y');
+}
diff --git a/src/bootstrap.php b/src/bootstrap.php
new file mode 100644
index 0000000..b71bb9f
--- /dev/null
+++ b/src/bootstrap.php
@@ -0,0 +1,269 @@
+ PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ ]);
+
+ return [
+ new MySqlJsonRepository($pdo, resolveTableName($tablePrefix, $config['database']['tables']['bookings'])),
+ new MySqlJsonRepository($pdo, resolveTableName($tablePrefix, $config['database']['tables']['invoices'])),
+ ];
+ } catch (Throwable $exception) {
+ error_log('MySQL-Verbindung fehlgeschlagen, JSON-Fallback aktiv: ' . $exception->getMessage());
+ }
+ }
+ }
+
+ return [
+ new JsonRepository($config['storage']['bookings']),
+ new JsonRepository($config['storage']['invoices']),
+ ];
+}
+
+function resolveTableName(string $prefix, string $table): string
+{
+ return $prefix . $table;
+}
+
+function handlePublicBooking(BookingService $bookingService): void
+{
+ try {
+ $bookingService->createPublicBooking($_POST);
+ flash('success', 'Deine Anfrage wurde gespeichert. Wir melden uns zeitnah mit der Bestaetigung und allen Details.');
+ } catch (Throwable $exception) {
+ flash('error', $exception->getMessage());
+ flash('old', $_POST);
+ }
+
+ redirect('/');
+}
+
+function handleAdminLogin(array $adminConfig): void
+{
+ $username = trim((string) ($_POST['username'] ?? ''));
+ $password = (string) ($_POST['password'] ?? '');
+
+ if ($username === $adminConfig['username'] && hash_equals($adminConfig['password'], $password)) {
+ $_SESSION['admin_authenticated'] = true;
+ flash('success', 'Admin-Bereich geoeffnet.');
+ } else {
+ flash('error', 'Die Admin-Zugangsdaten sind nicht korrekt.');
+ }
+
+ redirect('/admin');
+}
+
+function handleAdminLogout(): void
+{
+ unset($_SESSION['admin_authenticated']);
+ flash('success', 'Du wurdest aus dem Admin-Bereich abgemeldet.');
+ redirect('/admin');
+}
+
+function handleAdminRequest(string $path, string $method, BookingService $bookingService): void
+{
+ if (!isAdminAuthenticated()) {
+ renderAdminLogin();
+ return;
+ }
+
+ if ($method === 'POST' && $path === '/admin/create') {
+ try {
+ $bookingService->createAdminBooking($_POST);
+ flash('success', 'Die Bestellung wurde fuer den Kunden angelegt.');
+ } catch (Throwable $exception) {
+ flash('error', $exception->getMessage());
+ flash('admin_old', $_POST);
+ }
+
+ redirect('/admin/create');
+ }
+
+ if ($method === 'POST' && $path === '/admin/order/update') {
+ try {
+ $bookingService->updateBooking((string) ($_POST['booking_id'] ?? ''), $_POST);
+ flash('success', 'Der Auftrag wurde aktualisiert.');
+ } catch (Throwable $exception) {
+ flash('error', $exception->getMessage());
+ }
+
+ redirect('/admin/order?id=' . urlencode((string) ($_POST['booking_id'] ?? '')));
+ }
+
+ if ($method === 'POST' && $path === '/admin/order/invoice') {
+ try {
+ $invoiceId = $bookingService->createInvoiceForBooking((string) ($_POST['booking_id'] ?? ''), $_POST);
+ flash('success', 'Die Rechnung wurde erstellt.');
+ redirect('/admin/order?id=' . urlencode((string) ($_POST['booking_id'] ?? '')) . '&invoice=' . urlencode($invoiceId));
+ } catch (Throwable $exception) {
+ flash('error', $exception->getMessage());
+ redirect('/admin/order?id=' . urlencode((string) ($_POST['booking_id'] ?? '')));
+ }
+ }
+
+ if ($path === '/admin/create') {
+ renderAdminCreate($bookingService);
+ return;
+ }
+
+ if ($path === '/admin/order') {
+ renderAdminOrder($bookingService);
+ return;
+ }
+
+ renderAdminDashboard($bookingService);
+}
+
+function handleInvoicePdf(BookingService $bookingService, InvoicePdfService $invoicePdfService): void
+{
+ $invoiceId = (string) ($_GET['id'] ?? '');
+ $invoice = $bookingService->findInvoice($invoiceId);
+
+ if ($invoice === null) {
+ http_response_code(404);
+ echo 'Rechnung nicht gefunden.';
+ return;
+ }
+
+ $pdf = $invoicePdfService->render($invoice);
+
+ header('Content-Type: application/pdf');
+ header('Content-Disposition: inline; filename="' . $invoice['invoice_number'] . '.pdf"');
+ header('Content-Length: ' . strlen($pdf));
+
+ echo $pdf;
+}
+
+function renderHome(BookingService $bookingService, array $config): void
+{
+ render('home', [
+ 'pageTitle' => 'Fotobox mieten',
+ 'config' => $config,
+ 'flashSuccess' => flash('success'),
+ 'flashError' => flash('error'),
+ 'old' => flash('old') ?? [],
+ 'bookings' => $bookingService->getHighlightedBookings(),
+ ]);
+}
+
+function renderAdminLogin(): void
+{
+ render('admin/login', [
+ 'pageTitle' => 'Admin Login',
+ 'flashSuccess' => flash('success'),
+ 'flashError' => flash('error'),
+ ]);
+}
+
+function renderAdminDashboard(BookingService $bookingService): void
+{
+ render('admin/dashboard', [
+ 'pageTitle' => 'Admin Dashboard',
+ 'flashSuccess' => flash('success'),
+ 'flashError' => flash('error'),
+ 'stats' => $bookingService->getDashboardStats(),
+ 'bookings' => $bookingService->getBookings(),
+ 'invoices' => $bookingService->getInvoices(),
+ ]);
+}
+
+function renderAdminCreate(BookingService $bookingService): void
+{
+ render('admin/create', [
+ 'pageTitle' => 'Kundenbestellung anlegen',
+ 'flashSuccess' => flash('success'),
+ 'flashError' => flash('error'),
+ 'old' => flash('admin_old') ?? [],
+ 'defaults' => $bookingService->getAdminDefaults(),
+ ]);
+}
+
+function renderAdminOrder(BookingService $bookingService): void
+{
+ $bookingId = (string) ($_GET['id'] ?? '');
+ $booking = $bookingService->findBooking($bookingId);
+
+ if ($booking === null) {
+ http_response_code(404);
+ echo 'Auftrag nicht gefunden.';
+ return;
+ }
+
+ render('admin/order', [
+ 'pageTitle' => 'Auftrag ' . $booking['reference'],
+ 'flashSuccess' => flash('success'),
+ 'flashError' => flash('error'),
+ 'booking' => $booking,
+ 'invoice' => $booking['invoice_id'] ? $bookingService->findInvoice($booking['invoice_id']) : null,
+ 'statusOptions' => $bookingService->getStatusOptions(),
+ 'paymentOptions' => $bookingService->getPaymentStatusOptions(),
+ ]);
+}
diff --git a/storage/bookings.json b/storage/bookings.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/storage/bookings.json
@@ -0,0 +1 @@
+[]
diff --git a/storage/invoices.json b/storage/invoices.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/storage/invoices.json
@@ -0,0 +1 @@
+[]
diff --git a/views/admin/create.php b/views/admin/create.php
new file mode 100644
index 0000000..f8d66c6
--- /dev/null
+++ b/views/admin/create.php
@@ -0,0 +1,120 @@
+
+
+
+
+ = h((string) $flashSuccess) ?>
+
+
+ = h((string) $flashError) ?>
+
+
+
+
diff --git a/views/admin/dashboard.php b/views/admin/dashboard.php
new file mode 100644
index 0000000..6da4c58
--- /dev/null
+++ b/views/admin/dashboard.php
@@ -0,0 +1,119 @@
+
+
+
+
+ = h((string) $flashSuccess) ?>
+
+
+ = h((string) $flashError) ?>
+
+
+
+
+ Alle Auftraege
+ = h((string) $stats['bookings_total']) ?>
+
+
+ Offene Anfragen
+ = h((string) $stats['open_requests']) ?>
+
+
+ Bestaetigte Buchungen
+ = h((string) $stats['confirmed_bookings']) ?>
+
+
+ Planumsatz
+ = h(formatCurrency((int) $stats['revenue_cents'])) ?>
+
+
+ Rechnungen
+ = h((string) $stats['invoice_total']) ?>
+
+
+ Offene Rechnungen
+ = h((string) $stats['invoice_open']) ?>
+
+
+
+
+
+
+
+
+
+
+ Referenz
+ Kunde
+ Termin
+ Status
+ Zahlung
+ Preis
+
+
+
+
+
+ = h($booking['reference']) ?>
+ = h($booking['customer']['name']) ?>
+ = h(formatDate($booking['start_date'])) ?> - = h(formatDate($booking['end_date'])) ?>
+ = h($booking['status_label']) ?>
+ = h($booking['payment_status_label']) ?>
+ = h(formatCurrency((int) $booking['subtotal_cents'])) ?>
+
+
+
+
+ Noch keine Auftraege vorhanden.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nummer
+ Auftrag
+ Faellig
+ Zahlungsart
+ Gesamt
+
+
+
+
+
+ = h($invoice['invoice_number']) ?>
+ = h($invoice['booking_id']) ?>
+ = h(formatDate($invoice['due_date'])) ?>
+ = h($invoice['payment_method_label']) ?>
+ = h(formatCurrency((int) $invoice['total_cents'])) ?>
+
+
+
+
+ Noch keine Rechnungen erstellt.
+
+
+
+
+
+
+
+
diff --git a/views/admin/login.php b/views/admin/login.php
new file mode 100644
index 0000000..7a7a31f
--- /dev/null
+++ b/views/admin/login.php
@@ -0,0 +1,26 @@
+
+
+
Verwaltung
+
Admin-Login
+
Hier verwaltest du Anfragen, Kundenbestellungen und Rechnungen.
+
+
+
= h((string) $flashSuccess) ?>
+
+
+
= h((string) $flashError) ?>
+
+
+
+
+ Benutzername
+
+
+
+ Passwort
+
+
+ Anmelden
+
+
+
diff --git a/views/admin/order.php b/views/admin/order.php
new file mode 100644
index 0000000..c8d8969
--- /dev/null
+++ b/views/admin/order.php
@@ -0,0 +1,105 @@
+
+
+
+
+ = h((string) $flashSuccess) ?>
+
+
+ = h((string) $flashError) ?>
+
+
+
+
+ Kundendaten
+
+
Name = h($booking['customer']['name']) ?>
+
Firma = h($booking['customer']['company'] ?: '-') ?>
+
E-Mail = h($booking['customer']['email']) ?>
+
Telefon = h($booking['customer']['phone']) ?>
+
Adresse = h($booking['customer']['street']) ?>, = h($booking['customer']['postal_code']) ?> = h($booking['customer']['city']) ?>
+
Anlass = h($booking['customer']['event_type'] ?: '-') ?>
+
Ort = h($booking['customer']['event_location'] ?: '-') ?>
+
Mietzeitraum = h(formatDate($booking['start_date'])) ?> bis = h(formatDate($booking['end_date'])) ?>
+
Leistung = h($booking['delivery_mode_label']) ?>
+
Zahlungsart = h($booking['payment_method_label']) ?>
+
Gesamt = h(formatCurrency((int) $booking['subtotal_cents'])) ?>
+
+
+
+
+ Verwaltung
+
+
+
+ Status
+
+ $label): ?>
+ >= h($label) ?>
+
+
+
+
+ Zahlungsstatus
+
+ $label): ?>
+ >= h($label) ?>
+
+
+
+
+ Kundennotiz
+ = h((string) $booking['notes_customer']) ?>
+
+
+ Interne Notiz
+ = h((string) $booking['internal_notes']) ?>
+
+ Aenderungen speichern
+
+
+
+
+
+
+ Rechnung
+
+ Rechnung = h($invoice['invoice_number']) ?> ist erstellt.
+
+ Faellig am = h(formatDate($invoice['due_date'])) ?>
+ Gesamtbetrag = h(formatCurrency((int) $invoice['total_cents'])) ?>
+ Zahlungsart = h($invoice['payment_method_label']) ?>
+
+ PDF oeffnen
+
+ Fuer diesen Auftrag wurde noch keine Rechnung erstellt.
+
+
+
+ Faelligkeitsdatum
+
+
+
+ Rechnungsnotiz
+ Vielen Dank fuer deinen Auftrag.
+
+ Rechnung erstellen
+
+
+
+
+ Systeminfos
+
+
ID = h($booking['id']) ?>
+
Quelle = h($booking['source']) ?>
+
Erstellt = h($booking['created_at']) ?>
+
Aktualisiert = h($booking['updated_at']) ?>
+
+
+
+
diff --git a/views/home.php b/views/home.php
new file mode 100644
index 0000000..c1f5603
--- /dev/null
+++ b/views/home.php
@@ -0,0 +1,272 @@
+
+
+
+
Fotobox-Vermietung neu gedacht
+
Die Fotobox fuer Hochzeiten, Geburtstage und Firmenevents
+
+ Unkompliziert mieten, schnell aufbauen, kinderleicht bedienen und alle Bilder direkt digital sichern.
+ Unsere Fotobox verbindet hochwertige Technik mit einem klaren Buchungsprozess ohne Shop-Chaos.
+
+
+
+ Spiegelreflexkamera, Studioblitz und Softbox
+ WLAN-Download direkt aufs Handy
+ Selbstabholung oder Lieferung mit Aufbau
+ 99,99 EUR pro Kalendertag
+
+
+
+
+ Eventbereit in wenigen Minuten
+ Abholung ab 17:00 Uhr
+
+
+
+ Digitale Galerie inklusive
+ Rueckgabe bis 13:00 Uhr
+
+
+
+
+
+
+ Technik, die nicht zickt
+ Spiegelreflexkamera, Bildschirm und Studioblitz mit Softbox sorgen fuer helle, scharfe Bilder bei jedem Anlass.
+
+
+ Direkt aufs Handy
+ Per WLAN koennen deine Gaeste ihre Fotos sofort laden und teilen. Nach dem Event gibt es alle Bilder digital.
+
+
+ Flexible Logistik
+ Selbst abholen, liefern lassen oder auf Wunsch mit Aufbau und Vor-Ort-Unterstuetzung buchen.
+
+
+
+
+
+
Ablauf
+
So laeuft die Miete ab
+
+
+ 1. Zeitraum waehlen
+ Du waehlst Mietbeginn und Mietende und siehst sofort die voraussichtlichen Kosten.
+
+
+ 2. Leistung festlegen
+ Selbstabholung, Lieferung mit Aufbau oder Vor-Ort-Betreuung passend zu deinem Event.
+
+
+ 3. Anfrage absenden
+ Wir speichern alle Kundendaten und bereiten auf Wunsch direkt die Rechnungsabwicklung vor.
+
+
+ 4. Fotos geniessen
+ Am Eventtag steht die Box bereit und danach bekommst du alle Bilder digital zur Weitergabe.
+
+
+
+
+
Ausstattung
+
Alles drin fuer einen reibungslosen Party-Hit
+
+ Spiegelreflexkamera fuer gestochen scharfe Aufnahmen
+ Bildschirm mit einfacher Bedienung per Touch
+ Studioblitz mit grosser Softbox fuer gleichmaessiges Licht
+ Digitale Uebergabe aller Fotos nach dem Event
+ Optionaler Hintergrund und Betreuung vor Ort
+
+
+
+
+
+
+
Preis und Logistik
+
Transparent statt versteckt
+
+ Standardmaessig berechnen wir = h(formatCurrency($dayRate)) ?> pro Kalendertag.
+ Mietbeginn und Mietende zaehlen beide mit. Selbstabholung spart Zeit in der Abstimmung,
+ Lieferung und Aufbau machen es vor Ort noch entspannter.
+
+
+
+
+ Tagespreis
+ = h(formatCurrency($dayRate)) ?>
+
+
+ Zahlungsarten
+ Rechnung / Ueberweisung und PayPal
+
+
+ Servicefenster
+ Abholung ab 17:00 Uhr, Rueckgabe bis 13:00 Uhr
+
+
+
+
+
+
+
Online-Verfuegbarkeit
+
Bereits reservierte Zeitraeume
+
Diese Liste zeigt geblockte oder bestaetigte Termine aus dem Verwaltungssystem.
+
+
+
+
+ Aktuell sind keine festen Reservierungen hinterlegt.
+ Du kannst direkt eine neue Anfrage senden.
+
+
+
+
+ = h($booking['reference']) ?>
+ = h(formatDate($booking['start_date'])) ?> bis = h(formatDate($booking['end_date'])) ?>
+ = h($booking['status_label']) ?>
+
+
+
+
+
+
+
+
Buchungsanfrage
+
Fotobox jetzt anfragen
+
+ Zwei Termine auswaehlen, Wunschleistung festlegen und Kundendaten hinterlegen.
+ Die Verwaltung kann deine Anfrage danach direkt bestaetigen, als Kundenbestellung uebernehmen und eine Rechnung erzeugen.
+
+
+ Kontakt fuer Rueckfragen
+ = h($company['email']) ?>
+ = h($company['phone']) ?>
+
+
+
+
+
+
+
+
FAQ
+
Wichtige Fragen auf einen Blick
+
+
+
+ Wie schnell ist die Fotobox einsatzbereit?
+ Durch den einfachen Aufbau und die kurze Einweisung ist die Box in wenigen Minuten startklar.
+
+
+ Bekommen wir alle Fotos?
+ Ja. Alle Bilder werden nach dem Event digital zur Verfuegung gestellt. Auf Wunsch koennen Gaeste sie schon vor Ort aufs Handy laden.
+
+
+ Kann ich auch Lieferung und Betreuung buchen?
+ Ja. Du kannst zwischen Selbstabholung, Lieferung mit Aufbau oder zusaetzlicher Vor-Ort-Unterstuetzung waehlen.
+
+
+ Ist PayPal schon moeglich?
+ Ja, PayPal ist als Zahlungsart im Ablauf vorgesehen. Sobald du echte MySQL- und PayPal-Daten hinterlegst, laesst sich der operative Betrieb direkt anbinden.
+
+
+
diff --git a/views/layout.php b/views/layout.php
new file mode 100644
index 0000000..7eb77d6
--- /dev/null
+++ b/views/layout.php
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+ = h($metaTitle) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+