From a2c95c70e79721dbe4f1417316c02fa804e21870 Mon Sep 17 00:00:00 2001 From: Julia Wehden Date: Thu, 19 Mar 2026 16:21:55 +0100 Subject: [PATCH] feat: Equipment-System, Buchungsbearbeitung, Kundenadresse, LexOffice-Fix - Vintage Modell hinzugefuegt - Equipment Multi-Select (Neue Buchung + Bearbeitung) - Kundenadresse in Formularen - Bearbeiten-Seite fuer Buchungen - Abbau-Zeiten in Formularen und Uebersicht - Vertrag PDF nur bei Privatkunden - LexOffice Kontakt-Erstellung Fix (BUSINESS) - Zurueck-Pfeil auf Touren-Seite --- .env.development-flags | 66 ++ AUTOMATION-SYSTEM.md | 379 ++++++++++++ DEVELOPMENT-MODE.md | 184 ++++++ LEXOFFICE-SETUP.md | 89 +++ .../berlin/._nf_form_11_11_2025_Fotobox.nff | Bin 547 -> 0 bytes .../._nf_form_11_11_2025_Fotospiegel.nff | Bin 547 -> 0 bytes .../hamburg/._nf_form_11_11_2025_Fotobox.nff | Bin 547 -> 0 bytes ..._nf_form_11_11_2025_Fotobox__Nostalgie.nff | Bin 547 -> 0 bytes .../._nf_form_11_11_2025_Fotospiegel.nff | Bin 547 -> 0 bytes .../kiel/._nf_form_11_11_2025_Fotobox.nff | Bin 541 -> 0 bytes ..._nf_form_11_11_2025_Fotobox__Nostalgie.nff | Bin 541 -> 0 bytes .../kiel/._nf_form_11_11_2025_Fotospiegel.nff | Bin 541 -> 0 bytes .../luebeck/._nf_form_11_11_2025_Fotobox.nff | Bin 547 -> 0 bytes .../._nf_form_11_11_2025_Fotospiegel.nff | Bin 547 -> 0 bytes ...form_11_11_2025_Fotospiegel__Nostalgie.nff | Bin 547 -> 0 bytes .../rostock/._nf_form_11_11_2025_Fotobox.nff | Bin 547 -> 0 bytes ..._nf_form_11_11_2025_Fotobox__Nostalgie.nff | Bin 547 -> 0 bytes .../._nf_form_11_11_2025_Fotospiegel.nff | Bin 547 -> 0 bytes app/api/admin/sync-all-bookings/route.ts | 87 +++ app/api/admin/sync-emails/route.ts | 108 ++++ app/api/admin/test-automation/route.ts | 96 +++ app/api/bookings/[id]/confirm/route.ts | 175 ++++++ .../bookings/[id]/confirmation-pdf/route.ts | 54 ++ app/api/bookings/[id]/contract-pdf/route.ts | 73 +++ app/api/bookings/[id]/debug/route.ts | 111 ++++ app/api/bookings/[id]/quotation-pdf/route.ts | 54 ++ app/api/bookings/[id]/route.ts | 88 ++- app/api/bookings/[id]/sign/route.ts | 165 +++++ app/api/bookings/create/route.ts | 9 + app/api/bookings/route.ts | 75 ++- .../driver/tour-stops/[id]/status/route.ts | 76 +++ app/api/driver/tours/[id]/route.ts | 62 ++ app/api/equipment/route.ts | 30 + app/api/tours/route.ts | 38 ++ app/dashboard/bookings/[id]/edit/page.tsx | 311 ++++++++++ app/dashboard/bookings/[id]/page.tsx | 237 ++++++- app/dashboard/page.tsx | 46 ++ app/dashboard/tours/new/page.tsx | 377 +++++++++++ app/dashboard/tours/page.tsx | 585 ++++++------------ app/driver/tours/[id]/page.tsx | 457 ++++++++++++++ components/BookingAutomationPanel.tsx | 448 ++++++++++++++ components/DashboardSidebar.tsx | 10 +- components/NewBookingForm.tsx | 119 +++- cron-email-sync.ts | 81 +++ lib/booking-automation.ts | 189 ++++++ lib/distance-calculator.ts | 88 +++ lib/email-service.ts | 364 +++++++++-- lib/email-sync.ts | 7 + lib/lexoffice.ts | 260 +++++++- lib/nextcloud-calendar.ts | 73 ++- lib/price-calculator.ts | 108 ++++ package-lock.json | 18 +- package.json | 2 + .../migration.sql | 432 +++++++++++++ .../migration.sql | 12 + .../migration.sql | 94 +++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 133 +++- prisma/seed.ts | 55 +- scripts/check-email-sync.ts | 73 +++ scripts/check-equipment.ts | 31 + scripts/check-quotation-status.ts | 56 ++ scripts/clear-lexoffice-article-ids.ts | 40 ++ scripts/configure-luebeck-km.ts | 77 +++ scripts/finalize-existing-quotation.ts | 35 ++ scripts/list-lexoffice-articles.js | 89 +++ scripts/list-lexoffice-articles.ts | 53 ++ scripts/manual-email-sync.ts | 69 +++ scripts/reset-lexoffice-ids.ts | 42 ++ scripts/restore-lexoffice-article-ids.ts | 68 ++ scripts/set-correct-article-ids.ts | 114 ++++ scripts/setup-lexoffice-mapping.ts | 113 ++++ scripts/sync-nextcloud-bookings.ts | 73 +++ scripts/test-article-access.ts | 85 +++ scripts/test-booking-automation.ts | 53 ++ scripts/test-lexoffice-finalize.ts | 54 ++ sync-nextcloud-bookings.js | 209 +++++++ test-nextcloud-sync-bookings.js | 70 +++ tsconfig.tsbuildinfo | 2 +- 79 files changed, 7396 insertions(+), 538 deletions(-) create mode 100644 .env.development-flags create mode 100644 AUTOMATION-SYSTEM.md create mode 100644 DEVELOPMENT-MODE.md create mode 100644 LEXOFFICE-SETUP.md delete mode 100644 __MACOSX/berlin/._nf_form_11_11_2025_Fotobox.nff delete mode 100644 __MACOSX/berlin/._nf_form_11_11_2025_Fotospiegel.nff delete mode 100644 __MACOSX/hamburg/._nf_form_11_11_2025_Fotobox.nff delete mode 100644 __MACOSX/hamburg/._nf_form_11_11_2025_Fotobox__Nostalgie.nff delete mode 100644 __MACOSX/hamburg/._nf_form_11_11_2025_Fotospiegel.nff delete mode 100644 __MACOSX/kiel/._nf_form_11_11_2025_Fotobox.nff delete mode 100644 __MACOSX/kiel/._nf_form_11_11_2025_Fotobox__Nostalgie.nff delete mode 100644 __MACOSX/kiel/._nf_form_11_11_2025_Fotospiegel.nff delete mode 100644 __MACOSX/luebeck/._nf_form_11_11_2025_Fotobox.nff delete mode 100644 __MACOSX/luebeck/._nf_form_11_11_2025_Fotospiegel.nff delete mode 100644 __MACOSX/luebeck/._nf_form_11_11_2025_Fotospiegel__Nostalgie.nff delete mode 100644 __MACOSX/rostock/._nf_form_11_11_2025_Fotobox.nff delete mode 100644 __MACOSX/rostock/._nf_form_11_11_2025_Fotobox__Nostalgie.nff delete mode 100644 __MACOSX/rostock/._nf_form_11_11_2025_Fotospiegel.nff create mode 100644 app/api/admin/sync-all-bookings/route.ts create mode 100644 app/api/admin/sync-emails/route.ts create mode 100644 app/api/admin/test-automation/route.ts create mode 100644 app/api/bookings/[id]/confirm/route.ts create mode 100644 app/api/bookings/[id]/confirmation-pdf/route.ts create mode 100644 app/api/bookings/[id]/contract-pdf/route.ts create mode 100644 app/api/bookings/[id]/debug/route.ts create mode 100644 app/api/bookings/[id]/quotation-pdf/route.ts create mode 100644 app/api/bookings/[id]/sign/route.ts create mode 100644 app/api/driver/tour-stops/[id]/status/route.ts create mode 100644 app/api/driver/tours/[id]/route.ts create mode 100644 app/api/equipment/route.ts create mode 100644 app/dashboard/bookings/[id]/edit/page.tsx create mode 100644 app/dashboard/tours/new/page.tsx create mode 100644 app/driver/tours/[id]/page.tsx create mode 100644 components/BookingAutomationPanel.tsx create mode 100644 cron-email-sync.ts create mode 100644 lib/booking-automation.ts create mode 100644 lib/distance-calculator.ts create mode 100644 lib/price-calculator.ts create mode 100644 prisma/migrations/20251112204654_add_warehouse_address_and_km_pricing/migration.sql create mode 100644 prisma/migrations/20251112213709_add_lexoffice_mapping_and_print_flat/migration.sql create mode 100644 prisma/migrations/20251204130857_add_tour_tracking_and_photos/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 scripts/check-email-sync.ts create mode 100644 scripts/check-equipment.ts create mode 100644 scripts/check-quotation-status.ts create mode 100644 scripts/clear-lexoffice-article-ids.ts create mode 100644 scripts/configure-luebeck-km.ts create mode 100644 scripts/finalize-existing-quotation.ts create mode 100644 scripts/list-lexoffice-articles.js create mode 100644 scripts/list-lexoffice-articles.ts create mode 100644 scripts/manual-email-sync.ts create mode 100644 scripts/reset-lexoffice-ids.ts create mode 100644 scripts/restore-lexoffice-article-ids.ts create mode 100644 scripts/set-correct-article-ids.ts create mode 100644 scripts/setup-lexoffice-mapping.ts create mode 100644 scripts/sync-nextcloud-bookings.ts create mode 100644 scripts/test-article-access.ts create mode 100644 scripts/test-booking-automation.ts create mode 100644 scripts/test-lexoffice-finalize.ts create mode 100644 sync-nextcloud-bookings.js create mode 100644 test-nextcloud-sync-bookings.js diff --git a/.env.development-flags b/.env.development-flags new file mode 100644 index 0000000..505c0fe --- /dev/null +++ b/.env.development-flags @@ -0,0 +1,66 @@ +# =========================================== +# DEVELOPMENT / PRODUCTION MODE +# =========================================== +# WICHTIG: Diese Flags kontrollieren automatische Aktionen! + +# Umgebung: development | production +NODE_ENV="development" + +# Test-Modus aktivieren (verhindert echte E-Mails an Kunden) +TEST_MODE="true" + +# E-Mail-Versand aktivieren +EMAIL_ENABLED="false" + +# Automatische Workflows aktivieren +AUTO_WORKFLOWS="false" + +# Auto-Versand von Verträgen bei Status-Änderung +AUTO_SEND_CONTRACT="false" + +# Wenn TEST_MODE aktiv, werden E-Mails an diese Adresse umgeleitet: +TEST_EMAIL_RECIPIENT="ihre-test-email@example.com" + +# =========================================== +# FUNKTIONS-BESCHREIBUNG +# =========================================== +# +# TEST_MODE="true" +# → Alle E-Mails gehen an TEST_EMAIL_RECIPIENT +# → Echte Kunden-E-Mails werden NICHT versendet +# → Sicher für Development! +# +# EMAIL_ENABLED="false" +# → E-Mail-Versand komplett deaktiviert +# → Nur Logs in der Konsole +# → Gut für lokales Testen +# +# AUTO_WORKFLOWS="false" +# → Keine automatischen Aktionen +# → Alles manuell über Buttons +# → Maximale Kontrolle +# +# AUTO_SEND_CONTRACT="false" +# → Vertrag wird NICHT automatisch versendet +# → Nur manuell über "Vertrag senden" Button +# → Sicher für Tests +# +# =========================================== +# EMPFOHLENE EINSTELLUNGEN +# =========================================== +# +# DEVELOPMENT (jetzt): +# NODE_ENV="development" +# TEST_MODE="true" +# EMAIL_ENABLED="true" (für Tests) +# AUTO_WORKFLOWS="false" +# AUTO_SEND_CONTRACT="false" +# TEST_EMAIL_RECIPIENT="ihre-email@example.com" +# +# PRODUCTION (später): +# NODE_ENV="production" +# TEST_MODE="false" +# EMAIL_ENABLED="true" +# AUTO_WORKFLOWS="true" +# AUTO_SEND_CONTRACT="true" +# TEST_EMAIL_RECIPIENT="" (nicht benötigt) diff --git a/AUTOMATION-SYSTEM.md b/AUTOMATION-SYSTEM.md new file mode 100644 index 0000000..cef2611 --- /dev/null +++ b/AUTOMATION-SYSTEM.md @@ -0,0 +1,379 @@ +# SaveTheMoment Automatisierungs-System + +## Übersicht + +Das vollständig automatisierte Buchungs- und Verwaltungssystem für SaveTheMoment verarbeitet Anfragen von der E-Mail-Eingang bis zur Admin-Bestätigung. + +## Workflow-Phasen + +### Phase 1: ANFRAGE (Automatisch bei neuer Buchung) + +**Trigger:** Neue Buchung via E-Mail-Sync oder API + +**Automatische Aktionen (`lib/booking-automation.ts::runPostBookingActions()`):** + +1. ✅ **LexOffice Contact & Quotation erstellen** + - Contact aus Kundendaten erstellen + - Angebot mit allen Positionen generieren (Fotobox, KM-Pauschale, Extras) + - Angebots-PDF von LexOffice herunterladen + - Speichert `lexofficeContactId` und `lexofficeOfferId` + +2. ✅ **Mietvertrag-PDF generieren** + - PDF aus Template (`mietvertrag-vorlage.pdf`) erstellen + - Kundendaten, Event-Details, Preis einfügen + - Setzt `contractGenerated: true` und `contractGeneratedAt` + +3. ✅ **E-Mail mit Angebot + Vertrag versenden** + - Beide PDFs als Anhänge + - Online-Signatur-Link enthalten + - Übersichtliche Buchungsdetails und Gesamtpreis + - Setzt `contractSentAt` + +4. ✅ **Nextcloud Kalender-Sync** + - Event im Buchungskalender erstellen/aktualisieren + - Setzt `calendarSynced: true` und `calendarSyncedAt` + +5. ✅ **Admin-Benachrichtigung** + - Notification mit Typ `NEW_BOOKING` + +**Dateien:** +- `lib/booking-automation.ts` - Hauptlogik +- `lib/lexoffice.ts` - LexOffice API (Contact, Quotation, PDF-Download) +- `lib/pdf-template-service.ts` - Contract PDF-Generierung +- `lib/email-service.ts` - `sendInitialBookingEmail()` +- `lib/nextcloud-calendar.ts` - CalDAV-Synchronisation + +--- + +### Phase 2: ONLINE-UNTERSCHRIFT (Kunde) + +**Endpoint:** `POST /api/bookings/[id]/sign` + +**Ablauf:** +1. Kunde öffnet Link: `/contract/sign/{token}` +2. Canvas-Signatur zeichnen +3. POST an `/api/bookings/[id]/sign` mit Base64-Signatur +4. PDF mit Signatur neu generieren +5. Datenbank-Update: + - `contractSigned: true` + - `contractSignedAt: DateTime` + - `contractSignedOnline: true` + - `contractSignatureData: String` (Base64) + - `contractSignedBy: String` (Kundenname) + - `contractSignedIp: String` +6. Notification `CONTRACT_SIGNED` für Admin + +**Dateien:** +- `app/api/bookings/[id]/sign/route.ts` + +--- + +### Phase 3: ADMIN-BESTÄTIGUNG + +**Endpoint:** `POST /api/bookings/[id]/confirm` + +**Voraussetzungen:** +- ✅ `contractSigned === true` +- ✅ `status !== 'CONFIRMED'` + +**Ablauf:** +1. Admin klickt auf "Buchung bestätigen" im Dashboard +2. LexOffice Auftragsbestätigung erstellen + - `lexofficeService.createConfirmationFromBooking()` + - Speichert `lexofficeConfirmationId` +3. Status-Update: `RESERVED → CONFIRMED` +4. Setzt `confirmationSentAt` +5. Nextcloud Kalender aktualisieren (Status: CONFIRMED) +6. Notification `BOOKING_CONFIRMED` + +**Dateien:** +- `app/api/bookings/[id]/confirm/route.ts` + +--- + +### Phase 4: RECHNUNG (Zeitgesteuert - TODO) + +**Trigger:** Cron-Job + +**Regeln:** +- **Privatkunde:** 2 Wochen vor Event-Datum +- **Geschäftskunde:** Nach Event-Datum + +**Ablauf:** +1. LexOffice Rechnung erstellen +2. Rechnung finalisieren (freigeben) +3. E-Mail an Kunde mit Rechnungs-PDF +4. Setzt `lexofficeInvoiceId` + +--- + +## API-Endpunkte + +### Admin-Endpoints + +#### `POST/GET /api/admin/test-automation` +Testet die automatischen Aktionen für die neueste Buchung. + +**Response:** +```json +{ + "success": true, + "emailSent": true, + "calendarSynced": true, + "lexofficeCreated": true, + "contractGenerated": true, + "errors": [] +} +``` + +#### `POST /api/bookings/[id]/confirm` +Bestätigt eine Buchung (RESERVED → CONFIRMED) und erstellt LexOffice Auftragsbestätigung. + +**Auth:** Admin required + +--- + +### Public-Endpoints + +#### `POST /api/bookings/[id]/sign` +Speichert die Online-Signatur des Kunden. + +**Body:** +```json +{ + "signatureData": "data:image/png;base64,..." +} +``` + +#### `GET /api/bookings/[id]/sign` +Ruft Buchungsdetails für Signatur-Seite ab. + +--- + +## E-Mail-Templates + +### 1. Initiale Buchungsanfrage (`sendInitialBookingEmail`) +**An:** Kunde +**Anhänge:** +- `Angebot_{bookingNumber}.pdf` +- `Mietvertrag_{bookingNumber}.pdf` + +**Inhalt:** +- Buchungsdetails (Nummer, Datum, Location, Fotobox) +- Gesamtpreis (Highlight) +- Online-Signatur-Button +- Nächste Schritte + +### 2. Buchungsbestätigung (`sendBookingConfirmationEmail`) +**An:** Kunde +**Anhänge:** Keine (aktuell - TODO: Auftragsbestätigung anhängen) + +**Inhalt:** +- Bestätigung der verbindlichen Buchung +- Buchungsdetails + +### 3. Vertragsversand (`sendContractEmail`) +**An:** Kunde +**Anhänge:** +- `Mietvertrag_{bookingNumber}.pdf` + +**Inhalt:** +- Vertrag als PDF +- Online-Signatur-Link +- Hinweis auf Signatur-Möglichkeiten + +--- + +## Datenbank-Felder (Booking) + +### LexOffice-Integration +- `lexofficeContactId` - LexOffice Kontakt-ID +- `lexofficeOfferId` - LexOffice Angebots-ID +- `lexofficeConfirmationId` - LexOffice Auftragsbestätigungs-ID +- `lexofficeInvoiceId` - LexOffice Rechnungs-ID (TODO) +- `confirmationSentAt` - Zeitpunkt Admin-Bestätigung + +### Contract Management +- `contractGenerated` - PDF wurde generiert +- `contractGeneratedAt` - Zeitpunkt Generierung +- `contractSentAt` - Zeitpunkt Versand +- `contractSigned` - Vertrag unterschrieben +- `contractSignedAt` - Zeitpunkt Unterschrift +- `contractSignedOnline` - Online vs. Upload +- `contractSignatureData` - Base64 Signatur-Bild +- `contractSignedBy` - Name des Unterzeichners +- `contractSignedIp` - IP-Adresse bei Online-Signatur +- `contractPdfUrl` - URL zum finalen PDF (optional) + +### Kalender-Sync +- `calendarEventId` - Nextcloud Event-UID +- `calendarSynced` - Sync erfolgreich +- `calendarSyncedAt` - Zeitpunkt letzter Sync + +--- + +## Konfiguration + +### Umgebungsvariablen + +```env +# LexOffice +LEXOFFICE_API_KEY=your_api_key + +# Nextcloud +NEXTCLOUD_URL=https://your-nextcloud.com +NEXTCLOUD_USERNAME=username +NEXTCLOUD_PASSWORD=app_password + +# E-Mail Test-Modus +TEST_MODE=true +TEST_EMAIL_RECIPIENT=test@example.com +EMAIL_ENABLED=true + +# Base URL +NEXTAUTH_URL=https://your-domain.com +``` + +### LexOffice-Artikel-IDs + +Artikel-IDs sind in `PriceConfig` hinterlegt: +- `lexofficeArticleId` - Fotobox ohne Druckflatrate +- `lexofficeArticleIdWithFlat` - Fotobox mit Druckflatrate +- `lexofficeKmFlatArticleId` - Kilometerpauschale (optional) +- `lexofficeKmExtraArticleId` - Zusatzkilometer (optional) + +**Setup:** `scripts/setup-lexoffice-mapping.ts` + +--- + +## Testing + +### Manuelle Tests + +1. **Test Automation:** + ```bash + curl -X GET http://localhost:3000/api/admin/test-automation \ + -H "Cookie: next-auth.session-token=YOUR_TOKEN" + ``` + +2. **Test Signatur:** + ```bash + curl -X POST http://localhost:3000/api/bookings/{id}/sign \ + -H "Content-Type: application/json" \ + -d '{"signatureData": "data:image/png;base64,..."}' + ``` + +3. **Test Bestätigung:** + ```bash + curl -X POST http://localhost:3000/api/bookings/{id}/confirm \ + -H "Cookie: next-auth.session-token=YOUR_TOKEN" + ``` + +### E-Mail-Sync Test + +```bash +npm run sync-emails +# oder +node scripts/manual-email-sync.ts +``` + +--- + +## Fehlerbehandlung + +### Automatisierung (Non-Blocking) + +Alle Aktionen in `runPostBookingActions()` sind try-catch geschützt: +- LexOffice-Fehler → logged, aber kein Abbruch +- PDF-Fehler → logged, aber kein Abbruch +- E-Mail-Fehler → logged, nur wenn beide PDFs verfügbar +- Kalender-Fehler → logged, aber kein Abbruch + +**Return-Objekt:** +```typescript +{ + emailSent: boolean; + calendarSynced: boolean; + lexofficeCreated: boolean; + contractGenerated: boolean; + errors: string[]; +} +``` + +### Error-Logs + +Alle Fehler werden in Console geloggt mit Emoji-Präfix: +- ✅ Erfolg +- ❌ Fehler +- ⚠️ Warnung +- 🤖 Automation Start +- 💼 LexOffice +- 📄 PDF +- 📧 E-Mail +- 📅 Kalender + +--- + +## Nächste Schritte + +### Kurzfristig +1. ✅ Frontend für Online-Signatur erstellen (`/contract/sign/{token}`) +2. ✅ Dashboard-Button für Admin-Bestätigung +3. ✅ Testing mit echter Buchung + +### Mittelfristig +1. Rechnung-Scheduler implementieren (Cron-Job) +2. E-Mail-Template für Rechnung +3. Zahlungsstatus-Tracking + +### Langfristig +1. Webhook-Integration für LexOffice (statt Polling) +2. SMS-Benachrichtigungen +3. Customer-Portal für Buchungsübersicht + +--- + +## Architektur-Entscheidungen + +### Warum Custom LineItems statt nur LexOffice-Artikel? + +**Problem:** Kilometerpreise sind standortabhängig (Lübeck: 60€/15km, Hamburg: 100€/60km) + +**Lösung:** Hybrid-Ansatz +- Fotobox: LexOffice-Artikel (wenn ID vorhanden) +- KM-Pauschale: Custom LineItem mit dynamischem Preis +- Extras: LexOffice-Artikel oder Custom + +### Warum Promise-basiertes Singleton für Nextcloud? + +**Problem:** Race-Conditions bei parallelen Initialisierungen + +**Lösung:** `initPromise`-Pattern +- Erste Initialisierung erstellt Promise +- Alle weiteren Aufrufe warten auf gleiche Promise +- Bei Fehler: Reset für erneuten Versuch + +### Warum Non-Blocking Automation? + +**Problem:** Ein Fehler (z.B. LexOffice down) sollte nicht den gesamten Prozess stoppen + +**Lösung:** Granulare Error-Handling +- Jede Aktion in eigenem try-catch +- Fehler werden gesammelt, aber nicht propagiert +- Partial Success möglich (z.B. E-Mail ja, Kalender nein) + +--- + +## Support + +Bei Fragen oder Problemen: +1. Logs prüfen (Console-Output mit Emojis) +2. `.env` Konfiguration validieren +3. Nextcloud-Credentials testen: `node test-nextcloud-connection.js` +4. LexOffice-Artikel-IDs prüfen: `node scripts/setup-lexoffice-mapping.ts` + +--- + +**Version:** 1.0 +**Letzte Aktualisierung:** 2025-11-12 +**Entwickler:** Dennis Forte mit KI-Unterstützung diff --git a/DEVELOPMENT-MODE.md b/DEVELOPMENT-MODE.md new file mode 100644 index 0000000..22560fe --- /dev/null +++ b/DEVELOPMENT-MODE.md @@ -0,0 +1,184 @@ +# 🛡️ Sicherer Development-Modus + +## Aktuelles Problem +Sie entwickeln noch, aber **echte Buchungen** könnten bereits über Ihre Websites reinkommen (hamburg-fotobox.de, fotobox-luebeck.de, etc.). Diese sollen NICHT automatisch verarbeitet werden! + +--- + +## ✅ Lösung: Feature-Flags + +Fügen Sie diese Zeilen in Ihre `.env` Datei ein: + +```env +# =========================================== +# DEVELOPMENT MODE (für sicheres Testen) +# =========================================== + +NODE_ENV="development" +TEST_MODE="true" +EMAIL_ENABLED="true" +AUTO_WORKFLOWS="false" +AUTO_SEND_CONTRACT="false" +TEST_EMAIL_RECIPIENT="ihre-test-email@gmail.com" +``` + +--- + +## 📖 Was bedeuten die Flags? + +### **TEST_MODE="true"** 🧪 +- **Alle E-Mails werden umgeleitet** an `TEST_EMAIL_RECIPIENT` +- Echte Kunden bekommen KEINE E-Mails +- E-Mail enthält Hinweis: "Diese E-Mail wäre an kunde@example.de gegangen" +- **Betreff:** `[TEST] Ihr Mietvertrag...` + +### **EMAIL_ENABLED="false"** 📧 +- E-Mail-Versand komplett deaktiviert +- Nur Logs in der Konsole +- Gut für lokales Testen ohne echte SMTP-Verbindung + +### **AUTO_WORKFLOWS="false"** 🚫 +- Keine automatischen Aktionen +- Alles manuell über Buttons steuern +- Maximale Kontrolle über jeden Schritt + +### **AUTO_SEND_CONTRACT="false"** 📄 +- Vertrag wird NICHT automatisch versendet +- Nur manuell über "Vertrag senden" Button +- Sie entscheiden, wann versendet wird + +--- + +## 🎯 Empfohlene Einstellungen + +### **JETZT (Development/Testing):** +```env +NODE_ENV="development" +TEST_MODE="true" +EMAIL_ENABLED="true" +AUTO_WORKFLOWS="false" +AUTO_SEND_CONTRACT="false" +TEST_EMAIL_RECIPIENT="ihre-email@example.com" # IHRE Test-E-Mail! +``` + +**Ergebnis:** +- ✅ Sie können E-Mails testen +- ✅ Alle E-Mails kommen bei IHNEN an (nicht beim Kunden) +- ✅ Keine automatischen Aktionen +- ✅ Volle Kontrolle über jeden Schritt +- ✅ Echte Buchungen werden NICHT automatisch bearbeitet + +### **SPÄTER (Production - wenn alles fertig ist):** +```env +NODE_ENV="production" +TEST_MODE="false" +EMAIL_ENABLED="true" +AUTO_WORKFLOWS="true" +AUTO_SEND_CONTRACT="true" +# TEST_EMAIL_RECIPIENT nicht benötigt +``` + +**Ergebnis:** +- 🚀 E-Mails gehen an echte Kunden +- 🚀 Automatische Workflows aktiv +- 🚀 Vertrag wird automatisch versendet bei Bestätigung + +--- + +## 🧪 Test-Workflow + +### **So testen Sie sicher:** + +1. **`.env` konfigurieren:** + ```env + TEST_MODE="true" + EMAIL_ENABLED="true" + TEST_EMAIL_RECIPIENT="ihre-email@gmail.com" + ``` + +2. **Server neu starten:** + ```bash + # Terminal: Strg+C (Server stoppen) + npm run dev + ``` + +3. **Test-Buchung anlegen:** + - Dashboard → Neue Buchung + - Beispiel-Daten eingeben + - Standort wählen (z.B. Hamburg) + +4. **Vertrag generieren & senden:** + - Buchung öffnen + - "Vertrag generieren" klicken + - "Vertrag per E-Mail senden" klicken + +5. **Prüfen:** + - E-Mail kommt bei IHRER Test-Adresse an + - Betreff beginnt mit `[TEST]` + - Gelber Banner: "Diese E-Mail wäre an kunde@example.de gegangen" + +--- + +## ⚠️ WICHTIG: Was ist im Test-Modus sicher? + +| Aktion | Test-Modus | Production | +|--------|------------|------------| +| **E-Mail-Versand** | ✅ Geht an IHRE Test-E-Mail | ⚠️ Geht an echten Kunden | +| **Vertragsgenerierung** | ✅ Funktioniert normal | ✅ Funktioniert normal | +| **Datenbank** | ⚠️ Echte Daten (shared!) | ⚠️ Echte Daten | +| **Nextcloud-Sync** | ✅ Funktioniert normal | ✅ Funktioniert normal | +| **Automatik** | ❌ Deaktiviert | ✅ Aktiv | + +--- + +## 🎨 Visuelles Warning + +Im Dashboard sehen Sie oben ein **gelbes Banner**, wenn Test-Modus aktiv ist: + +``` +🧪 TEST-MODUS AKTIV +Alle E-Mails werden an ihre-email@example.com umgeleitet. +Echte Kunden erhalten KEINE E-Mails. +``` + +--- + +## 🔒 Sicherheits-Checkliste + +Vor dem Live-Gang (Production): + +- [ ] `TEST_MODE="false"` setzen +- [ ] `AUTO_WORKFLOWS="true"` aktivieren (wenn gewünscht) +- [ ] `AUTO_SEND_CONTRACT="true"` aktivieren (wenn gewünscht) +- [ ] `TEST_EMAIL_RECIPIENT` entfernen oder leer lassen +- [ ] Server neu starten +- [ ] Test-Buchung mit echter E-Mail probieren +- [ ] Prüfen: E-Mail geht an Kunden (nicht an Test-Adresse) + +--- + +## 🐛 Troubleshooting + +### E-Mails kommen nicht an +1. Prüfen: `EMAIL_ENABLED="true"`? +2. Terminal-Logs prüfen: `✅ Email sent` oder Fehler? +3. SMTP-Daten korrekt in Location-Einstellungen? + +### E-Mails gehen immer noch an Kunden +1. Prüfen: `TEST_MODE="true"`? +2. `TEST_EMAIL_RECIPIENT` gesetzt? +3. Server nach `.env`-Änderung neu gestartet? + +### Test-Banner wird nicht angezeigt +1. Browser-Cache leeren +2. Seite neu laden (Strg+Shift+R) +3. `.env` korrekt gespeichert? + +--- + +## 📞 Support + +Bei Fragen während der Entwicklung: +1. Terminal-Logs prüfen (dort sehen Sie alle E-Mail-Aktionen) +2. Browser-Console öffnen (F12) +3. Test-E-Mail in Ihrem Postfach prüfen diff --git a/LEXOFFICE-SETUP.md b/LEXOFFICE-SETUP.md new file mode 100644 index 0000000..2939612 --- /dev/null +++ b/LEXOFFICE-SETUP.md @@ -0,0 +1,89 @@ +# LexOffice Produkt-Verknüpfung + +## 🎯 Ziel +Automatische Erstellung von LexOffice Angeboten mit korrekten Produkt-Positionen und automatischer Kilometerberechnung. + +## 📋 Was wurde vorbereitet: + +### 1. Datenbank-Schema erweitert +- ✅ `PriceConfig`: LexOffice Artikel-IDs für Fotoboxen +- ✅ `PriceConfig`: LexOffice Artikel-IDs für KM-Pauschale & Extra-KM +- ✅ `Equipment`: LexOffice Artikel-IDs für Extras + +### 2. LexOffice-Integration erweitert +- ✅ Automatische Positionen im Angebot: + - Fotobox (mit Artikel-ID oder Custom) + - Kilometerpauschale (automatisch berechnet) + - Zusatzkilometer (automatisch berechnet) + - Equipment/Extras (falls vorhanden) + +### 3. Kilometer-Automatisierung +- ✅ **KEINE manuelle Eingabe mehr nötig!** +- ✅ System berechnet automatisch: + - Distanz (OSRM/OpenStreetMap) + - Pauschale bis X km (z.B. 60€ bis 15km) + - Zusatzkilometer × 4 Strecken + - Trennung in separate LexOffice-Positionen + +--- + +## 🔧 Setup - LexOffice Artikel-IDs finden + +### Option 1: Über LexOffice Web-Interface +1. Bei LexOffice einloggen +2. **Einstellungen** → **Artikel** +3. Artikel anklicken → URL kopieren +4. ID ist im Format: `XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX` + +### Option 2: Script ausführen +```bash +cd /Users/dennisforte/Desktop/KI\ Web-Projekte/SaveTheMomentAtlas +npx ts-node --esm scripts/list-lexoffice-articles.ts +``` + +Das zeigt alle verfügbaren Artikel mit IDs. + +--- + +## 📝 Artikel-Mapping (Beispiel) + +Nach dem Sie die IDs haben, tragen Sie diese ein: + +```typescript +// Für Lübeck - VINTAGE_SMILE +{ + lexofficeArticleId: "12345678-1234-1234-1234-123456789abc", + lexofficeKmFlatArticleId: "23456789-2345-2345-2345-23456789abcd", + lexofficeKmExtraArticleId: "34567890-3456-3456-3456-34567890abcd" +} +``` + +--- + +## 🚀 Nächste Schritte + +1. **LexOffice Artikel-IDs besorgen** (siehe oben) +2. **Migration ausführen**: + ```bash + npx prisma migrate dev --name add_lexoffice_article_ids + ``` +3. **IDs in Datenbank eintragen** (Script kommt noch) +4. **Testbuchung erstellen** +5. **LexOffice Angebot generieren & prüfen** + +--- + +## 💡 Vorteile + +### Vorher (Manuell): +❌ Kilometer manuell berechnen +❌ Custom-Artikel in LexOffice anlegen +❌ Positionen händisch eintragen +❌ Fehleranfällig + +### Nachher (Automatisch): +✅ Distanz automatisch berechnet +✅ Pauschale + Extra-KM automatisch getrennt +✅ Korrekte LexOffice Artikel verwendet +✅ Saubere Positionen im Angebot +✅ Fehlerlos & zeitsparend diff --git a/__MACOSX/berlin/._nf_form_11_11_2025_Fotobox.nff b/__MACOSX/berlin/._nf_form_11_11_2025_Fotobox.nff deleted file mode 100644 index e56f1833dc0ece06bd2ea37497f11694d4d92f07..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 547 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDJkFfe`u(m+0wGLR+$IEI7-L6m`YJOP?u zkEV?o$OnoucrY;VC+FwtB^DIqr0V4+Cg*?^GU@=ST?`DWNOGwqi7AOCiB{RZE}kW+ zx#1b9MX7E@`MJeFrHs#jge?PuC_;T4o|R1{Q|a zW|oG==4r+z)^3?ascHEY){c&bhGwp2&br1Xj?TI!rWP)`juvh%y5_DXmS#q#jxLVo GZVUjqr*#Yf diff --git a/__MACOSX/berlin/._nf_form_11_11_2025_Fotospiegel.nff b/__MACOSX/berlin/._nf_form_11_11_2025_Fotospiegel.nff deleted file mode 100644 index eb12489e3af1ad2d2abac6fd6c9b016a1cbc0924..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 547 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDJkFfe`u(m+0wGLR+$IEI7-L6m`YJOP?u zkEV?o$OnoucrY;VC+FwtB^DIqr0V4+Cg*?^GU@=ST?`DWNOGwqi7AOCiB{RZE}kW+ zx#1b9MX7E@`MJeFrHs#jge?PuC_;T4o|R1{Q|a zW|oG==4r;})^3?ascHEY)@Du?E{-M!hPsBvPNuphj+U0X7LLYFy3QtMj*do_#s+Rq GE(`#+GIZVm diff --git a/__MACOSX/hamburg/._nf_form_11_11_2025_Fotobox.nff b/__MACOSX/hamburg/._nf_form_11_11_2025_Fotobox.nff deleted file mode 100644 index 9ff242828fe7e5253e6f72e6951f1976c984cf9d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 547 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDJkFfe`u(m+0wGLR+$IEI7-L6m`YJOP?u zkEV?o$OnoucrY;VC+FwtB^DIqr0V4+Cg*?^GU@=ST?`DWNOGwqi7AOCiB{RZE}kW+ zx#1b9MX7E@`MJeFrHs#jge?PuC_;T4o|R1{Q|a zW|oG==BY^r)^3?ascHEY*5+=mj*iAg2D(O0&PKW>mX@ZvPEHn$Vqox1Ojhs@R)|o50+1L3ClDJkFfe`u(m+0wGLR+$IEI7-L6m`YJOP?u zkEV?o$OnoucrY;VC+FwtB^DIqr0V4+Cg*?^GU@=ST?`DWNOGwqi7AOCiB{RZE}kW+ zx#1b9MX7E@`MJeFrHs#jge?PuC_;T4o|R1{Q|a zW|oG==BY`>)^3?ascHEY)`pHwhAz&Qrn-iXj+VM6<_5;PmZmPox)yFG&Sr*g&Q1o- G&I|yg5Ontd diff --git a/__MACOSX/hamburg/._nf_form_11_11_2025_Fotospiegel.nff b/__MACOSX/hamburg/._nf_form_11_11_2025_Fotospiegel.nff deleted file mode 100644 index 19c300a654934578bd221afb337f84290d467bc7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 547 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDJkFfe`u(m+0wGLR+$IEI7-L6m`YJOP?u zkEV?o$OnoucrY;VC+FwtB^DIqr0V4+Cg*?^GU@=ST?`DWNOGwqi7AOCiB{RZE}kW+ zx#1b9MX7E@`MJeFrHs#jge?PuC_;T4o|R1{Q|a zW|oG==BbG()^3?ascHEY)<#B-j!vd#uDTYkE+)Dru1+qxmOwEeVc_WOWN2vaW@^d+ E0I!~PB>(^b diff --git a/__MACOSX/kiel/._nf_form_11_11_2025_Fotobox.nff b/__MACOSX/kiel/._nf_form_11_11_2025_Fotobox.nff deleted file mode 100644 index f013e0d31a635f3715b3f3fc9cc47cd7cab1254c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 541 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDJkFfhIb(m+0wERZGwIEI7-L6m`YJOP?u ziKdMi$OnoucrY;VC+FwtB^DIqr0V4+Cg*?^GU@=SEes5*NOGwqi7AOCiB{RZE}kW+ zx#1b9MX7E@`MJeFrHl`Nge?PuC_;TFcNEm*gkqSLkMErsn9Sr0SO!=q9G*X6EUG zNxg!M0{eo*^i$Vqox1Ojhs@R)|o50+1L3ClDJkFfhIb(m+0wERZGwIEI7-L6m`YJOP?u ziKdMi$OnoucrY;VC+FwtB^DIqr0V4+Cg*?^GU@=SEes5*NOGwqi7AOCiB{RZE}kW+ zx#1b9MX7E@`MJeFrHl`Nge?PuC_;TFcNEm*gkqSLkMErsn9Sr0SO!=q9G*X6EUG zNxg!M0{eo*^irv6-%mg`tzKiJ76ZuA_yqxvqhcfr+DulbNHVqa_0X D1WIx! diff --git a/__MACOSX/kiel/._nf_form_11_11_2025_Fotospiegel.nff b/__MACOSX/kiel/._nf_form_11_11_2025_Fotospiegel.nff deleted file mode 100644 index c16e0f73fa281f3a6fdd9503dcd5d0dbbe0690b0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 541 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDJkFfhIb(m+0wERZGwIEI7-L6m`YJOP?u ziKdMi$OnoucrY;VC+FwtB^DIqr0V4+Cg*?^GU@=SEes5*NOGwqi7AOCiB{RZE}kW+ zx#1b9MX7E@`MJeFrHl`Nge?PuC_;TFcNEm*gkqSLkMErsn9Sr0SO!=q9G*X6EUG zNxg!M0{eo*^i$Vqox1Ojhs@R)|o50+1L3ClDJkFfe`u(m+0wGLR+$IEI7-L6m`YJOP?u zkEV?o$OnoucrY;VC+FwtB^DIqr0V4+Cg*?^GU@=ST?`DWNOGwqi7AOCiB{RZE}kW+ zx#1b9MX7E@`MJeFrHs#jge?PuC_;T4o|R1{Q|a zW|oG==BdU>)^3?ascHEY)=oyQj%Ma2#=3?EE=Ia0u4WdxPR3@2x`viUmd0kz78aH! Gh716b+H`^d diff --git a/__MACOSX/luebeck/._nf_form_11_11_2025_Fotospiegel.nff b/__MACOSX/luebeck/._nf_form_11_11_2025_Fotospiegel.nff deleted file mode 100644 index 19e06d41090206c77266394f804ece3fde22844b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 547 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDJkFfe`u(m+0wGLR+$IEI7-L6m`YJOP?u zkEV?o$OnoucrY;VC+FwtB^DIqr0V4+Cg*?^GU@=ST?`DWNOGwqi7AOCiB{RZE}kW+ zx#1b9MX7E@`MJeFrHs#jge?PuC_;T4o|R1{Q|a zW|oG==BdW!)^3?ascHEY)`k`)&L+kNuDUMHu12~hMvkVsmTpGIx(4Q!2Bxl##*U7z G#tZ$Vqox1Ojhs@R)|o50+1L3ClDJkFfe`u(m+0wGLR+$IEI7-L6m`YJOP?u zkEV?o$OnoucrY;VC+FwtB^DIqr0V4+Cg*?^GU@=ST?`DWNOGwqi7AOCiB{RZE}kW+ zx#1b9MX7E@`MJeFrHs#jge?PuC_;T4o|R1{Q|a zW|oG==BdW1)^3?ascHEY)+Pq7hK44tX1a!!CT6-O&TekHmc~xbx^Cu1&X#V5u11a) Gt_%Q`m2~F- diff --git a/__MACOSX/rostock/._nf_form_11_11_2025_Fotobox.nff b/__MACOSX/rostock/._nf_form_11_11_2025_Fotobox.nff deleted file mode 100644 index a4d10d9b5701b61b4eda1082a1e1205349394152..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 547 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDJkFfe`u(m+0wGLR+$IEI7-L6m`YJOP?u zkEV?o$OnoucrY;VC+FwtB^DIqr0V4+Cg*?^GU@=ST?`DWNOGwqi7AOCiB{RZE}kW+ zx#1b9MX7E@`MJeFrHs#jge?PuC_;T4o|R1{Q|a zW|oG==Ba5Z)^3?ascHEY)^3&tZcfJLrn;`ChUU5^E^cnRmac}*x&}thZkDEQPKK5S GE(`$P&~$Vqox1Ojhs@R)|o50+1L3ClDJkFfe`u(m+0wGLR+$IEI7-L6m`YJOP?u zkEV?o$OnoucrY;VC+FwtB^DIqr0V4+Cg*?^GU@=ST?`DWNOGwqi7AOCiB{RZE}kW+ zx#1b9MX7E@`MJeFrHs#jge?PuC_;T4o|R1{Q|a zW|oG==Ba7s)^3?ascHEY)@H7*W|mIIj=GkX#wNNZW|pqHj>aa&x<)|4(bUbz(8<(| F0RYl_br1jm diff --git a/__MACOSX/rostock/._nf_form_11_11_2025_Fotospiegel.nff b/__MACOSX/rostock/._nf_form_11_11_2025_Fotospiegel.nff deleted file mode 100644 index 1651ab103da81cff97df263c397684b8328f588b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 547 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDJkFfe`u(m+0wGLR+$IEI7-L6m`YJOP?u zkEV?o$OnoucrY;VC+FwtB^DIqr0V4+Cg*?^GU@=ST?`DWNOGwqi7AOCiB{RZE}kW+ zx#1b9MX7E@`MJeFrHs#jge?PuC_;T4o|R1{Q|a zW|oG==Ba6k)^3?ascHEY)`rHWZkDD_mb#ASW+u8OZjR=<7LFEXK*G|%+}zpJ(#_D3 F0RY*ibxi;O diff --git a/app/api/admin/sync-all-bookings/route.ts b/app/api/admin/sync-all-bookings/route.ts new file mode 100644 index 0000000..dd037ad --- /dev/null +++ b/app/api/admin/sync-all-bookings/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { nextcloudCalendar } from '@/lib/nextcloud-calendar'; + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session || session.user.role !== 'ADMIN') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + console.log('🔄 Synchronisiere bestehende Buchungen mit Nextcloud...\n'); + + // Hole alle bestätigten Buchungen + const bookings = await prisma.booking.findMany({ + where: { + status: { + in: ['RESERVED', 'CONFIRMED'], + }, + }, + include: { + location: true, + photobox: true, + }, + orderBy: { + eventDate: 'asc', + }, + }); + + console.log(`📊 Gefunden: ${bookings.length} Buchungen`); + + if (bookings.length === 0) { + return NextResponse.json({ + success: true, + message: 'Keine Buchungen zum Synchronisieren gefunden.', + synced: 0, + failed: 0, + total: 0, + }); + } + + let synced = 0; + let failed = 0; + const errors: any[] = []; + + for (const booking of bookings) { + try { + console.log(`📅 Synchronisiere: ${booking.bookingNumber} - ${booking.customerName}`); + + await nextcloudCalendar.syncBookingToCalendar(booking); + synced++; + console.log(` ✅ Erfolgreich!`); + } catch (error: any) { + failed++; + console.error(` ❌ Fehler: ${error.message}`); + errors.push({ + bookingNumber: booking.bookingNumber, + customerName: booking.customerName, + error: error.message, + }); + } + } + + console.log('─'.repeat(50)); + console.log(`✅ Erfolgreich synchronisiert: ${synced}`); + console.log(`❌ Fehlgeschlagen: ${failed}`); + console.log(`📊 Gesamt: ${bookings.length}`); + + return NextResponse.json({ + success: true, + synced, + failed, + total: bookings.length, + errors: errors.length > 0 ? errors : undefined, + }); + + } catch (error: any) { + console.error('❌ Fehler beim Synchronisieren:', error); + return NextResponse.json( + { error: error.message || 'Failed to sync bookings' }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/sync-emails/route.ts b/app/api/admin/sync-emails/route.ts new file mode 100644 index 0000000..4375fcd --- /dev/null +++ b/app/api/admin/sync-emails/route.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { emailSyncService } from '@/lib/email-sync'; +import { prisma } from '@/lib/prisma'; + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session || session.user.role !== 'ADMIN') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { locationId } = await request.json(); + + if (locationId) { + // Sync specific location + console.log(`🔄 Starte E-Mail-Sync für Location: ${locationId}`); + const result = await emailSyncService.syncLocationEmails(locationId); + + return NextResponse.json({ + success: result.success, + location: locationId, + newEmails: result.newEmails, + newBookings: result.newBookings, + errors: result.errors, + }); + } else { + // Sync all locations + console.log('🔄 Starte E-Mail-Sync für alle Locations...'); + + const locations = await prisma.location.findMany({ + where: { emailSyncEnabled: true }, + select: { id: true, name: true }, + }); + + const results = []; + + for (const location of locations) { + console.log(`📍 Sync: ${location.name}`); + const result = await emailSyncService.syncLocationEmails(location.id); + results.push({ + locationId: location.id, + locationName: location.name, + ...result, + }); + } + + const totalNewEmails = results.reduce((sum, r) => sum + r.newEmails, 0); + const totalNewBookings = results.reduce((sum, r) => sum + r.newBookings, 0); + + return NextResponse.json({ + success: true, + totalLocations: locations.length, + totalNewEmails, + totalNewBookings, + results, + }); + } + } catch (error: any) { + console.error('❌ E-Mail-Sync Fehler:', error); + return NextResponse.json( + { error: error.message || 'E-Mail-Sync fehlgeschlagen' }, + { status: 500 } + ); + } +} + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session || session.user.role !== 'ADMIN') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Get sync status for all locations + const locations = await prisma.location.findMany({ + select: { + id: true, + name: true, + slug: true, + emailSyncEnabled: true, + lastEmailSync: true, + imapHost: true, + imapUser: true, + }, + }); + + const status = locations.map(loc => ({ + id: loc.id, + name: loc.name, + slug: loc.slug, + syncEnabled: loc.emailSyncEnabled, + configured: !!(loc.imapHost && loc.imapUser), + lastSync: loc.lastEmailSync, + })); + + return NextResponse.json({ locations: status }); + } catch (error: any) { + console.error('❌ Fehler beim Abrufen des Sync-Status:', error); + return NextResponse.json( + { error: error.message || 'Fehler beim Abrufen des Status' }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/test-automation/route.ts b/app/api/admin/test-automation/route.ts new file mode 100644 index 0000000..e1d8a4b --- /dev/null +++ b/app/api/admin/test-automation/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { bookingAutomationService } from '@/lib/booking-automation'; +import { prisma } from '@/lib/prisma'; + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session || session.user.role !== 'ADMIN') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { bookingId } = await request.json(); + + if (!bookingId) { + return NextResponse.json({ error: 'bookingId required' }, { status: 400 }); + } + + console.log(`🤖 Starte automatische Aktionen für Buchung: ${bookingId}`); + + const result = await bookingAutomationService.runPostBookingActions(bookingId); + + return NextResponse.json({ + success: true, + emailSent: result.emailSent, + calendarSynced: result.calendarSynced, + lexofficeCreated: result.lexofficeCreated, + contractGenerated: result.contractGenerated, + errors: result.errors, + }); + + } catch (error: any) { + console.error('❌ Automation Fehler:', error); + return NextResponse.json( + { error: error.message || 'Automation fehlgeschlagen' }, + { status: 500 } + ); + } +} + +// GET: Hole neueste Buchung und teste Automation +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session || session.user.role !== 'ADMIN') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Hole neueste Buchung + const latestBooking = await prisma.booking.findFirst({ + orderBy: { createdAt: 'desc' }, + select: { + id: true, + bookingNumber: true, + customerName: true, + customerEmail: true, + eventDate: true, + calendarSynced: true, + }, + }); + + if (!latestBooking) { + return NextResponse.json({ error: 'Keine Buchung gefunden' }, { status: 404 }); + } + + console.log(`🤖 Teste Automation für: ${latestBooking.bookingNumber}`); + + const result = await bookingAutomationService.runPostBookingActions(latestBooking.id); + + return NextResponse.json({ + success: true, + booking: { + id: latestBooking.id, + bookingNumber: latestBooking.bookingNumber, + customerName: latestBooking.customerName, + customerEmail: latestBooking.customerEmail, + eventDate: latestBooking.eventDate, + }, + emailSent: result.emailSent, + calendarSynced: result.calendarSynced, + lexofficeCreated: result.lexofficeCreated, + contractGenerated: result.contractGenerated, + errors: result.errors, + }); + + } catch (error: any) { + console.error('❌ Test Automation Fehler:', error); + return NextResponse.json( + { error: error.message || 'Test fehlgeschlagen' }, + { status: 500 } + ); + } +} diff --git a/app/api/bookings/[id]/confirm/route.ts b/app/api/bookings/[id]/confirm/route.ts new file mode 100644 index 0000000..6a33753 --- /dev/null +++ b/app/api/bookings/[id]/confirm/route.ts @@ -0,0 +1,175 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { lexofficeService } from '@/lib/lexoffice'; +import { nextcloudCalendar } from '@/lib/nextcloud-calendar'; + +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session || session.user.role !== 'ADMIN') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const bookingId = params.id; + + const booking = await prisma.booking.findUnique({ + where: { id: bookingId }, + include: { + location: true, + photobox: true, + bookingEquipment: { + include: { + equipment: true, + }, + }, + }, + }); + + if (!booking) { + return NextResponse.json( + { error: 'Buchung nicht gefunden' }, + { status: 404 } + ); + } + + if (booking.status === 'CONFIRMED') { + return NextResponse.json( + { error: 'Buchung ist bereits bestätigt' }, + { status: 400 } + ); + } + + if (!booking.contractSigned) { + return NextResponse.json( + { error: 'Vertrag muss zuerst unterschrieben werden' }, + { status: 400 } + ); + } + + console.log(`🔄 Bestätige Buchung ${booking.bookingNumber}...`); + + let lexofficeConfirmationId = null; + + if (booking.lexofficeContactId) { + try { + console.log(' 💼 Erstelle LexOffice Auftragsbestätigung...'); + + lexofficeConfirmationId = await lexofficeService.createConfirmationFromBooking( + booking, + booking.lexofficeContactId + ); + + console.log(` ✅ Auftragsbestätigung erstellt: ${lexofficeConfirmationId}`); + } catch (error: any) { + console.error(' ❌ LexOffice Fehler:', error.message); + } + } + + const updatedBooking = await prisma.booking.update({ + where: { id: bookingId }, + data: { + status: 'CONFIRMED', + lexofficeConfirmationId, + confirmationSentAt: new Date(), + }, + }); + + try { + console.log(' 📅 Update Nextcloud Kalender...'); + await nextcloudCalendar.syncBookingToCalendar(updatedBooking); + console.log(' ✅ Kalender aktualisiert'); + } catch (error: any) { + console.error(' ❌ Kalender-Update Fehler:', error.message); + } + + await prisma.notification.create({ + data: { + type: 'BOOKING_CONFIRMED', + title: 'Buchung bestätigt', + message: `Buchung ${booking.bookingNumber} für ${booking.customerName} wurde von Admin bestätigt.`, + metadata: { + bookingId: booking.id, + bookingNumber: booking.bookingNumber, + lexofficeConfirmationId, + }, + }, + }); + + console.log(`✅ Buchung bestätigt: ${booking.bookingNumber}`); + + return NextResponse.json({ + success: true, + booking: { + id: updatedBooking.id, + bookingNumber: updatedBooking.bookingNumber, + status: updatedBooking.status, + confirmationSentAt: updatedBooking.confirmationSentAt, + lexofficeConfirmationId: updatedBooking.lexofficeConfirmationId, + }, + }); + + } catch (error: any) { + console.error('❌ Bestätigungs-Fehler:', error); + return NextResponse.json( + { error: error.message || 'Bestätigung fehlgeschlagen' }, + { status: 500 } + ); + } +} + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session || session.user.role !== 'ADMIN') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const bookingId = params.id; + + const booking = await prisma.booking.findUnique({ + where: { id: bookingId }, + select: { + id: true, + bookingNumber: true, + status: true, + contractSigned: true, + contractSignedAt: true, + confirmationSentAt: true, + lexofficeContactId: true, + lexofficeOfferId: true, + lexofficeConfirmationId: true, + }, + }); + + if (!booking) { + return NextResponse.json( + { error: 'Buchung nicht gefunden' }, + { status: 404 } + ); + } + + const canConfirm = booking.status !== 'CONFIRMED' && booking.contractSigned; + + return NextResponse.json({ + booking, + canConfirm, + }); + + } catch (error: any) { + console.error('❌ Buchungs-Status Fehler:', error); + return NextResponse.json( + { error: error.message || 'Fehler beim Abrufen des Status' }, + { status: 500 } + ); + } +} diff --git a/app/api/bookings/[id]/confirmation-pdf/route.ts b/app/api/bookings/[id]/confirmation-pdf/route.ts new file mode 100644 index 0000000..d4467d1 --- /dev/null +++ b/app/api/bookings/[id]/confirmation-pdf/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { lexofficeService } from '@/lib/lexoffice'; + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session || session.user.role !== 'ADMIN') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const bookingId = params.id; + + const booking = await prisma.booking.findUnique({ + where: { id: bookingId }, + select: { + id: true, + bookingNumber: true, + lexofficeConfirmationId: true, + }, + }); + + if (!booking) { + return NextResponse.json({ error: 'Buchung nicht gefunden' }, { status: 404 }); + } + + if (!booking.lexofficeConfirmationId) { + return NextResponse.json({ error: 'Keine Auftragsbestätigung vorhanden' }, { status: 404 }); + } + + const pdfBuffer = await lexofficeService.getInvoicePDF(booking.lexofficeConfirmationId); + + return new NextResponse(pdfBuffer, { + status: 200, + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="Auftragsbestaetigung_${booking.bookingNumber}.pdf"`, + }, + }); + + } catch (error: any) { + console.error('❌ PDF-Download Fehler:', error); + return NextResponse.json( + { error: error.message || 'PDF-Download fehlgeschlagen' }, + { status: 500 } + ); + } +} diff --git a/app/api/bookings/[id]/contract-pdf/route.ts b/app/api/bookings/[id]/contract-pdf/route.ts new file mode 100644 index 0000000..fd86f80 --- /dev/null +++ b/app/api/bookings/[id]/contract-pdf/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { generateContractFromTemplate } from '@/lib/pdf-template-service'; + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session || session.user.role !== 'ADMIN') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const bookingId = params.id; + + const booking = await prisma.booking.findUnique({ + where: { id: bookingId }, + include: { + location: true, + photobox: true, + }, + }); + + if (!booking) { + return NextResponse.json({ error: 'Buchung nicht gefunden' }, { status: 404 }); + } + + let priceConfig = null; + if (booking.photobox?.model && booking.locationId) { + priceConfig = await prisma.priceConfig.findUnique({ + where: { + locationId_model: { + locationId: booking.locationId, + model: booking.photobox.model, + }, + }, + }); + } + + const bookingWithPriceConfig = { + ...booking, + priceConfig, + }; + + const signatureData = booking.contractSignedOnline ? booking.contractSignatureData : undefined; + + const pdfBuffer = await generateContractFromTemplate( + bookingWithPriceConfig, + booking.location, + booking.photobox, + signatureData + ); + + return new NextResponse(pdfBuffer, { + status: 200, + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="Mietvertrag_${booking.bookingNumber}.pdf"`, + }, + }); + + } catch (error: any) { + console.error('❌ Contract-PDF-Download Fehler:', error); + return NextResponse.json( + { error: error.message || 'PDF-Download fehlgeschlagen' }, + { status: 500 } + ); + } +} diff --git a/app/api/bookings/[id]/debug/route.ts b/app/api/bookings/[id]/debug/route.ts new file mode 100644 index 0000000..53fce2b --- /dev/null +++ b/app/api/bookings/[id]/debug/route.ts @@ -0,0 +1,111 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session || session.user.role !== 'ADMIN') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const bookingId = params.id; + + const booking = await prisma.booking.findUnique({ + where: { id: bookingId }, + include: { + location: true, + photobox: true, + bookingEquipment: { + include: { + equipment: true, + }, + }, + }, + }); + + if (!booking) { + return NextResponse.json({ error: 'Buchung nicht gefunden' }, { status: 404 }); + } + + let priceConfig = null; + if (booking.photobox?.model && booking.locationId) { + priceConfig = await prisma.priceConfig.findUnique({ + where: { + locationId_model: { + locationId: booking.locationId, + model: booking.photobox.model, + }, + }, + }); + } + + const lineItems: any[] = []; + const withPrintFlat = booking.withPrintFlat !== false; + + // Photobox LineItem + const photoboxArticleId = withPrintFlat + ? (priceConfig?.lexofficeArticleIdWithFlat || priceConfig?.lexofficeArticleId) + : priceConfig?.lexofficeArticleId; + + const boxName = booking.photobox?.model || 'Fotobox'; + const flatSuffix = withPrintFlat ? ' mit Druckflatrate' : ' (nur digital)'; + + const photoboxItem: any = { + type: (photoboxArticleId && photoboxArticleId.trim()) ? 'material' : 'custom', + quantity: 1, + unitName: 'Stück', + name: `${boxName}${flatSuffix}`, + }; + + if (photoboxArticleId && photoboxArticleId.trim()) { + photoboxItem.id = photoboxArticleId; + } else { + photoboxItem.description = `Event am ${new Date(booking.eventDate).toLocaleDateString('de-DE')} in ${booking.eventCity}`; + photoboxItem.unitPrice = { + currency: 'EUR', + netAmount: priceConfig?.basePrice || 1, + taxRatePercentage: 19, + }; + } + + lineItems.push(photoboxItem); + + return NextResponse.json({ + booking: { + id: booking.id, + bookingNumber: booking.bookingNumber, + locationId: booking.locationId, + photoboxModel: booking.photobox?.model, + withPrintFlat: booking.withPrintFlat, + distance: booking.distance, + }, + priceConfig: priceConfig ? { + id: priceConfig.id, + basePrice: priceConfig.basePrice, + kmFlatRate: priceConfig.kmFlatRate, + kmFlatRateUpTo: priceConfig.kmFlatRateUpTo, + pricePerKm: priceConfig.pricePerKm, + kmMultiplier: priceConfig.kmMultiplier, + lexofficeArticleId: priceConfig.lexofficeArticleId, + lexofficeArticleIdWithFlat: priceConfig.lexofficeArticleIdWithFlat, + lexofficeKmFlatArticleId: priceConfig.lexofficeKmFlatArticleId, + lexofficeKmExtraArticleId: priceConfig.lexofficeKmExtraArticleId, + } : null, + lineItems, + photoboxArticleId, + }); + + } catch (error: any) { + console.error('❌ Debug Fehler:', error); + return NextResponse.json( + { error: error.message, stack: error.stack }, + { status: 500 } + ); + } +} diff --git a/app/api/bookings/[id]/quotation-pdf/route.ts b/app/api/bookings/[id]/quotation-pdf/route.ts new file mode 100644 index 0000000..0904ac9 --- /dev/null +++ b/app/api/bookings/[id]/quotation-pdf/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { lexofficeService } from '@/lib/lexoffice'; + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session || session.user.role !== 'ADMIN') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const bookingId = params.id; + + const booking = await prisma.booking.findUnique({ + where: { id: bookingId }, + select: { + id: true, + bookingNumber: true, + lexofficeOfferId: true, + }, + }); + + if (!booking) { + return NextResponse.json({ error: 'Buchung nicht gefunden' }, { status: 404 }); + } + + if (!booking.lexofficeOfferId) { + return NextResponse.json({ error: 'Kein LexOffice Angebot vorhanden' }, { status: 404 }); + } + + const pdfBuffer = await lexofficeService.getQuotationPDF(booking.lexofficeOfferId); + + return new NextResponse(pdfBuffer, { + status: 200, + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="Angebot_${booking.bookingNumber}.pdf"`, + }, + }); + + } catch (error: any) { + console.error('❌ PDF-Download Fehler:', error); + return NextResponse.json( + { error: error.message || 'PDF-Download fehlgeschlagen' }, + { status: 500 } + ); + } +} diff --git a/app/api/bookings/[id]/route.ts b/app/api/bookings/[id]/route.ts index ad1fe90..49cb133 100644 --- a/app/api/bookings/[id]/route.ts +++ b/app/api/bookings/[id]/route.ts @@ -4,6 +4,39 @@ import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { nextcloudCalendar } from '@/lib/nextcloud-calendar'; +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session || session.user.role !== 'ADMIN') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const booking = await prisma.booking.findUnique({ + where: { id: params.id }, + include: { + location: true, + photobox: true, + bookingEquipment: { + include: { equipment: true }, + }, + }, + }); + + if (!booking) { + return NextResponse.json({ error: 'Booking not found' }, { status: 404 }); + } + + return NextResponse.json({ booking }); + } catch (error) { + console.error('Booking GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + export async function PUT( request: NextRequest, { params }: { params: { id: string } } @@ -65,23 +98,68 @@ export async function PATCH( const body = await request.json(); const { id } = params; - const { status } = body; - if (!status) { - return NextResponse.json({ error: 'Status is required' }, { status: 400 }); + const updateData: any = {}; + + if (body.status) updateData.status = body.status; + if (body.customerName !== undefined) updateData.customerName = body.customerName; + if (body.customerEmail !== undefined) updateData.customerEmail = body.customerEmail; + if (body.customerPhone !== undefined) updateData.customerPhone = body.customerPhone; + if (body.customerAddress !== undefined) updateData.customerAddress = body.customerAddress; + if (body.customerCity !== undefined) updateData.customerCity = body.customerCity; + if (body.customerZip !== undefined) updateData.customerZip = body.customerZip; + if (body.companyName !== undefined) updateData.companyName = body.companyName; + if (body.invoiceType !== undefined) updateData.invoiceType = body.invoiceType; + if (body.eventDate !== undefined) updateData.eventDate = new Date(body.eventDate); + if (body.eventAddress !== undefined) updateData.eventAddress = body.eventAddress; + if (body.eventCity !== undefined) updateData.eventCity = body.eventCity; + if (body.eventZip !== undefined) updateData.eventZip = body.eventZip; + if (body.eventLocation !== undefined) updateData.eventLocation = body.eventLocation; + if (body.setupTimeStart !== undefined) updateData.setupTimeStart = body.setupTimeStart ? new Date(body.setupTimeStart) : null; + if (body.setupTimeLatest !== undefined) updateData.setupTimeLatest = body.setupTimeLatest ? new Date(body.setupTimeLatest) : null; + if (body.dismantleTimeEarliest !== undefined) updateData.dismantleTimeEarliest = body.dismantleTimeEarliest ? new Date(body.dismantleTimeEarliest) : null; + if (body.dismantleTimeLatest !== undefined) updateData.dismantleTimeLatest = body.dismantleTimeLatest ? new Date(body.dismantleTimeLatest) : null; + if (body.calculatedPrice !== undefined) updateData.calculatedPrice = body.calculatedPrice; + if (body.notes !== undefined) updateData.notes = body.notes; + if (body.withPrintFlat !== undefined) updateData.withPrintFlat = body.withPrintFlat; + + const hasEquipmentUpdate = Array.isArray(body.equipmentIds); + const hasModelUpdate = body.model !== undefined; + + if (Object.keys(updateData).length === 0 && !hasEquipmentUpdate && !hasModelUpdate) { + return NextResponse.json({ error: 'Keine Änderungen angegeben' }, { status: 400 }); } const booking = await prisma.booking.update({ where: { id }, - data: { status }, + data: updateData, include: { location: true, photobox: true, }, }); + if (hasModelUpdate && booking.photoboxId) { + await prisma.photobox.update({ + where: { id: booking.photoboxId }, + data: { model: body.model }, + }); + } + + if (hasEquipmentUpdate) { + await prisma.bookingEquipment.deleteMany({ where: { bookingId: id } }); + if (body.equipmentIds.length > 0) { + await prisma.bookingEquipment.createMany({ + data: body.equipmentIds.map((eqId: string) => ({ + bookingId: id, + equipmentId: eqId, + })), + }); + } + } + try { - if (status === 'CANCELLED') { + if (updateData.status === 'CANCELLED') { await nextcloudCalendar.removeBookingFromCalendar(booking.id); } else { await nextcloudCalendar.syncBookingToCalendar(booking); diff --git a/app/api/bookings/[id]/sign/route.ts b/app/api/bookings/[id]/sign/route.ts new file mode 100644 index 0000000..cc9c059 --- /dev/null +++ b/app/api/bookings/[id]/sign/route.ts @@ -0,0 +1,165 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { generateContractFromTemplate } from '@/lib/pdf-template-service'; + +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const bookingId = params.id; + const body = await request.json(); + const { signatureData } = body; + + if (!signatureData) { + return NextResponse.json( + { error: 'Signatur-Daten fehlen' }, + { status: 400 } + ); + } + + const booking = await prisma.booking.findUnique({ + where: { id: bookingId }, + include: { + location: true, + photobox: true, + }, + }); + + if (!booking) { + return NextResponse.json( + { error: 'Buchung nicht gefunden' }, + { status: 404 } + ); + } + + if (booking.contractSigned) { + return NextResponse.json( + { error: 'Vertrag wurde bereits unterschrieben' }, + { status: 400 } + ); + } + + const clientIp = request.headers.get('x-forwarded-for') || + request.headers.get('x-real-ip') || + 'unknown'; + + let priceConfig = null; + if (booking.photobox?.model && booking.locationId) { + priceConfig = await prisma.priceConfig.findUnique({ + where: { + locationId_model: { + locationId: booking.locationId, + model: booking.photobox.model, + }, + }, + }); + } + + const bookingWithPriceConfig = { + ...booking, + priceConfig, + }; + + const contractPdf = await generateContractFromTemplate( + bookingWithPriceConfig, + booking.location, + booking.photobox, + signatureData + ); + + const updatedBooking = await prisma.booking.update({ + where: { id: bookingId }, + data: { + contractSigned: true, + contractSignedAt: new Date(), + contractSignedOnline: true, + contractSignatureData: signatureData, + contractSignedBy: booking.customerName, + contractSignedIp: clientIp, + }, + }); + + await prisma.notification.create({ + data: { + type: 'CONTRACT_SIGNED', + title: 'Vertrag unterschrieben', + message: `${booking.customerName} hat den Vertrag für Buchung ${booking.bookingNumber} online unterschrieben.`, + metadata: { + bookingId: booking.id, + bookingNumber: booking.bookingNumber, + signedOnline: true, + }, + }, + }); + + console.log(`✅ Vertrag online unterschrieben: ${booking.bookingNumber}`); + + return NextResponse.json({ + success: true, + booking: { + id: updatedBooking.id, + bookingNumber: updatedBooking.bookingNumber, + contractSigned: updatedBooking.contractSigned, + contractSignedAt: updatedBooking.contractSignedAt, + }, + }); + + } catch (error: any) { + console.error('❌ Signatur-Fehler:', error); + return NextResponse.json( + { error: error.message || 'Signatur fehlgeschlagen' }, + { status: 500 } + ); + } +} + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const bookingId = params.id; + + const booking = await prisma.booking.findUnique({ + where: { id: bookingId }, + select: { + id: true, + bookingNumber: true, + customerName: true, + eventDate: true, + eventLocation: true, + contractSigned: true, + contractSignedAt: true, + contractSignedOnline: true, + calculatedPrice: true, + photobox: { + select: { + model: true, + }, + }, + location: { + select: { + name: true, + }, + }, + }, + }); + + if (!booking) { + return NextResponse.json( + { error: 'Buchung nicht gefunden' }, + { status: 404 } + ); + } + + return NextResponse.json({ booking }); + + } catch (error: any) { + console.error('❌ Buchungs-Abruf Fehler:', error); + return NextResponse.json( + { error: error.message || 'Fehler beim Abrufen der Buchung' }, + { status: 500 } + ); + } +} diff --git a/app/api/bookings/create/route.ts b/app/api/bookings/create/route.ts index 29a03cc..695f378 100644 --- a/app/api/bookings/create/route.ts +++ b/app/api/bookings/create/route.ts @@ -105,6 +105,15 @@ export async function POST(request: NextRequest) { }, }); + if (Array.isArray(body.equipmentIds) && body.equipmentIds.length > 0) { + await prisma.bookingEquipment.createMany({ + data: body.equipmentIds.map((eqId: string) => ({ + bookingId: booking.id, + equipmentId: eqId, + })), + }); + } + try { await nextcloudCalendar.syncBookingToCalendar(booking); } catch (calError) { diff --git a/app/api/bookings/route.ts b/app/api/bookings/route.ts index c315806..948459d 100644 --- a/app/api/bookings/route.ts +++ b/app/api/bookings/route.ts @@ -1,6 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; import { z } from 'zod'; +import { DistanceCalculator } from '@/lib/distance-calculator'; +import { PriceCalculator } from '@/lib/price-calculator'; +import { bookingAutomationService } from '@/lib/booking-automation'; const bookingSchema = z.object({ locationSlug: z.string(), @@ -92,7 +95,62 @@ export async function POST(request: NextRequest) { }, }); - const calculatedPrice = priceConfig ? priceConfig.basePrice : 0; + if (!priceConfig) { + return NextResponse.json( + { error: 'Preiskonfiguration nicht gefunden' }, + { status: 404 } + ); + } + + let distance: number | null = null; + let calculatedPrice = priceConfig.basePrice; + + if (location.warehouseAddress && location.warehouseZip && location.warehouseCity) { + const warehouseAddress = DistanceCalculator.formatAddress( + location.warehouseAddress, + location.warehouseZip, + location.warehouseCity + ); + const eventAddress = DistanceCalculator.formatAddress( + data.eventAddress, + data.eventZip, + data.eventCity + ); + + const distanceResult = await DistanceCalculator.calculateDistance( + warehouseAddress, + eventAddress + ); + + if (distanceResult) { + distance = distanceResult.distance; + + const priceBreakdown = PriceCalculator.calculateTotalPrice( + priceConfig.basePrice, + distance, + { + basePrice: priceConfig.basePrice, + kmFlatRate: priceConfig.kmFlatRate, + kmFlatRateUpTo: priceConfig.kmFlatRateUpTo, + pricePerKm: priceConfig.pricePerKm, + kmMultiplier: priceConfig.kmMultiplier, + } + ); + + calculatedPrice = priceBreakdown.totalPrice; + + console.log('📍 Distanzberechnung:', { + from: warehouseAddress, + to: eventAddress, + distance: `${distance}km`, + breakdown: PriceCalculator.formatPriceBreakdown(priceBreakdown), + }); + } else { + console.warn('⚠️ Distanzberechnung fehlgeschlagen, verwende nur Grundpreis'); + } + } else { + console.warn('⚠️ Keine Lager-Adresse konfiguriert, verwende nur Grundpreis'); + } const booking = await prisma.booking.create({ data: { @@ -117,6 +175,7 @@ export async function POST(request: NextRequest) { setupTimeLatest: new Date(data.setupTimeLatest), dismantleTimeEarliest: data.dismantleTimeEarliest ? new Date(data.dismantleTimeEarliest) : null, dismantleTimeLatest: data.dismantleTimeLatest ? new Date(data.dismantleTimeLatest) : null, + distance, calculatedPrice, notes: data.notes, }, @@ -138,6 +197,12 @@ export async function POST(request: NextRequest) { }, }); + // 🤖 Automatische Post-Booking Aktionen (E-Mail + Kalender) + console.log('📢 Starte automatische Aktionen...'); + bookingAutomationService.runPostBookingActions(booking.id).catch(err => { + console.error('⚠️ Automatische Aktionen fehlgeschlagen:', err); + }); + return NextResponse.json({ success: true, booking: { @@ -174,7 +239,13 @@ export async function GET(request: NextRequest) { const where: any = {}; if (status) { - where.status = status; + // Support multiple statuses separated by comma + const statuses = status.split(',').map(s => s.trim()); + if (statuses.length > 1) { + where.status = { in: statuses }; + } else { + where.status = status; + } } if (locationSlug) { diff --git a/app/api/driver/tour-stops/[id]/status/route.ts b/app/api/driver/tour-stops/[id]/status/route.ts new file mode 100644 index 0000000..973e977 --- /dev/null +++ b/app/api/driver/tour-stops/[id]/status/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +export async function PATCH( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session || session.user.role !== 'DRIVER') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { status } = body; + + if (!status) { + return NextResponse.json({ error: 'Status is required' }, { status: 400 }); + } + + const tourStop = await prisma.tourStop.findUnique({ + where: { id: params.id }, + include: { + tour: { + select: { + driverId: true, + }, + }, + }, + }); + + if (!tourStop) { + return NextResponse.json({ error: 'Tour stop not found' }, { status: 404 }); + } + + if (tourStop.tour.driverId !== session.user.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }); + } + + const updateData: any = { status }; + + switch (status) { + case 'ARRIVED': + updateData.arrivedAt = new Date(); + break; + case 'SETUP_IN_PROGRESS': + updateData.setupStartedAt = new Date(); + break; + case 'SETUP_COMPLETE': + updateData.setupCompleteAt = new Date(); + break; + case 'PICKUP_IN_PROGRESS': + updateData.pickupStartedAt = new Date(); + break; + case 'PICKUP_COMPLETE': + updateData.pickupCompleteAt = new Date(); + break; + } + + const updatedStop = await prisma.tourStop.update({ + where: { id: params.id }, + data: updateData, + }); + + return NextResponse.json({ tourStop: updatedStop }); + } catch (error: any) { + console.error('Status update error:', error); + return NextResponse.json( + { error: error.message || 'Failed to update status' }, + { status: 500 } + ); + } +} diff --git a/app/api/driver/tours/[id]/route.ts b/app/api/driver/tours/[id]/route.ts new file mode 100644 index 0000000..131bff9 --- /dev/null +++ b/app/api/driver/tours/[id]/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session || session.user.role !== 'DRIVER') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const tour = await prisma.tour.findUnique({ + where: { + id: params.id, + driverId: session.user.id, + }, + include: { + tourStops: { + include: { + booking: { + include: { + photobox: { + select: { + model: true, + serialNumber: true, + }, + }, + }, + }, + photos: { + select: { + id: true, + photoType: true, + fileName: true, + }, + }, + }, + orderBy: { + stopOrder: 'asc', + }, + }, + }, + }); + + if (!tour) { + return NextResponse.json({ error: 'Tour not found' }, { status: 404 }); + } + + return NextResponse.json({ tour }); + } catch (error: any) { + console.error('Tour fetch error:', error); + return NextResponse.json( + { error: error.message || 'Failed to fetch tour' }, + { status: 500 } + ); + } +} diff --git a/app/api/equipment/route.ts b/app/api/equipment/route.ts new file mode 100644 index 0000000..0f24486 --- /dev/null +++ b/app/api/equipment/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +export async function GET() { + try { + const session = await getServerSession(authOptions); + + if (!session || session.user.role !== 'ADMIN') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const equipment = await prisma.equipment.findMany({ + where: { status: 'AVAILABLE' }, + orderBy: { name: 'asc' }, + select: { + id: true, + name: true, + type: true, + price: true, + }, + }); + + return NextResponse.json({ equipment }); + } catch (error) { + console.error('Equipment GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/tours/route.ts b/app/api/tours/route.ts index 30f1b05..b101a15 100644 --- a/app/api/tours/route.ts +++ b/app/api/tours/route.ts @@ -106,6 +106,7 @@ export async function POST(request: NextRequest) { }, data: { tourId: tour.id, + status: 'ASSIGNED', }, }); @@ -145,6 +146,13 @@ export async function POST(request: NextRequest) { }, }); + // Create TourStops for each booking + const fullBookings = await prisma.booking.findMany({ + where: { id: { in: bookingIds } }, + include: { setupWindows: true }, + orderBy: { setupTimeStart: 'asc' }, + }); + try { // For route optimization, use the selected setup window time if available const stopsWithSetupTimes = bookings.map((booking: any) => { @@ -182,8 +190,38 @@ export async function POST(request: NextRequest) { estimatedDuration: routeData.totalDuration, }, }); + + // Create TourStops based on optimized order + const optimizedOrder = routeData.optimizedOrder || fullBookings.map((_, i) => i); + for (let i = 0; i < optimizedOrder.length; i++) { + const orderIndex = optimizedOrder[i]; + const booking = fullBookings[orderIndex]; + + await prisma.tourStop.create({ + data: { + tourId: tour.id, + bookingId: booking.id, + stopOrder: i + 1, + stopType: 'DELIVERY', + status: 'PENDING', + }, + }); + } } catch (routeError) { console.error('Route optimization error:', routeError); + + // If route optimization fails, create TourStops in simple order + for (let i = 0; i < fullBookings.length; i++) { + await prisma.tourStop.create({ + data: { + tourId: tour.id, + bookingId: fullBookings[i].id, + stopOrder: i + 1, + stopType: 'DELIVERY', + status: 'PENDING', + }, + }); + } } } diff --git a/app/dashboard/bookings/[id]/edit/page.tsx b/app/dashboard/bookings/[id]/edit/page.tsx new file mode 100644 index 0000000..9d1e24d --- /dev/null +++ b/app/dashboard/bookings/[id]/edit/page.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter, useParams } from "next/navigation"; +import Link from "next/link"; + +export default function EditBookingPage() { + const router = useRouter(); + const params = useParams(); + const bookingId = params.id as string; + + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [equipmentList, setEquipmentList] = useState([]); + const [selectedEquipment, setSelectedEquipment] = useState([]); + const [formData, setFormData] = useState({ + customerName: "", + customerEmail: "", + customerPhone: "", + customerAddress: "", + customerCity: "", + customerZip: "", + companyName: "", + invoiceType: "PRIVATE", + model: "", + eventDate: "", + eventAddress: "", + eventCity: "", + eventZip: "", + eventLocation: "", + setupTimeStart: "", + setupTimeLatest: "", + dismantleTimeEarliest: "", + dismantleTimeLatest: "", + calculatedPrice: 0, + notes: "", + withPrintFlat: false, + }); + + const toggleEquipment = (id: string) => { + setSelectedEquipment((prev) => + prev.includes(id) ? prev.filter((e) => e !== id) : [...prev, id] + ); + }; + + useEffect(() => { + Promise.all([ + fetch(`/api/bookings/${bookingId}`).then((r) => r.json()), + fetch("/api/equipment").then((r) => r.json()), + ]) + .then(([bookingData, eqData]) => { + const b = bookingData.booking || bookingData; + setFormData({ + customerName: b.customerName || "", + customerEmail: b.customerEmail || "", + customerPhone: b.customerPhone || "", + customerAddress: b.customerAddress || "", + customerCity: b.customerCity || "", + customerZip: b.customerZip || "", + companyName: b.companyName || "", + invoiceType: b.invoiceType || "PRIVATE", + model: b.model || b.photobox?.model || "", + eventDate: b.eventDate ? new Date(b.eventDate).toISOString().split("T")[0] : "", + eventAddress: b.eventAddress || "", + eventCity: b.eventCity || "", + eventZip: b.eventZip || "", + eventLocation: b.eventLocation || "", + setupTimeStart: b.setupTimeStart ? new Date(b.setupTimeStart).toISOString().slice(0, 16) : "", + setupTimeLatest: b.setupTimeLatest ? new Date(b.setupTimeLatest).toISOString().slice(0, 16) : "", + dismantleTimeEarliest: b.dismantleTimeEarliest ? new Date(b.dismantleTimeEarliest).toISOString().slice(0, 16) : "", + dismantleTimeLatest: b.dismantleTimeLatest ? new Date(b.dismantleTimeLatest).toISOString().slice(0, 16) : "", + calculatedPrice: b.calculatedPrice || 0, + notes: b.notes || "", + withPrintFlat: b.withPrintFlat || false, + }); + setEquipmentList(eqData.equipment || []); + if (b.bookingEquipment) { + setSelectedEquipment(b.bookingEquipment.map((be: any) => be.equipmentId)); + } + setLoading(false); + }) + .catch(() => { + setError("Buchung konnte nicht geladen werden"); + setLoading(false); + }); + }, [bookingId]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + setError(""); + + try { + const res = await fetch(`/api/bookings/${bookingId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...formData, equipmentIds: selectedEquipment }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Fehler beim Speichern"); + } + + router.push(`/dashboard/bookings/${bookingId}`); + } catch (err: any) { + setError(err.message); + setSaving(false); + } + }; + + if (loading) { + return ( +
+
Laden...
+
+ ); + } + + const inputClass = "w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"; + + return ( +
+
+
+ + ← Zurück zur Buchung + +

Buchung bearbeiten

+
+ +
+
+

Fotobox & Ausstattung

+
+
+ + +
+
+ + {equipmentList.length > 0 && ( +
+ +
+ {equipmentList.map((eq) => ( + + ))} +
+
+ )} +
+ +
+

Kundendaten

+
+
+ +
+ + +
+
+ {formData.invoiceType === "BUSINESS" && ( +
+ + setFormData({ ...formData, companyName: e.target.value })} className={inputClass} /> +
+ )} +
+ + setFormData({ ...formData, customerName: e.target.value })} required className={inputClass} /> +
+
+ + setFormData({ ...formData, customerEmail: e.target.value })} required className={inputClass} /> +
+
+ + setFormData({ ...formData, customerPhone: e.target.value })} required className={inputClass} /> +
+
+ + setFormData({ ...formData, customerAddress: e.target.value })} placeholder="Straße und Hausnummer" className={inputClass} /> +
+
+ + setFormData({ ...formData, customerZip: e.target.value })} className={inputClass} /> +
+
+ + setFormData({ ...formData, customerCity: e.target.value })} className={inputClass} /> +
+
+
+ +
+

Event-Details

+
+
+ + setFormData({ ...formData, eventDate: e.target.value })} required className={inputClass} /> +
+
+ + setFormData({ ...formData, calculatedPrice: parseFloat(e.target.value) })} required className={inputClass} /> +
+
+ + setFormData({ ...formData, eventAddress: e.target.value })} required className={inputClass} /> +
+
+ + setFormData({ ...formData, eventZip: e.target.value })} required className={inputClass} /> +
+
+ + setFormData({ ...formData, eventCity: e.target.value })} required className={inputClass} /> +
+
+ + setFormData({ ...formData, eventLocation: e.target.value })} className={inputClass} /> +
+
+ + setFormData({ ...formData, setupTimeStart: e.target.value })} className={inputClass} /> +
+
+ + setFormData({ ...formData, setupTimeLatest: e.target.value })} className={inputClass} /> +
+
+ + setFormData({ ...formData, dismantleTimeEarliest: e.target.value })} className={inputClass} /> +
+
+ + setFormData({ ...formData, dismantleTimeLatest: e.target.value })} className={inputClass} /> +
+
+ +
+
+ +