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 @@ +
+
+
+

Manuelle Buchung

+

Bestellung fuer Kunden anlegen

+
+ Zurueck zum Dashboard +
+ + +
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + +
+ + +
+
+ Mietdauer + Noch nicht gewaehlt +
+
+ Gesamtpreis + +
+
+ +
+
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 @@ +
+
+
+

Dashboard

+

Anfragen, Buchungen und Rechnungen

+
+ Bestellung fuer Kunden anlegen +
+ + +
+ + +
+ + +
+
+ Alle Auftraege + +
+
+ Offene Anfragen + +
+
+ Bestaetigte Buchungen + +
+
+ Planumsatz + +
+
+ Rechnungen + +
+
+ Offene Rechnungen + +
+
+ +
+
+
+

Auftraege

+ Eintraege +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ReferenzKundeTerminStatusZahlungPreis
-
Noch keine Auftraege vorhanden.
+
+
+ +
+
+

Rechnungen

+ Eintraege +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
NummerAuftragFaelligZahlungsartGesamt
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.

+ + +
+ + +
+ + +
+ + + +
+
+
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 @@ +
+
+
+

Auftragsdetail

+

+
+ Zurueck zum Dashboard +
+ + +
+ + +
+ + +
+
+

Kundendaten

+
+
Name
+
Firma
+
E-Mail
+
Telefon
+
Adresse
,
+
Anlass
+
Ort
+
Mietzeitraum
bis
+
Leistung
+
Zahlungsart
+
Gesamt
+
+
+ +
+

Verwaltung

+
+ + + + + + +
+
+
+ +
+
+

Rechnung

+ +

Rechnung ist erstellt.

+
    +
  • Faellig am
  • +
  • Gesamtbetrag
  • +
  • Zahlungsart
  • +
+ PDF oeffnen + +

Fuer diesen Auftrag wurde noch keine Rechnung erstellt.

+
+ + + + +
+ +
+
+

Systeminfos

+
+
ID
+
Quelle
+
Erstellt
+
Aktualisiert
+
+
+
+
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. +

+ + +
+
+
+ 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. + 1. Zeitraum waehlen + Du waehlst Mietbeginn und Mietende und siehst sofort die voraussichtlichen Kosten. +
  2. +
  3. + 2. Leistung festlegen + Selbstabholung, Lieferung mit Aufbau oder Vor-Ort-Betreuung passend zu deinem Event. +
  4. +
  5. + 3. Anfrage absenden + Wir speichern alle Kundendaten und bereiten auf Wunsch direkt die Rechnungsabwicklung vor. +
  6. +
  7. + 4. Fotos geniessen + Am Eventtag steht die Box bereit und danach bekommst du alle Bilder digital zur Weitergabe. +
  8. +
+
+
+

Ausstattung

+

Alles drin fuer einen reibungslosen Party-Hit

+ +
+
+ +
+
+

Preis und Logistik

+

Transparent statt versteckt

+

+ Standardmaessig berechnen wir pro Kalendertag. + Mietbeginn und Mietende zaehlen beide mit. Selbstabholung spart Zeit in der Abstimmung, + Lieferung und Aufbau machen es vor Ort noch entspannter. +

+
+
+
+ Tagespreis + +
+
+ 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. +
+ + +
+ + bis + +
+ +
+
+ +
+
+

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 + + +
+
+
+ +
+ + +
+ + +
+
+ + + + + + + + + + + + + +
+ + +
+
+ Mietdauer + Noch nicht gewaehlt +
+
+ Gesamtpreis + +
+
+ + +

Mit dem Absenden wird eine verwaltbare Anfrage angelegt. Die finale Bestaetigung erfolgt durch den Verwalter.

+
+
+
+ +
+
+

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) ?> + + + + + +
+ + +
+ +
+
+ +