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
This commit is contained in:
66
.env.development-flags
Normal file
66
.env.development-flags
Normal file
@@ -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)
|
||||||
379
AUTOMATION-SYSTEM.md
Normal file
379
AUTOMATION-SYSTEM.md
Normal file
@@ -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
|
||||||
184
DEVELOPMENT-MODE.md
Normal file
184
DEVELOPMENT-MODE.md
Normal file
@@ -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
|
||||||
89
LEXOFFICE-SETUP.md
Normal file
89
LEXOFFICE-SETUP.md
Normal file
@@ -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
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
87
app/api/admin/sync-all-bookings/route.ts
Normal file
87
app/api/admin/sync-all-bookings/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
108
app/api/admin/sync-emails/route.ts
Normal file
108
app/api/admin/sync-emails/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
app/api/admin/test-automation/route.ts
Normal file
96
app/api/admin/test-automation/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
175
app/api/bookings/[id]/confirm/route.ts
Normal file
175
app/api/bookings/[id]/confirm/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/api/bookings/[id]/confirmation-pdf/route.ts
Normal file
54
app/api/bookings/[id]/confirmation-pdf/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/api/bookings/[id]/contract-pdf/route.ts
Normal file
73
app/api/bookings/[id]/contract-pdf/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
111
app/api/bookings/[id]/debug/route.ts
Normal file
111
app/api/bookings/[id]/debug/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/api/bookings/[id]/quotation-pdf/route.ts
Normal file
54
app/api/bookings/[id]/quotation-pdf/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,39 @@ import { authOptions } from '@/lib/auth';
|
|||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { nextcloudCalendar } from '@/lib/nextcloud-calendar';
|
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(
|
export async function PUT(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: { id: string } }
|
||||||
@@ -65,23 +98,68 @@ export async function PATCH(
|
|||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const { status } = body;
|
|
||||||
|
|
||||||
if (!status) {
|
const updateData: any = {};
|
||||||
return NextResponse.json({ error: 'Status is required' }, { status: 400 });
|
|
||||||
|
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({
|
const booking = await prisma.booking.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status },
|
data: updateData,
|
||||||
include: {
|
include: {
|
||||||
location: true,
|
location: true,
|
||||||
photobox: 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 {
|
try {
|
||||||
if (status === 'CANCELLED') {
|
if (updateData.status === 'CANCELLED') {
|
||||||
await nextcloudCalendar.removeBookingFromCalendar(booking.id);
|
await nextcloudCalendar.removeBookingFromCalendar(booking.id);
|
||||||
} else {
|
} else {
|
||||||
await nextcloudCalendar.syncBookingToCalendar(booking);
|
await nextcloudCalendar.syncBookingToCalendar(booking);
|
||||||
|
|||||||
165
app/api/bookings/[id]/sign/route.ts
Normal file
165
app/api/bookings/[id]/sign/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
try {
|
||||||
await nextcloudCalendar.syncBookingToCalendar(booking);
|
await nextcloudCalendar.syncBookingToCalendar(booking);
|
||||||
} catch (calError) {
|
} catch (calError) {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { z } from 'zod';
|
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({
|
const bookingSchema = z.object({
|
||||||
locationSlug: z.string(),
|
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({
|
const booking = await prisma.booking.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -117,6 +175,7 @@ export async function POST(request: NextRequest) {
|
|||||||
setupTimeLatest: new Date(data.setupTimeLatest),
|
setupTimeLatest: new Date(data.setupTimeLatest),
|
||||||
dismantleTimeEarliest: data.dismantleTimeEarliest ? new Date(data.dismantleTimeEarliest) : null,
|
dismantleTimeEarliest: data.dismantleTimeEarliest ? new Date(data.dismantleTimeEarliest) : null,
|
||||||
dismantleTimeLatest: data.dismantleTimeLatest ? new Date(data.dismantleTimeLatest) : null,
|
dismantleTimeLatest: data.dismantleTimeLatest ? new Date(data.dismantleTimeLatest) : null,
|
||||||
|
distance,
|
||||||
calculatedPrice,
|
calculatedPrice,
|
||||||
notes: data.notes,
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
booking: {
|
booking: {
|
||||||
@@ -174,7 +239,13 @@ export async function GET(request: NextRequest) {
|
|||||||
const where: any = {};
|
const where: any = {};
|
||||||
|
|
||||||
if (status) {
|
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) {
|
if (locationSlug) {
|
||||||
|
|||||||
76
app/api/driver/tour-stops/[id]/status/route.ts
Normal file
76
app/api/driver/tour-stops/[id]/status/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/api/driver/tours/[id]/route.ts
Normal file
62
app/api/driver/tours/[id]/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/api/equipment/route.ts
Normal file
30
app/api/equipment/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,6 +106,7 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
tourId: tour.id,
|
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 {
|
try {
|
||||||
// For route optimization, use the selected setup window time if available
|
// For route optimization, use the selected setup window time if available
|
||||||
const stopsWithSetupTimes = bookings.map((booking: any) => {
|
const stopsWithSetupTimes = bookings.map((booking: any) => {
|
||||||
@@ -182,8 +190,38 @@ export async function POST(request: NextRequest) {
|
|||||||
estimatedDuration: routeData.totalDuration,
|
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) {
|
} catch (routeError) {
|
||||||
console.error('Route optimization error:', 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
311
app/dashboard/bookings/[id]/edit/page.tsx
Normal file
311
app/dashboard/bookings/[id]/edit/page.tsx
Normal file
@@ -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<any[]>([]);
|
||||||
|
const [selectedEquipment, setSelectedEquipment] = useState<string[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 flex items-center justify-center">
|
||||||
|
<div className="text-gray-400">Laden...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 p-8">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/bookings/${bookingId}`}
|
||||||
|
className="text-sm text-gray-400 hover:text-gray-300 mb-2 inline-block transition-colors"
|
||||||
|
>
|
||||||
|
← Zurück zur Buchung
|
||||||
|
</Link>
|
||||||
|
<h2 className="text-3xl font-bold text-white">Buchung bearbeiten</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-xl shadow-sm p-6 space-y-6"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">Fotobox & Ausstattung</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">Modell</label>
|
||||||
|
<select
|
||||||
|
value={formData.model}
|
||||||
|
onChange={(e) => setFormData({ ...formData, model: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="">Bitte wählen...</option>
|
||||||
|
<option value="VINTAGE">Vintage</option>
|
||||||
|
<option value="VINTAGE_SMILE">Vintage Smile</option>
|
||||||
|
<option value="VINTAGE_PHOTOS">Vintage Photos</option>
|
||||||
|
<option value="NOSTALGIE">Nostalgie</option>
|
||||||
|
<option value="MAGIC_MIRROR">Magic Mirror</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{equipmentList.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">Zusatzausstattung</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{equipmentList.map((eq) => (
|
||||||
|
<label
|
||||||
|
key={eq.id}
|
||||||
|
className={`flex items-center gap-2 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||||
|
selectedEquipment.includes(eq.id)
|
||||||
|
? "bg-red-500/10 border-red-500/50 text-white"
|
||||||
|
: "bg-gray-700/50 border-gray-600 text-gray-300 hover:border-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedEquipment.includes(eq.id)}
|
||||||
|
onChange={() => toggleEquipment(eq.id)}
|
||||||
|
className="accent-red-500"
|
||||||
|
/>
|
||||||
|
{eq.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">Kundendaten</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">Rechnungsart</label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center text-white">
|
||||||
|
<input type="radio" value="PRIVATE" checked={formData.invoiceType === "PRIVATE"} onChange={(e) => setFormData({ ...formData, invoiceType: e.target.value })} className="mr-2" />
|
||||||
|
Privat
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center text-white">
|
||||||
|
<input type="radio" value="BUSINESS" checked={formData.invoiceType === "BUSINESS"} onChange={(e) => setFormData({ ...formData, invoiceType: e.target.value })} className="mr-2" />
|
||||||
|
Geschäftlich
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{formData.invoiceType === "BUSINESS" && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">Firmenname</label>
|
||||||
|
<input type="text" value={formData.companyName} onChange={(e) => setFormData({ ...formData, companyName: e.target.value })} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">Name</label>
|
||||||
|
<input type="text" value={formData.customerName} onChange={(e) => setFormData({ ...formData, customerName: e.target.value })} required className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">E-Mail</label>
|
||||||
|
<input type="email" value={formData.customerEmail} onChange={(e) => setFormData({ ...formData, customerEmail: e.target.value })} required className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">Telefon</label>
|
||||||
|
<input type="tel" value={formData.customerPhone} onChange={(e) => setFormData({ ...formData, customerPhone: e.target.value })} required className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">Adresse</label>
|
||||||
|
<input type="text" value={formData.customerAddress} onChange={(e) => setFormData({ ...formData, customerAddress: e.target.value })} placeholder="Straße und Hausnummer" className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">PLZ</label>
|
||||||
|
<input type="text" value={formData.customerZip} onChange={(e) => setFormData({ ...formData, customerZip: e.target.value })} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">Stadt</label>
|
||||||
|
<input type="text" value={formData.customerCity} onChange={(e) => setFormData({ ...formData, customerCity: e.target.value })} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">Event-Details</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">Event-Datum</label>
|
||||||
|
<input type="date" value={formData.eventDate} onChange={(e) => setFormData({ ...formData, eventDate: e.target.value })} required className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">Preis (EUR)</label>
|
||||||
|
<input type="number" step="0.01" value={formData.calculatedPrice} onChange={(e) => setFormData({ ...formData, calculatedPrice: parseFloat(e.target.value) })} required className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">Event-Adresse</label>
|
||||||
|
<input type="text" value={formData.eventAddress} onChange={(e) => setFormData({ ...formData, eventAddress: e.target.value })} required className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">PLZ</label>
|
||||||
|
<input type="text" value={formData.eventZip} onChange={(e) => setFormData({ ...formData, eventZip: e.target.value })} required className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">Stadt</label>
|
||||||
|
<input type="text" value={formData.eventCity} onChange={(e) => setFormData({ ...formData, eventCity: e.target.value })} required className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">Location-Name (optional)</label>
|
||||||
|
<input type="text" value={formData.eventLocation} onChange={(e) => setFormData({ ...formData, eventLocation: e.target.value })} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">Aufbau ab</label>
|
||||||
|
<input type="datetime-local" value={formData.setupTimeStart} onChange={(e) => setFormData({ ...formData, setupTimeStart: e.target.value })} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">Aufbau spätestens</label>
|
||||||
|
<input type="datetime-local" value={formData.setupTimeLatest} onChange={(e) => setFormData({ ...formData, setupTimeLatest: e.target.value })} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">Abbau ab</label>
|
||||||
|
<input type="datetime-local" value={formData.dismantleTimeEarliest} onChange={(e) => setFormData({ ...formData, dismantleTimeEarliest: e.target.value })} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">Abbau spätestens</label>
|
||||||
|
<input type="datetime-local" value={formData.dismantleTimeLatest} onChange={(e) => setFormData({ ...formData, dismantleTimeLatest: e.target.value })} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="flex items-center text-white gap-2">
|
||||||
|
<input type="checkbox" checked={formData.withPrintFlat} onChange={(e) => setFormData({ ...formData, withPrintFlat: e.target.checked })} />
|
||||||
|
Druckflatrate
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">Notizen</label>
|
||||||
|
<textarea value={formData.notes} onChange={(e) => setFormData({ ...formData, notes: e.target.value })} rows={4} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/50 text-red-400 px-4 py-3 rounded-lg">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-4 pt-4 border-t border-gray-700">
|
||||||
|
<Link href={`/dashboard/bookings/${bookingId}`} className="flex-1 px-4 py-3 bg-gray-700 text-gray-300 rounded-lg font-semibold text-center hover:bg-gray-600 transition-colors">
|
||||||
|
Abbrechen
|
||||||
|
</Link>
|
||||||
|
<button type="submit" disabled={saving} className="flex-1 px-4 py-3 bg-gradient-to-r from-red-600 to-pink-600 text-white rounded-lg font-semibold hover:from-red-700 hover:to-pink-700 transition-all shadow-lg disabled:opacity-50">
|
||||||
|
{saving ? "Wird gespeichert..." : "Änderungen speichern"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,14 +2,16 @@ import { getServerSession } from 'next-auth';
|
|||||||
import { authOptions } from '@/lib/auth';
|
import { authOptions } from '@/lib/auth';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import BookingDetail from '@/components/BookingDetail';
|
|
||||||
import DashboardSidebar from '@/components/DashboardSidebar';
|
import DashboardSidebar from '@/components/DashboardSidebar';
|
||||||
|
import BookingAutomationPanel from '@/components/BookingAutomationPanel';
|
||||||
|
import { formatDate, formatDateTime } from '@/lib/date-utils';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
export default async function BookingDetailPage({ params }: { params: { id: string } }) {
|
export default async function BookingDetailPage({ params }: { params: { id: string } }) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
if (!session) {
|
if (!session || session.user.role !== 'ADMIN') {
|
||||||
redirect('/login');
|
redirect('/auth/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
const booking = await prisma.booking.findUnique({
|
const booking = await prisma.booking.findUnique({
|
||||||
@@ -17,16 +19,6 @@ export default async function BookingDetailPage({ params }: { params: { id: stri
|
|||||||
include: {
|
include: {
|
||||||
location: true,
|
location: true,
|
||||||
photobox: true,
|
photobox: true,
|
||||||
tour: {
|
|
||||||
include: {
|
|
||||||
driver: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
bookingEquipment: {
|
|
||||||
include: {
|
|
||||||
equipment: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,17 +26,224 @@ export default async function BookingDetailPage({ params }: { params: { id: stri
|
|||||||
redirect('/dashboard/bookings');
|
redirect('/dashboard/bookings');
|
||||||
}
|
}
|
||||||
|
|
||||||
const emails = await prisma.email.findMany({
|
const getStatusColor = (status: string) => {
|
||||||
where: { bookingId: booking.id },
|
switch (status) {
|
||||||
orderBy: { receivedAt: 'desc' },
|
case 'RESERVED': return 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/50';
|
||||||
});
|
case 'CONFIRMED': return 'bg-green-500/20 text-green-400 border border-green-500/50';
|
||||||
|
case 'COMPLETED': return 'bg-blue-500/20 text-blue-400 border border-blue-500/50';
|
||||||
|
case 'CANCELLED': return 'bg-red-500/20 text-red-400 border border-red-500/50';
|
||||||
|
default: return 'bg-gray-500/20 text-gray-400 border border-gray-500/50';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'RESERVED': return 'Reserviert';
|
||||||
|
case 'CONFIRMED': return 'Bestätigt';
|
||||||
|
case 'COMPLETED': return 'Abgeschlossen';
|
||||||
|
case 'CANCELLED': return 'Storniert';
|
||||||
|
default: return status;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
|
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<DashboardSidebar user={session?.user} />
|
<DashboardSidebar user={session.user} />
|
||||||
<main className="flex-1 p-8">
|
<main className="flex-1 p-8">
|
||||||
<BookingDetail booking={booking} emails={emails} user={session?.user} />
|
<div className="max-w-6xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white">{booking.bookingNumber}</h1>
|
||||||
|
<p className="text-gray-400 mt-1">{booking.customerName}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/bookings/${booking.id}/edit`}
|
||||||
|
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</Link>
|
||||||
|
<div className={`px-4 py-2 rounded-lg ${getStatusColor(booking.status)}`}>
|
||||||
|
{getStatusLabel(booking.status)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Automation Panel */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<BookingAutomationPanel booking={booking} invoiceType={booking.invoiceType} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Booking Details */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Customer Info */}
|
||||||
|
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-xl p-6 shadow-lg">
|
||||||
|
<h3 className="text-xl font-bold text-white mb-4">Kundendaten</h3>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Name:</span>
|
||||||
|
<span className="text-white ml-2 font-medium">{booking.customerName}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">E-Mail:</span>
|
||||||
|
<span className="text-white ml-2">{booking.customerEmail}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Telefon:</span>
|
||||||
|
<span className="text-white ml-2">{booking.customerPhone}</span>
|
||||||
|
</div>
|
||||||
|
{booking.customerAddress && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Adresse:</span>
|
||||||
|
<span className="text-white ml-2">
|
||||||
|
{booking.customerAddress}, {booking.customerZip} {booking.customerCity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{booking.companyName && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Firma:</span>
|
||||||
|
<span className="text-white ml-2">{booking.companyName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event Info */}
|
||||||
|
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-xl p-6 shadow-lg">
|
||||||
|
<h3 className="text-xl font-bold text-white mb-4">Event-Details</h3>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Datum:</span>
|
||||||
|
<span className="text-white ml-2 font-medium">{formatDate(booking.eventDate)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Location:</span>
|
||||||
|
<span className="text-white ml-2">{booking.eventLocation || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Adresse:</span>
|
||||||
|
<span className="text-white ml-2">
|
||||||
|
{booking.eventAddress}, {booking.eventZip} {booking.eventCity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Aufbau Start:</span>
|
||||||
|
<span className="text-white ml-2">{formatDateTime(booking.setupTimeStart)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Aufbau spätestens:</span>
|
||||||
|
<span className="text-white ml-2">{formatDateTime(booking.setupTimeLatest)}</span>
|
||||||
|
</div>
|
||||||
|
{booking.dismantleTimeEarliest && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Abbau ab:</span>
|
||||||
|
<span className="text-white ml-2">{formatDateTime(booking.dismantleTimeEarliest)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{booking.dismantleTimeLatest && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Abbau spätestens:</span>
|
||||||
|
<span className="text-white ml-2">{formatDateTime(booking.dismantleTimeLatest)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{booking.notes && (
|
||||||
|
<div className="pt-3 border-t border-gray-700">
|
||||||
|
<span className="text-gray-400">Notizen:</span>
|
||||||
|
<p className="text-white mt-1 whitespace-pre-wrap">{booking.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Photobox & Pricing */}
|
||||||
|
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-xl p-6 shadow-lg">
|
||||||
|
<h3 className="text-xl font-bold text-white mb-4">Fotobox & Preis</h3>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Modell:</span>
|
||||||
|
<span className="text-white ml-2 font-medium">{booking.photobox?.model || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Serial Number:</span>
|
||||||
|
<span className="text-white ml-2">{booking.photobox?.serialNumber || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Druckflatrate:</span>
|
||||||
|
<span className="text-white ml-2">{booking.withPrintFlat ? 'Ja' : 'Nein'}</span>
|
||||||
|
</div>
|
||||||
|
{booking.distance && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Entfernung:</span>
|
||||||
|
<span className="text-white ml-2">{booking.distance.toFixed(1)} km</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{booking.calculatedPrice && (
|
||||||
|
<div className="pt-3 border-t border-gray-700">
|
||||||
|
<span className="text-gray-400">Gesamtpreis:</span>
|
||||||
|
<span className="text-2xl text-pink-400 ml-2 font-bold">
|
||||||
|
{booking.calculatedPrice.toFixed(2)} €
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location Info */}
|
||||||
|
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-xl p-6 shadow-lg">
|
||||||
|
<h3 className="text-xl font-bold text-white mb-4">Standort</h3>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Name:</span>
|
||||||
|
<span className="text-white ml-2 font-medium">{booking.location?.name}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Stadt:</span>
|
||||||
|
<span className="text-white ml-2">{booking.location?.city}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Website:</span>
|
||||||
|
<span className="text-white ml-2">{booking.location?.websiteUrl}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Kontakt:</span>
|
||||||
|
<span className="text-white ml-2">{booking.location?.contactEmail}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LexOffice IDs (if present) */}
|
||||||
|
{(booking.lexofficeContactId || booking.lexofficeOfferId || booking.lexofficeConfirmationId) && (
|
||||||
|
<div className="mt-6 bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-xl p-6 shadow-lg">
|
||||||
|
<h3 className="text-xl font-bold text-white mb-4">LexOffice Integration</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||||
|
{booking.lexofficeContactId && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Kontakt-ID:</span>
|
||||||
|
<span className="text-white ml-2 font-mono text-xs">{booking.lexofficeContactId}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{booking.lexofficeOfferId && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Angebots-ID:</span>
|
||||||
|
<span className="text-white ml-2 font-mono text-xs">{booking.lexofficeOfferId}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{booking.lexofficeConfirmationId && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Bestätigungs-ID:</span>
|
||||||
|
<span className="text-white ml-2 font-mono text-xs">{booking.lexofficeConfirmationId}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import DashboardSidebar from '@/components/DashboardSidebar';
|
|||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
const isTestMode = process.env.TEST_MODE === 'true';
|
||||||
|
const testEmailRecipient = process.env.TEST_EMAIL_RECIPIENT;
|
||||||
|
const emailEnabled = process.env.EMAIL_ENABLED !== 'false';
|
||||||
|
const autoWorkflows = process.env.AUTO_WORKFLOWS === 'true';
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
totalBookings: await prisma.booking.count(),
|
totalBookings: await prisma.booking.count(),
|
||||||
reservedBookings: await prisma.booking.count({
|
reservedBookings: await prisma.booking.count({
|
||||||
@@ -41,6 +46,47 @@ export default async function DashboardPage() {
|
|||||||
<div className="flex">
|
<div className="flex">
|
||||||
<DashboardSidebar user={session?.user} />
|
<DashboardSidebar user={session?.user} />
|
||||||
<main className="flex-1 p-8">
|
<main className="flex-1 p-8">
|
||||||
|
{/* Development Mode Warning Banner */}
|
||||||
|
{isTestMode && (
|
||||||
|
<div className="mb-6 bg-yellow-500/10 border-2 border-yellow-500 rounded-xl p-4 shadow-lg">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="text-3xl">🧪</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-bold text-yellow-400 mb-1">
|
||||||
|
TEST-MODUS AKTIV
|
||||||
|
</h3>
|
||||||
|
<p className="text-yellow-200 text-sm mb-2">
|
||||||
|
Alle E-Mails werden an <strong>{testEmailRecipient || 'Test-E-Mail'}</strong> umgeleitet.
|
||||||
|
<br />
|
||||||
|
Echte Kunden erhalten KEINE E-Mails!
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4 text-xs text-yellow-300/80">
|
||||||
|
<span>📧 E-Mails: {emailEnabled ? '✅ Aktiv' : '❌ Deaktiviert'}</span>
|
||||||
|
<span>🤖 Auto-Workflows: {autoWorkflows ? '✅ Aktiv' : '❌ Deaktiviert'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!emailEnabled && !isTestMode && (
|
||||||
|
<div className="mb-6 bg-red-500/10 border-2 border-red-500 rounded-xl p-4 shadow-lg">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="text-3xl">⚠️</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-bold text-red-400 mb-1">
|
||||||
|
E-MAIL-VERSAND DEAKTIVIERT
|
||||||
|
</h3>
|
||||||
|
<p className="text-red-200 text-sm">
|
||||||
|
EMAIL_ENABLED=false - Kunden erhalten keine E-Mails!
|
||||||
|
<br />
|
||||||
|
Setzen Sie EMAIL_ENABLED="true" in der .env Datei.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<DashboardContent
|
<DashboardContent
|
||||||
user={session?.user}
|
user={session?.user}
|
||||||
stats={stats}
|
stats={stats}
|
||||||
|
|||||||
377
app/dashboard/tours/new/page.tsx
Normal file
377
app/dashboard/tours/new/page.tsx
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { FiCalendar, FiTruck, FiCheckSquare, FiSquare, FiMapPin, FiClock, FiSave, FiAlertCircle } from 'react-icons/fi';
|
||||||
|
|
||||||
|
interface Driver {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
vehiclePlate: string | null;
|
||||||
|
vehicleModel: string | null;
|
||||||
|
available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Booking {
|
||||||
|
id: string;
|
||||||
|
bookingNumber: string;
|
||||||
|
customerName: string;
|
||||||
|
eventDate: string;
|
||||||
|
eventAddress: string;
|
||||||
|
eventCity: string;
|
||||||
|
eventZip: string;
|
||||||
|
setupTimeStart: string;
|
||||||
|
setupTimeLatest: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewTourPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [drivers, setDrivers] = useState<Driver[]>([]);
|
||||||
|
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||||
|
|
||||||
|
const [tourDate, setTourDate] = useState('');
|
||||||
|
const [selectedDriver, setSelectedDriver] = useState('');
|
||||||
|
const [selectedBookings, setSelectedBookings] = useState<Set<string>>(new Set());
|
||||||
|
const [optimizationType, setOptimizationType] = useState<'fastest' | 'schedule'>('fastest');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const [driversRes, bookingsRes] = await Promise.all([
|
||||||
|
fetch('/api/drivers?available=true'),
|
||||||
|
fetch('/api/bookings?status=READY_FOR_ASSIGNMENT,OPEN_FOR_DRIVERS'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!driversRes.ok || !bookingsRes.ok) {
|
||||||
|
throw new Error('Fehler beim Laden der Daten');
|
||||||
|
}
|
||||||
|
|
||||||
|
const driversData = await driversRes.json();
|
||||||
|
const bookingsData = await bookingsRes.json();
|
||||||
|
|
||||||
|
setDrivers(driversData.drivers || []);
|
||||||
|
setBookings(bookingsData.bookings || []);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Fehler beim Laden der Daten');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleBooking = (bookingId: string) => {
|
||||||
|
const newSelected = new Set(selectedBookings);
|
||||||
|
if (newSelected.has(bookingId)) {
|
||||||
|
newSelected.delete(bookingId);
|
||||||
|
} else {
|
||||||
|
newSelected.add(bookingId);
|
||||||
|
}
|
||||||
|
setSelectedBookings(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAllOnDate = () => {
|
||||||
|
if (!tourDate) return;
|
||||||
|
|
||||||
|
const targetDate = new Date(tourDate).toISOString().split('T')[0];
|
||||||
|
const newSelected = new Set(selectedBookings);
|
||||||
|
|
||||||
|
bookings.forEach((booking) => {
|
||||||
|
const bookingDate = new Date(booking.eventDate).toISOString().split('T')[0];
|
||||||
|
if (bookingDate === targetDate) {
|
||||||
|
newSelected.add(booking.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedBookings(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!tourDate) {
|
||||||
|
setError('Bitte wähle ein Tourdatum');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedBookings.size === 0) {
|
||||||
|
setError('Bitte wähle mindestens eine Buchung aus');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setCreating(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch('/api/tours', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tourDate,
|
||||||
|
driverId: selectedDriver || null,
|
||||||
|
bookingIds: Array.from(selectedBookings),
|
||||||
|
optimizationType,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || 'Fehler beim Erstellen der Tour');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tour } = await response.json();
|
||||||
|
router.push(`/dashboard/tours/${tour.id}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Fehler beim Erstellen der Tour');
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupedBookings = bookings.reduce((acc, booking) => {
|
||||||
|
const date = new Date(booking.eventDate).toLocaleDateString('de-DE', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
if (!acc[date]) acc[date] = [];
|
||||||
|
acc[date].push(booking);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, Booking[]>);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 flex items-center justify-center">
|
||||||
|
<div className="text-gray-400">Lade Daten...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-6xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Neue Tour erstellen</h1>
|
||||||
|
<p className="text-gray-400">Wähle Buchungen aus und weise sie einem Fahrer zu</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 bg-red-500/10 border border-red-500/50 rounded-lg p-4 flex items-start gap-3">
|
||||||
|
<FiAlertCircle className="text-red-400 mt-0.5 flex-shrink-0" size={20} />
|
||||||
|
<div className="text-red-400">{error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
<FiCalendar className="inline mr-2" />
|
||||||
|
Tourdatum *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={tourDate}
|
||||||
|
onChange={(e) => setTourDate(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white focus:border-pink-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
{tourDate && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={selectAllOnDate}
|
||||||
|
className="mt-2 text-sm text-pink-400 hover:text-pink-300"
|
||||||
|
>
|
||||||
|
Alle Buchungen an diesem Tag auswählen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
<FiTruck className="inline mr-2" />
|
||||||
|
Fahrer zuweisen (optional)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedDriver}
|
||||||
|
onChange={(e) => setSelectedDriver(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white focus:border-pink-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">-- Später zuweisen --</option>
|
||||||
|
{drivers.map((driver) => (
|
||||||
|
<option key={driver.id} value={driver.id}>
|
||||||
|
{driver.name}
|
||||||
|
{driver.vehiclePlate && ` (${driver.vehiclePlate})`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{drivers.length === 0 && (
|
||||||
|
<p className="mt-2 text-sm text-yellow-400">
|
||||||
|
Keine verfügbaren Fahrer
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-4">
|
||||||
|
Route-Optimierung
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value="fastest"
|
||||||
|
checked={optimizationType === 'fastest'}
|
||||||
|
onChange={(e) => setOptimizationType(e.target.value as 'fastest')}
|
||||||
|
className="text-pink-500 focus:ring-pink-500"
|
||||||
|
/>
|
||||||
|
<span className="text-white">Kürzeste Route</span>
|
||||||
|
<span className="text-gray-400 text-sm">(Entfernung)</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value="schedule"
|
||||||
|
checked={optimizationType === 'schedule'}
|
||||||
|
onChange={(e) => setOptimizationType(e.target.value as 'schedule')}
|
||||||
|
className="text-pink-500 focus:ring-pink-500"
|
||||||
|
/>
|
||||||
|
<span className="text-white">Nach Zeitfenster</span>
|
||||||
|
<span className="text-gray-400 text-sm">(Aufbauzeiten)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<label className="text-sm font-medium text-gray-300">
|
||||||
|
Buchungen auswählen * ({selectedBookings.size} ausgewählt)
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedBookings.size === bookings.length) {
|
||||||
|
setSelectedBookings(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedBookings(new Set(bookings.map(b => b.id)));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-sm text-pink-400 hover:text-pink-300"
|
||||||
|
>
|
||||||
|
{selectedBookings.size === bookings.length ? 'Alle abwählen' : 'Alle auswählen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bookings.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-400">
|
||||||
|
<FiMapPin className="mx-auto mb-2" size={32} />
|
||||||
|
<p>Keine verfügbaren Buchungen</p>
|
||||||
|
<p className="text-sm mt-1">
|
||||||
|
Buchungen müssen den Status "Bereit zur Zuweisung" oder "Offen für Fahrer" haben
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 max-h-[500px] overflow-y-auto">
|
||||||
|
{Object.entries(groupedBookings).map(([date, dateBookings]) => (
|
||||||
|
<div key={date}>
|
||||||
|
<div className="text-sm font-medium text-gray-400 mb-2 sticky top-0 bg-gray-800/90 py-2">
|
||||||
|
{date}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{dateBookings.map((booking) => (
|
||||||
|
<div
|
||||||
|
key={booking.id}
|
||||||
|
onClick={() => toggleBooking(booking.id)}
|
||||||
|
className={`
|
||||||
|
p-4 rounded-lg border cursor-pointer transition-all
|
||||||
|
${selectedBookings.has(booking.id)
|
||||||
|
? 'bg-pink-500/10 border-pink-500/50'
|
||||||
|
: 'bg-gray-900/50 border-gray-700 hover:border-gray-600'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-1">
|
||||||
|
{selectedBookings.has(booking.id) ? (
|
||||||
|
<FiCheckSquare className="text-pink-400" size={20} />
|
||||||
|
) : (
|
||||||
|
<FiSquare className="text-gray-500" size={20} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-medium text-white">
|
||||||
|
{booking.bookingNumber}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400">•</span>
|
||||||
|
<span className="text-gray-300">
|
||||||
|
{booking.customerName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-400">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<FiMapPin size={14} />
|
||||||
|
{booking.eventCity}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<FiClock size={14} />
|
||||||
|
{new Date(booking.setupTimeStart).toLocaleTimeString('de-DE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
{' - '}
|
||||||
|
{new Date(booking.setupTimeLatest).toLocaleTimeString('de-DE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-500 mt-1">
|
||||||
|
{booking.eventAddress}, {booking.eventZip} {booking.eventCity}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
disabled={creating}
|
||||||
|
className="px-6 py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={creating || selectedBookings.size === 0 || !tourDate}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-6 py-3 bg-gradient-to-r from-pink-600 to-red-600 text-white rounded-lg hover:from-pink-700 hover:to-red-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg"
|
||||||
|
>
|
||||||
|
<FiSave />
|
||||||
|
{creating ? 'Erstelle Tour...' : 'Tour erstellen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,430 +1,209 @@
|
|||||||
'use client';
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { FiPlus, FiCalendar, FiTruck, FiMapPin, FiClock } from 'react-icons/fi';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
export default async function ToursPage() {
|
||||||
import { useRouter } from 'next/navigation';
|
const session = await getServerSession(authOptions);
|
||||||
import { formatDate } from '@/lib/date-utils';
|
|
||||||
import DashboardSidebar from '@/components/DashboardSidebar';
|
|
||||||
import { useSession } from 'next-auth/react';
|
|
||||||
|
|
||||||
export default function ToursPage() {
|
if (!session || session.user.role !== 'ADMIN') {
|
||||||
const router = useRouter();
|
redirect('/login');
|
||||||
const { data: session } = useSession();
|
}
|
||||||
const [tours, setTours] = useState<any[]>([]);
|
|
||||||
const [drivers, setDrivers] = useState<any[]>([]);
|
|
||||||
const [bookings, setBookings] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [showForm, setShowForm] = useState(false);
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
// Hole alle Touren, sortiert nach Datum
|
||||||
tourDate: '',
|
const tours = await prisma.tour.findMany({
|
||||||
driverId: '',
|
include: {
|
||||||
bookingIds: [] as string[],
|
driver: {
|
||||||
optimizationType: 'fastest' as 'fastest' | 'schedule',
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
vehiclePlate: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bookings: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
bookingNumber: true,
|
||||||
|
customerName: true,
|
||||||
|
eventCity: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tourStops: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
tourDate: 'desc',
|
||||||
|
},
|
||||||
|
take: 50,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const getStatusColor = (status: string) => {
|
||||||
fetchTours();
|
switch (status) {
|
||||||
fetchDrivers();
|
case 'PLANNED': return 'bg-blue-500/20 text-blue-400 border-blue-500/50';
|
||||||
fetchUnassignedBookings();
|
case 'IN_PROGRESS': return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50';
|
||||||
}, []);
|
case 'COMPLETED': return 'bg-green-500/20 text-green-400 border-green-500/50';
|
||||||
|
case 'CANCELLED': return 'bg-red-500/20 text-red-400 border-red-500/50';
|
||||||
const fetchTours = async () => {
|
default: return 'bg-gray-500/20 text-gray-400 border-gray-500/50';
|
||||||
try {
|
|
||||||
const res = await fetch('/api/tours');
|
|
||||||
const data = await res.json();
|
|
||||||
setTours(data.tours || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fetch error:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchDrivers = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/drivers?available=true');
|
|
||||||
const data = await res.json();
|
|
||||||
setDrivers(data.drivers || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Drivers fetch error:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchUnassignedBookings = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/bookings');
|
|
||||||
const data = await res.json();
|
|
||||||
const unassigned = (data.bookings || []).filter((b: any) => {
|
|
||||||
// Must be confirmed and not assigned to a tour
|
|
||||||
if (!b.tourId && b.status === 'CONFIRMED') {
|
|
||||||
// If booking has setup windows, check if any are already selected
|
|
||||||
if (b.setupWindows && b.setupWindows.length > 0) {
|
|
||||||
const hasSelectedWindow = b.setupWindows.some((w: any) => w.selected);
|
|
||||||
return !hasSelectedWindow; // Exclude if any window is already selected
|
|
||||||
}
|
|
||||||
return true; // No setup windows, just check tourId
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
setBookings(unassigned);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Bookings fetch error:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/tours', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(formData),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
setShowForm(false);
|
|
||||||
setFormData({
|
|
||||||
tourDate: '',
|
|
||||||
driverId: '',
|
|
||||||
bookingIds: [],
|
|
||||||
optimizationType: 'fastest',
|
|
||||||
});
|
|
||||||
fetchTours();
|
|
||||||
fetchUnassignedBookings();
|
|
||||||
} else {
|
|
||||||
alert('Fehler beim Erstellen');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Create error:', error);
|
|
||||||
alert('Fehler beim Erstellen');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleBooking = (bookingId: string) => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
bookingIds: prev.bookingIds.includes(bookingId)
|
|
||||||
? prev.bookingIds.filter(id => id !== bookingId)
|
|
||||||
: [...prev.bookingIds, bookingId],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter bookings by selected tour date
|
|
||||||
const availableBookings = formData.tourDate
|
|
||||||
? bookings.filter(booking => {
|
|
||||||
const bookingDate = new Date(booking.eventDate).toISOString().split('T')[0];
|
|
||||||
const tourDate = formData.tourDate;
|
|
||||||
|
|
||||||
// Check if event date matches
|
|
||||||
if (bookingDate === tourDate) return true;
|
|
||||||
|
|
||||||
// Check if any setup window date matches
|
|
||||||
if (booking.setupWindows && booking.setupWindows.length > 0) {
|
|
||||||
return booking.setupWindows.some((window: any) => {
|
|
||||||
const windowDate = new Date(window.setupDate).toISOString().split('T')[0];
|
|
||||||
return windowDate === tourDate && !window.selected;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
})
|
|
||||||
: bookings;
|
|
||||||
|
|
||||||
// Group bookings by date for display
|
|
||||||
const bookingsByDate = bookings.reduce((acc: any, booking: any) => {
|
|
||||||
const date = new Date(booking.eventDate).toISOString().split('T')[0];
|
|
||||||
if (!acc[date]) acc[date] = [];
|
|
||||||
acc[date].push(booking);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
|
||||||
const styles: Record<string, string> = {
|
|
||||||
PLANNED: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
|
|
||||||
IN_PROGRESS: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50',
|
|
||||||
COMPLETED: 'bg-green-500/20 text-green-400 border-green-500/50',
|
|
||||||
CANCELLED: 'bg-red-500/20 text-red-400 border-red-500/50',
|
|
||||||
};
|
|
||||||
return styles[status] || 'bg-gray-500/20 text-gray-400 border-gray-500/50';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusLabel = (status: string) => {
|
const getStatusLabel = (status: string) => {
|
||||||
const labels: Record<string, string> = {
|
switch (status) {
|
||||||
PLANNED: 'Geplant',
|
case 'PLANNED': return 'Geplant';
|
||||||
IN_PROGRESS: 'In Arbeit',
|
case 'IN_PROGRESS': return 'Unterwegs';
|
||||||
COMPLETED: 'Abgeschlossen',
|
case 'COMPLETED': return 'Abgeschlossen';
|
||||||
CANCELLED: 'Abgebrochen',
|
case 'CANCELLED': return 'Storniert';
|
||||||
};
|
default: return status;
|
||||||
return labels[status] || status;
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
|
|
||||||
<div className="flex">
|
|
||||||
<DashboardSidebar user={session?.user} />
|
|
||||||
<div className="flex-1 p-8">
|
|
||||||
<p className="text-gray-300">Lädt...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
|
<div className="p-8">
|
||||||
<div className="flex">
|
<div className="mb-8">
|
||||||
<DashboardSidebar user={session?.user} />
|
<Link
|
||||||
<main className="flex-1 p-8">
|
href="/dashboard"
|
||||||
<div className="max-w-7xl mx-auto">
|
className="text-sm text-gray-400 hover:text-gray-300 mb-2 inline-block transition-colors"
|
||||||
<div className="flex justify-between items-center mb-8">
|
>
|
||||||
<div>
|
← Zurück zum Dashboard
|
||||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-400 to-cyan-500 bg-clip-text text-transparent">
|
</Link>
|
||||||
Touren
|
<div className="flex justify-between items-center">
|
||||||
</h1>
|
<div>
|
||||||
<p className="text-gray-400 mt-1">Verwalten Sie Fahrer-Touren</p>
|
<h1 className="text-3xl font-bold text-white">Touren</h1>
|
||||||
</div>
|
<p className="text-gray-400 mt-1">Verwalte Fahrer-Touren und Route-Optimierung</p>
|
||||||
<button
|
|
||||||
onClick={() => setShowForm(true)}
|
|
||||||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg hover:from-blue-700 hover:to-cyan-700 font-semibold shadow-lg"
|
|
||||||
>
|
|
||||||
+ Neue Tour
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/tours/new"
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-pink-600 to-red-600 text-white rounded-lg hover:from-pink-700 hover:to-red-700 transition-all shadow-lg"
|
||||||
|
>
|
||||||
|
<FiPlus />
|
||||||
|
Neue Tour erstellen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showForm && (
|
{tours.length === 0 ? (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center p-4 z-50 overflow-y-auto">
|
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-12 text-center">
|
||||||
<div className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-xl shadow-2xl max-w-3xl w-full p-8 border border-gray-700 my-8">
|
<FiTruck className="mx-auto text-gray-500 text-5xl mb-4" />
|
||||||
<h2 className="text-2xl font-bold mb-6 text-white">Neue Tour erstellen</h2>
|
<h3 className="text-xl font-bold text-gray-300 mb-2">Noch keine Touren</h3>
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<p className="text-gray-400 mb-6">Erstelle deine erste Tour, um Buchungen zu Fahrern zuzuweisen.</p>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<Link
|
||||||
<div>
|
href="/dashboard/tours/new"
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
className="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-pink-600 to-red-600 text-white rounded-lg hover:from-pink-700 hover:to-red-700 transition-all"
|
||||||
Tour-Datum *
|
>
|
||||||
</label>
|
<FiPlus />
|
||||||
<input
|
Tour erstellen
|
||||||
type="date"
|
</Link>
|
||||||
value={formData.tourDate}
|
</div>
|
||||||
onChange={(e) => setFormData({ ...formData, tourDate: e.target.value })}
|
) : (
|
||||||
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-blue-500"
|
<div className="grid gap-4">
|
||||||
required
|
{tours.map((tour) => {
|
||||||
/>
|
const completedStops = tour.tourStops.filter(s =>
|
||||||
</div>
|
s.status === 'SETUP_COMPLETE' || s.status === 'PICKUP_COMPLETE'
|
||||||
|
).length;
|
||||||
|
const totalStops = tour.tourStops.length;
|
||||||
|
const progress = totalStops > 0 ? (completedStops / totalStops) * 100 : 0;
|
||||||
|
|
||||||
<div>
|
return (
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<Link
|
||||||
Fahrer
|
key={tour.id}
|
||||||
</label>
|
href={`/dashboard/tours/${tour.id}`}
|
||||||
<select
|
className="block bg-gray-800/50 border border-gray-700 rounded-lg p-6 hover:border-pink-500/50 hover:bg-gray-800/70 transition-all"
|
||||||
value={formData.driverId}
|
>
|
||||||
onChange={(e) => setFormData({ ...formData, driverId: e.target.value })}
|
<div className="flex items-start justify-between mb-4">
|
||||||
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-blue-500"
|
<div className="flex-1">
|
||||||
>
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<option value="">Noch keinen Fahrer zuweisen</option>
|
<h3 className="text-xl font-bold text-white">{tour.tourNumber}</h3>
|
||||||
{drivers.map((driver) => (
|
<span className={`px-3 py-1 rounded-full text-xs font-medium border ${getStatusColor(tour.status)}`}>
|
||||||
<option key={driver.id} value={driver.id}>
|
{getStatusLabel(tour.status)}
|
||||||
{driver.name} {driver.vehiclePlate ? `(${driver.vehiclePlate})` : ''}
|
</span>
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
|
||||||
Routen-Optimierung
|
|
||||||
</label>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormData({ ...formData, optimizationType: 'fastest' })}
|
|
||||||
className={`px-4 py-3 rounded-lg border-2 transition-all ${
|
|
||||||
formData.optimizationType === 'fastest'
|
|
||||||
? 'bg-blue-600 border-blue-500 text-white'
|
|
||||||
: 'bg-gray-700 border-gray-600 text-gray-300 hover:border-gray-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="font-semibold">🚗 Schnellste Route</div>
|
|
||||||
<div className="text-xs mt-1 opacity-80">Nach Distanz/Zeit</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormData({ ...formData, optimizationType: 'schedule' })}
|
|
||||||
className={`px-4 py-3 rounded-lg border-2 transition-all ${
|
|
||||||
formData.optimizationType === 'schedule'
|
|
||||||
? 'bg-purple-600 border-purple-500 text-white'
|
|
||||||
: 'bg-gray-700 border-gray-600 text-gray-300 hover:border-gray-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="font-semibold">⏰ Nach Aufbauzeiten</div>
|
|
||||||
<div className="text-xs mt-1 opacity-80">Zeitfenster beachten</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400 mt-2">
|
|
||||||
{formData.optimizationType === 'fastest'
|
|
||||||
? 'Optimiert nach kürzester Strecke/Zeit'
|
|
||||||
: 'Berücksichtigt Aufbau-Zeitfenster der Buchungen'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
|
||||||
Buchungen zuordnen ({formData.bookingIds.length} ausgewählt)
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{!formData.tourDate && (
|
|
||||||
<div className="bg-yellow-500/20 border border-yellow-500/50 rounded-lg p-4 mb-4">
|
|
||||||
<p className="text-yellow-300 text-sm">
|
|
||||||
⚠️ Bitte wähle zuerst ein Tour-Datum aus, um passende Buchungen zu sehen
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="flex items-center gap-6 text-sm text-gray-400">
|
||||||
{formData.tourDate && availableBookings.length === 0 && (
|
<div className="flex items-center gap-2">
|
||||||
<div className="bg-blue-500/20 border border-blue-500/50 rounded-lg p-4 mb-4">
|
<FiCalendar size={16} />
|
||||||
<p className="text-blue-300 text-sm">
|
{new Date(tour.tourDate).toLocaleDateString('de-DE', {
|
||||||
ℹ️ Keine bestätigten Buchungen für {new Date(formData.tourDate).toLocaleDateString('de-DE')} gefunden
|
weekday: 'short',
|
||||||
</p>
|
day: '2-digit',
|
||||||
</div>
|
month: '2-digit',
|
||||||
)}
|
year: 'numeric'
|
||||||
|
|
||||||
<div className="bg-gray-700/50 border border-gray-600 rounded-lg p-4 max-h-64 overflow-y-auto">
|
|
||||||
{availableBookings.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{availableBookings.map((booking) => {
|
|
||||||
const bookingDate = new Date(booking.eventDate).toISOString().split('T')[0];
|
|
||||||
const isEventDate = bookingDate === formData.tourDate;
|
|
||||||
const matchingWindows = booking.setupWindows?.filter((w: any) => {
|
|
||||||
const windowDate = new Date(w.setupDate).toISOString().split('T')[0];
|
|
||||||
return windowDate === formData.tourDate && !w.selected;
|
|
||||||
}) || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={booking.id}
|
|
||||||
className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-600"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.bookingIds.includes(booking.id)}
|
|
||||||
onChange={() => toggleBooking(booking.id)}
|
|
||||||
className="w-4 h-4"
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="text-white font-medium">{booking.bookingNumber}</p>
|
|
||||||
{!isEventDate && matchingWindows.length > 0 && (
|
|
||||||
<span className="px-2 py-0.5 bg-purple-600 text-white text-xs rounded-full">
|
|
||||||
📦 Flexibler Aufbau
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
{booking.customerName} - Event: {formatDate(booking.eventDate)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">{booking.eventAddress}, {booking.eventCity}</p>
|
|
||||||
|
|
||||||
{!isEventDate && matchingWindows.length > 0 && (
|
|
||||||
<div className="mt-2 space-y-1">
|
|
||||||
{matchingWindows.map((window: any) => (
|
|
||||||
<p key={window.id} className="text-xs text-purple-400">
|
|
||||||
🕐 Aufbau-Option: {new Date(window.setupTimeStart).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
{' - '}
|
|
||||||
{new Date(window.setupTimeEnd).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
{window.preferred && ' ⭐'}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isEventDate && booking.setupTimeStart && (
|
|
||||||
<p className="text-xs text-blue-400 mt-1">
|
|
||||||
⏰ Aufbau: {new Date(booking.setupTimeStart).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
{booking.setupTimeLatest && ` - ${new Date(booking.setupTimeLatest).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<p className="text-gray-400 text-center py-4">
|
{tour.driver && (
|
||||||
{formData.tourDate ? 'Keine Buchungen für dieses Datum' : 'Bitte Datum auswählen'}
|
<div className="flex items-center gap-2">
|
||||||
</p>
|
<FiTruck size={16} />
|
||||||
)}
|
{tour.driver.name}
|
||||||
|
{tour.driver.vehiclePlate && (
|
||||||
|
<span className="text-gray-500">({tour.driver.vehiclePlate})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FiMapPin size={16} />
|
||||||
|
{totalStops} {totalStops === 1 ? 'Stopp' : 'Stopps'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tour.estimatedDuration && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FiClock size={16} />
|
||||||
|
{Math.round(tour.estimatedDuration / 60)}h
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4">
|
{/* Fortschrittsbalken */}
|
||||||
<button
|
{totalStops > 0 && tour.status !== 'CANCELLED' && (
|
||||||
type="button"
|
<div className="mt-4">
|
||||||
onClick={() => setShowForm(false)}
|
<div className="flex justify-between items-center mb-1">
|
||||||
className="flex-1 px-6 py-3 bg-gray-700 text-gray-300 rounded-lg hover:bg-gray-600 font-semibold"
|
<span className="text-xs text-gray-400">
|
||||||
>
|
Fortschritt: {completedStops} von {totalStops} Stopps abgeschlossen
|
||||||
Abbrechen
|
</span>
|
||||||
</button>
|
<span className="text-xs text-gray-400">{progress.toFixed(0)}%</span>
|
||||||
<button
|
</div>
|
||||||
type="submit"
|
<div className="w-full bg-gray-700 rounded-full h-2">
|
||||||
className="flex-1 px-6 py-3 bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg hover:from-blue-700 hover:to-cyan-700 font-semibold"
|
<div
|
||||||
>
|
className="bg-gradient-to-r from-pink-500 to-red-500 h-2 rounded-full transition-all"
|
||||||
Erstellen
|
style={{ width: `${progress}%` }}
|
||||||
</button>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
{/* Buchungen-Preview */}
|
||||||
{tours.map((tour) => (
|
{tour.bookings.length > 0 && (
|
||||||
<div
|
<div className="mt-4 pt-4 border-t border-gray-700">
|
||||||
key={tour.id}
|
<div className="text-xs text-gray-400 mb-2">Buchungen:</div>
|
||||||
onClick={() => router.push(`/dashboard/tours/${tour.id}`)}
|
<div className="flex flex-wrap gap-2">
|
||||||
className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-lg shadow-xl p-6 hover:shadow-2xl transition-all cursor-pointer border border-gray-700 hover:border-blue-500"
|
{tour.bookings.slice(0, 5).map((booking) => (
|
||||||
>
|
<span
|
||||||
<div className="flex justify-between items-start mb-4">
|
key={booking.id}
|
||||||
<div>
|
className="px-2 py-1 bg-gray-700/50 text-gray-300 text-xs rounded"
|
||||||
<h3 className="text-lg font-bold text-white">{tour.tourNumber}</h3>
|
>
|
||||||
<p className="text-sm text-gray-400">{formatDate(tour.tourDate)}</p>
|
{booking.bookingNumber}
|
||||||
</div>
|
</span>
|
||||||
<span className={`px-3 py-1 text-xs font-semibold rounded-full border ${getStatusBadge(tour.status)}`}>
|
))}
|
||||||
{getStatusLabel(tour.status)}
|
{tour.bookings.length > 5 && (
|
||||||
</span>
|
<span className="px-2 py-1 text-gray-500 text-xs">
|
||||||
</div>
|
+{tour.bookings.length - 5} weitere
|
||||||
|
</span>
|
||||||
<div className="text-sm text-gray-400 space-y-2">
|
)}
|
||||||
<p>
|
</div>
|
||||||
<span className="font-medium text-gray-300">Fahrer:</span>{' '}
|
</div>
|
||||||
{tour.driver ? tour.driver.name : 'Nicht zugewiesen'}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span className="font-medium text-gray-300">Buchungen:</span> {tour.bookings.length}
|
|
||||||
</p>
|
|
||||||
{tour.totalDistance && (
|
|
||||||
<p>
|
|
||||||
<span className="font-medium text-gray-300">Strecke:</span> {tour.totalDistance} km
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
{tour.estimatedDuration && (
|
</Link>
|
||||||
<p>
|
);
|
||||||
<span className="font-medium text-gray-300">Dauer:</span> ~{tour.estimatedDuration} Min
|
})}
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{tours.length === 0 && (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-gray-400">Noch keine Touren vorhanden</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
457
app/driver/tours/[id]/page.tsx
Normal file
457
app/driver/tours/[id]/page.tsx
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
FiArrowLeft,
|
||||||
|
FiMapPin,
|
||||||
|
FiNavigation,
|
||||||
|
FiCheckCircle,
|
||||||
|
FiClock,
|
||||||
|
FiCamera,
|
||||||
|
FiAlertCircle,
|
||||||
|
FiPhone,
|
||||||
|
FiMap
|
||||||
|
} from 'react-icons/fi';
|
||||||
|
|
||||||
|
interface TourStop {
|
||||||
|
id: string;
|
||||||
|
stopOrder: number;
|
||||||
|
stopType: string;
|
||||||
|
status: string;
|
||||||
|
arrivedAt: string | null;
|
||||||
|
setupStartedAt: string | null;
|
||||||
|
setupCompleteAt: string | null;
|
||||||
|
pickupStartedAt: string | null;
|
||||||
|
pickupCompleteAt: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
issueDescription: string | null;
|
||||||
|
booking: {
|
||||||
|
id: string;
|
||||||
|
bookingNumber: string;
|
||||||
|
customerName: string;
|
||||||
|
customerPhone: string;
|
||||||
|
eventAddress: string;
|
||||||
|
eventCity: string;
|
||||||
|
eventZip: string;
|
||||||
|
eventLocation: string | null;
|
||||||
|
setupTimeStart: string;
|
||||||
|
setupTimeLatest: string;
|
||||||
|
photobox: {
|
||||||
|
model: string;
|
||||||
|
serialNumber: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
photos: Array<{
|
||||||
|
id: string;
|
||||||
|
photoType: string;
|
||||||
|
fileName: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Tour {
|
||||||
|
id: string;
|
||||||
|
tourNumber: string;
|
||||||
|
tourDate: string;
|
||||||
|
status: string;
|
||||||
|
totalDistance: number | null;
|
||||||
|
estimatedDuration: number | null;
|
||||||
|
tourStops: TourStop[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DriverTourDetailPage({ params }: { params: { id: string } }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [tour, setTour] = useState<Tour | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [updating, setUpdating] = useState(false);
|
||||||
|
const [expandedStop, setExpandedStop] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTour();
|
||||||
|
}, [params.id]);
|
||||||
|
|
||||||
|
const loadTour = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch(`/api/driver/tours/${params.id}`);
|
||||||
|
if (!res.ok) throw new Error('Tour nicht gefunden');
|
||||||
|
const data = await res.json();
|
||||||
|
setTour(data.tour);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Load error:', error);
|
||||||
|
alert('Fehler beim Laden der Tour');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStopStatus = async (stopId: string, newStatus: string) => {
|
||||||
|
try {
|
||||||
|
setUpdating(true);
|
||||||
|
const res = await fetch(`/api/driver/tour-stops/${stopId}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: newStatus }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error('Status-Update fehlgeschlagen');
|
||||||
|
|
||||||
|
await loadTour();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update error:', error);
|
||||||
|
alert('Fehler beim Aktualisieren des Status');
|
||||||
|
} finally {
|
||||||
|
setUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startNavigation = (stop: TourStop) => {
|
||||||
|
const address = `${stop.booking.eventAddress}, ${stop.booking.eventZip} ${stop.booking.eventCity}`;
|
||||||
|
const googleMapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(address)}`;
|
||||||
|
window.open(googleMapsUrl, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'PENDING': return 'bg-gray-100 text-gray-700 border-gray-300';
|
||||||
|
case 'ARRIVED': return 'bg-blue-100 text-blue-700 border-blue-300';
|
||||||
|
case 'SETUP_IN_PROGRESS': return 'bg-yellow-100 text-yellow-700 border-yellow-300';
|
||||||
|
case 'SETUP_COMPLETE': return 'bg-green-100 text-green-700 border-green-300';
|
||||||
|
case 'PICKUP_IN_PROGRESS': return 'bg-orange-100 text-orange-700 border-orange-300';
|
||||||
|
case 'PICKUP_COMPLETE': return 'bg-emerald-100 text-emerald-700 border-emerald-300';
|
||||||
|
case 'ISSUE': return 'bg-red-100 text-red-700 border-red-300';
|
||||||
|
default: return 'bg-gray-100 text-gray-700 border-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'PENDING': return 'Ausstehend';
|
||||||
|
case 'ARRIVED': return 'Angekommen';
|
||||||
|
case 'SETUP_IN_PROGRESS': return 'Aufbau läuft';
|
||||||
|
case 'SETUP_COMPLETE': return 'Aufgebaut';
|
||||||
|
case 'PICKUP_IN_PROGRESS': return 'Abbau läuft';
|
||||||
|
case 'PICKUP_COMPLETE': return 'Abgeholt';
|
||||||
|
case 'ISSUE': return 'Problem';
|
||||||
|
default: return status;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNextAction = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'PENDING': return { label: 'Angekommen', newStatus: 'ARRIVED', icon: FiMapPin };
|
||||||
|
case 'ARRIVED': return { label: 'Aufbau starten', newStatus: 'SETUP_IN_PROGRESS', icon: FiCheckCircle };
|
||||||
|
case 'SETUP_IN_PROGRESS': return { label: 'Aufbau abgeschlossen', newStatus: 'SETUP_COMPLETE', icon: FiCheckCircle };
|
||||||
|
case 'SETUP_COMPLETE': return { label: 'Abbau starten', newStatus: 'PICKUP_IN_PROGRESS', icon: FiCheckCircle };
|
||||||
|
case 'PICKUP_IN_PROGRESS': return { label: 'Abbau abgeschlossen', newStatus: 'PICKUP_COMPLETE', icon: FiCheckCircle };
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="text-gray-600">Lade Tour...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tour) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="text-gray-600">Tour nicht gefunden</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedStops = tour.tourStops.filter(s =>
|
||||||
|
s.status === 'SETUP_COMPLETE' || s.status === 'PICKUP_COMPLETE'
|
||||||
|
).length;
|
||||||
|
const totalStops = tour.tourStops.length;
|
||||||
|
const progress = totalStops > 0 ? (completedStops / totalStops) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 shadow-sm">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-4">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-3"
|
||||||
|
>
|
||||||
|
<FiArrowLeft />
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{tour.tourNumber}</h1>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{new Date(tour.tourDate).toLocaleDateString('de-DE', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{tour.estimatedDuration && (
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm text-gray-600">Geschätzte Dauer</div>
|
||||||
|
<div className="text-lg font-bold text-gray-900">
|
||||||
|
{Math.floor(tour.estimatedDuration / 60)}h {tour.estimatedDuration % 60}min
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
Fortschritt: {completedStops} von {totalStops} Stopps
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">
|
||||||
|
{progress.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||||
|
<div
|
||||||
|
className="bg-green-500 h-3 rounded-full transition-all"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-6 space-y-4">
|
||||||
|
{tour.tourStops.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-xl p-8 text-center border border-gray-200">
|
||||||
|
<FiMapPin className="mx-auto text-gray-400 mb-4" size={48} />
|
||||||
|
<p className="text-gray-600">Keine Stopps für diese Tour</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
tour.tourStops.map((stop, index) => {
|
||||||
|
const isExpanded = expandedStop === stop.id;
|
||||||
|
const nextAction = getNextAction(stop.status);
|
||||||
|
const isCompleted = stop.status === 'SETUP_COMPLETE' || stop.status === 'PICKUP_COMPLETE';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={stop.id}
|
||||||
|
className={`bg-white rounded-xl border-2 transition-all ${
|
||||||
|
isCompleted
|
||||||
|
? 'border-green-300 bg-green-50/30'
|
||||||
|
: stop.status === 'ISSUE'
|
||||||
|
? 'border-red-300'
|
||||||
|
: 'border-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className={`flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center text-white font-bold ${
|
||||||
|
isCompleted ? 'bg-green-500' : 'bg-gray-400'
|
||||||
|
}`}>
|
||||||
|
{isCompleted ? <FiCheckCircle size={20} /> : index + 1}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-gray-900">
|
||||||
|
{stop.booking.customerName}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{stop.booking.bookingNumber}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs font-medium border ${getStatusColor(stop.status)}`}>
|
||||||
|
{getStatusLabel(stop.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-sm text-gray-700 mb-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<FiMapPin className="mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
{stop.booking.eventAddress}, {stop.booking.eventZip} {stop.booking.eventCity}
|
||||||
|
{stop.booking.eventLocation && (
|
||||||
|
<div className="text-gray-600">({stop.booking.eventLocation})</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FiClock className="flex-shrink-0" />
|
||||||
|
<span>
|
||||||
|
Aufbau: {new Date(stop.booking.setupTimeStart).toLocaleTimeString('de-DE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
{' - '}
|
||||||
|
{new Date(stop.booking.setupTimeLatest).toLocaleTimeString('de-DE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stop.booking.photobox && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FiCamera className="flex-shrink-0" />
|
||||||
|
<span>
|
||||||
|
{stop.booking.photobox.model} (SN: {stop.booking.photobox.serialNumber})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => startNavigation(stop)}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<FiNavigation size={16} />
|
||||||
|
Navigation
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{nextAction && (
|
||||||
|
<button
|
||||||
|
onClick={() => updateStopStatus(stop.id, nextAction.newStatus)}
|
||||||
|
disabled={updating}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<nextAction.icon size={16} />
|
||||||
|
{nextAction.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stop.booking.customerPhone && (
|
||||||
|
<a
|
||||||
|
href={`tel:${stop.booking.customerPhone}`}
|
||||||
|
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<FiPhone size={16} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedStop(isExpanded ? null : stop.id)}
|
||||||
|
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
{isExpanded ? '▼' : '▶'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200 space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-gray-600 mb-1">Timeline</div>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
{stop.arrivedAt && (
|
||||||
|
<div className="text-gray-700">
|
||||||
|
✓ Angekommen: {new Date(stop.arrivedAt).toLocaleTimeString('de-DE')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{stop.setupStartedAt && (
|
||||||
|
<div className="text-gray-700">
|
||||||
|
✓ Aufbau gestartet: {new Date(stop.setupStartedAt).toLocaleTimeString('de-DE')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{stop.setupCompleteAt && (
|
||||||
|
<div className="text-gray-700">
|
||||||
|
✓ Aufbau abgeschlossen: {new Date(stop.setupCompleteAt).toLocaleTimeString('de-DE')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{stop.pickupStartedAt && (
|
||||||
|
<div className="text-gray-700">
|
||||||
|
✓ Abbau gestartet: {new Date(stop.pickupStartedAt).toLocaleTimeString('de-DE')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{stop.pickupCompleteAt && (
|
||||||
|
<div className="text-gray-700">
|
||||||
|
✓ Abbau abgeschlossen: {new Date(stop.pickupCompleteAt).toLocaleTimeString('de-DE')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stop.photos.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-gray-600 mb-1">
|
||||||
|
Fotos ({stop.photos.length})
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{stop.photos.map((photo) => (
|
||||||
|
<div key={photo.id} className="text-xs px-2 py-1 bg-gray-100 rounded">
|
||||||
|
{photo.photoType}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stop.notes && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-gray-600 mb-1">Notizen</div>
|
||||||
|
<div className="text-sm text-gray-700 bg-gray-50 p-2 rounded">
|
||||||
|
{stop.notes}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stop.issueDescription && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-red-600 mb-1">Problem</div>
|
||||||
|
<div className="text-sm text-red-700 bg-red-50 p-2 rounded border border-red-200">
|
||||||
|
{stop.issueDescription}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<button className="flex-1 px-3 py-2 text-sm border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">
|
||||||
|
<FiCamera className="inline mr-2" />
|
||||||
|
Foto hochladen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => updateStopStatus(stop.id, 'ISSUE')}
|
||||||
|
className="flex-1 px-3 py-2 text-sm border border-red-300 text-red-700 rounded-lg hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<FiAlertCircle className="inline mr-2" />
|
||||||
|
Problem melden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tour.tourStops.length > 1 && (
|
||||||
|
<div className="fixed bottom-4 right-4">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const firstStop = tour.tourStops[0];
|
||||||
|
if (firstStop) {
|
||||||
|
const addresses = tour.tourStops.map(s =>
|
||||||
|
`${s.booking.eventAddress}, ${s.booking.eventZip} ${s.booking.eventCity}`
|
||||||
|
);
|
||||||
|
const url = `https://www.google.com/maps/dir/?api=1&origin=${encodeURIComponent(addresses[0])}&destination=${encodeURIComponent(addresses[addresses.length - 1])}&waypoints=${encodeURIComponent(addresses.slice(1, -1).join('|'))}`;
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<FiMap size={20} />
|
||||||
|
Gesamte Route
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
448
components/BookingAutomationPanel.tsx
Normal file
448
components/BookingAutomationPanel.tsx
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { FiMail, FiCalendar, FiFileText, FiCheckCircle, FiAlertCircle, FiPlay, FiCheck, FiDownload, FiClock } from 'react-icons/fi';
|
||||||
|
|
||||||
|
interface BookingAutomationPanelProps {
|
||||||
|
booking: {
|
||||||
|
id: string;
|
||||||
|
bookingNumber: string;
|
||||||
|
status: string;
|
||||||
|
contractSigned: boolean;
|
||||||
|
contractSignedAt: string | null;
|
||||||
|
contractGenerated: boolean;
|
||||||
|
contractSentAt: string | null;
|
||||||
|
calendarSynced: boolean;
|
||||||
|
calendarSyncedAt: string | null;
|
||||||
|
lexofficeContactId: string | null;
|
||||||
|
lexofficeOfferId: string | null;
|
||||||
|
lexofficeConfirmationId: string | null;
|
||||||
|
lexofficeInvoiceId: string | null;
|
||||||
|
};
|
||||||
|
invoiceType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BookingAutomationPanel({ booking, invoiceType }: BookingAutomationPanelProps) {
|
||||||
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
|
const [isConfirming, setIsConfirming] = useState(false);
|
||||||
|
const [result, setResult] = useState<any>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const runAutomation = async () => {
|
||||||
|
setIsRunning(true);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/test-automation', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ bookingId: booking.id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setResult(data);
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
setError(data.error || 'Fehler bei der Automation');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Netzwerkfehler');
|
||||||
|
} finally {
|
||||||
|
setIsRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmBooking = async () => {
|
||||||
|
setIsConfirming(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/bookings/${booking.id}/confirm`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('✅ Buchung erfolgreich bestätigt!');
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
setError(data.error || 'Fehler bei der Bestätigung');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Netzwerkfehler');
|
||||||
|
} finally {
|
||||||
|
setIsConfirming(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasRunAutomation = Boolean(booking.lexofficeOfferId);
|
||||||
|
const canConfirm = booking.contractSigned && booking.status !== 'CONFIRMED';
|
||||||
|
|
||||||
|
const downloadPDF = async (type: 'quotation' | 'contract' | 'confirmation') => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/bookings/${booking.id}/${type}-pdf`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
alert(`Fehler: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
|
||||||
|
let filename = '';
|
||||||
|
if (type === 'quotation') filename = `Angebot_${booking.bookingNumber}.pdf`;
|
||||||
|
if (type === 'contract') filename = `Mietvertrag_${booking.bookingNumber}.pdf`;
|
||||||
|
if (type === 'confirmation') filename = `Auftragsbestaetigung_${booking.bookingNumber}.pdf`;
|
||||||
|
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(`Fehler beim Download: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-xl p-6 shadow-lg">
|
||||||
|
<h3 className="text-xl font-bold text-white mb-6 flex items-center gap-2">
|
||||||
|
<FiPlay className="text-pink-500" />
|
||||||
|
Automation & Status
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Status Grid */}
|
||||||
|
<div className={`grid grid-cols-2 ${invoiceType !== 'BUSINESS' ? 'md:grid-cols-4' : 'md:grid-cols-3'} gap-4 mb-6`}>
|
||||||
|
{/* Contract Generated - nur bei Privatkunden */}
|
||||||
|
{invoiceType !== 'BUSINESS' && (
|
||||||
|
<div className={`p-4 rounded-lg border ${booking.contractGenerated ? 'bg-green-500/10 border-green-500/50' : 'bg-gray-700/50 border-gray-600'}`}>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<FiFileText className={booking.contractGenerated ? 'text-green-400' : 'text-gray-500'} />
|
||||||
|
<span className={`text-sm font-medium ${booking.contractGenerated ? 'text-green-400' : 'text-gray-400'}`}>
|
||||||
|
Vertrag PDF
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{booking.contractGenerated ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1 text-xs text-green-400">
|
||||||
|
<FiCheckCircle size={12} />
|
||||||
|
Generiert
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => downloadPDF('contract')}
|
||||||
|
className="w-full flex items-center justify-center gap-1 px-2 py-1 bg-green-600/20 hover:bg-green-600/30 text-green-400 text-xs rounded transition-colors"
|
||||||
|
>
|
||||||
|
<FiDownload size={12} />
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-gray-500">Nicht generiert</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* LexOffice Created */}
|
||||||
|
<div className={`p-4 rounded-lg border ${booking.lexofficeOfferId ? 'bg-green-500/10 border-green-500/50' : 'bg-gray-700/50 border-gray-600'}`}>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<FiFileText className={booking.lexofficeOfferId ? 'text-green-400' : 'text-gray-500'} />
|
||||||
|
<span className={`text-sm font-medium ${booking.lexofficeOfferId ? 'text-green-400' : 'text-gray-400'}`}>
|
||||||
|
LexOffice
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{booking.lexofficeOfferId ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1 text-xs text-green-400">
|
||||||
|
<FiCheckCircle size={12} />
|
||||||
|
Angebot erstellt
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-yellow-400 bg-yellow-500/10 border border-yellow-500/30 rounded px-2 py-1">
|
||||||
|
⚠️ Angebot wurde als Entwurf erstellt.<br/>
|
||||||
|
Bitte in LexOffice suchen und freigeben, um PDF herunterzuladen.
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => downloadPDF('quotation')}
|
||||||
|
className="w-full flex items-center justify-center gap-1 px-2 py-1 bg-green-600/20 hover:bg-green-600/30 text-green-400 text-xs rounded transition-colors"
|
||||||
|
>
|
||||||
|
<FiDownload size={12} />
|
||||||
|
PDF Download
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="https://app.lexoffice.de/#!sales/quotations"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="w-full flex items-center justify-center gap-1 px-2 py-1 bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 text-xs rounded transition-colors"
|
||||||
|
>
|
||||||
|
<FiFileText size={12} />
|
||||||
|
Angebot in LexOffice suchen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-gray-500">Nicht erstellt</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email Sent */}
|
||||||
|
<div className={`p-4 rounded-lg border ${booking.contractSentAt ? 'bg-green-500/10 border-green-500/50' : 'bg-gray-700/50 border-gray-600'}`}>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<FiMail className={booking.contractSentAt ? 'text-green-400' : 'text-gray-500'} />
|
||||||
|
<span className={`text-sm font-medium ${booking.contractSentAt ? 'text-green-400' : 'text-gray-400'}`}>
|
||||||
|
E-Mail
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{booking.contractSentAt ? (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-green-400">
|
||||||
|
<FiCheckCircle size={12} />
|
||||||
|
Versendet
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-gray-500">Nicht versendet</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar Synced */}
|
||||||
|
<div className={`p-4 rounded-lg border ${booking.calendarSynced ? 'bg-green-500/10 border-green-500/50' : 'bg-gray-700/50 border-gray-600'}`}>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<FiCalendar className={booking.calendarSynced ? 'text-green-400' : 'text-gray-500'} />
|
||||||
|
<span className={`text-sm font-medium ${booking.calendarSynced ? 'text-green-400' : 'text-gray-400'}`}>
|
||||||
|
Kalender
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{booking.calendarSynced ? (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-green-400">
|
||||||
|
<FiCheckCircle size={12} />
|
||||||
|
Synchronisiert
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-gray-500">Nicht synchronisiert</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LexOffice Workflow Timeline */}
|
||||||
|
<div className="mb-6 bg-gray-800/50 border border-gray-700 rounded-lg p-6">
|
||||||
|
<h4 className="text-sm font-bold text-gray-300 mb-4">LexOffice Workflow</h4>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{/* Step 1: Angebot */}
|
||||||
|
<div className="flex flex-col items-center flex-1">
|
||||||
|
<div className={`w-16 h-16 rounded-full flex items-center justify-center mb-2 ${
|
||||||
|
booking.lexofficeOfferId
|
||||||
|
? 'bg-green-500/20 border-2 border-green-500'
|
||||||
|
: 'bg-gray-700 border-2 border-gray-600'
|
||||||
|
}`}>
|
||||||
|
{booking.lexofficeOfferId ? (
|
||||||
|
<FiCheckCircle className="text-green-400 text-2xl" />
|
||||||
|
) : (
|
||||||
|
<FiClock className="text-gray-400 text-2xl" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-medium mb-1 ${booking.lexofficeOfferId ? 'text-green-400' : 'text-gray-400'}`}>
|
||||||
|
Angebot
|
||||||
|
</span>
|
||||||
|
{booking.lexofficeOfferId && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => downloadPDF('quotation')}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 bg-green-600/20 hover:bg-green-600/30 text-green-400 text-xs rounded transition-colors"
|
||||||
|
>
|
||||||
|
<FiDownload size={10} />
|
||||||
|
PDF
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="https://app.lexoffice.de/#!sales/quotations"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 px-2 py-1 bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 text-xs rounded transition-colors justify-center"
|
||||||
|
>
|
||||||
|
<FiFileText size={10} />
|
||||||
|
Angebot in LexOffice suchen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connector Line */}
|
||||||
|
<div className={`h-0.5 flex-1 mx-2 ${booking.lexofficeOfferId ? 'bg-green-500' : 'bg-gray-600'}`}></div>
|
||||||
|
|
||||||
|
{/* Step 2: Auftragsbestätigung */}
|
||||||
|
<div className="flex flex-col items-center flex-1">
|
||||||
|
<div className={`w-16 h-16 rounded-full flex items-center justify-center mb-2 ${
|
||||||
|
booking.lexofficeConfirmationId
|
||||||
|
? 'bg-green-500/20 border-2 border-green-500'
|
||||||
|
: booking.lexofficeOfferId
|
||||||
|
? 'bg-yellow-500/20 border-2 border-yellow-500'
|
||||||
|
: 'bg-gray-700 border-2 border-gray-600'
|
||||||
|
}`}>
|
||||||
|
{booking.lexofficeConfirmationId ? (
|
||||||
|
<FiCheckCircle className="text-green-400 text-2xl" />
|
||||||
|
) : booking.lexofficeOfferId ? (
|
||||||
|
<FiClock className="text-yellow-400 text-2xl" />
|
||||||
|
) : (
|
||||||
|
<FiClock className="text-gray-400 text-2xl" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-medium mb-1 ${
|
||||||
|
booking.lexofficeConfirmationId
|
||||||
|
? 'text-green-400'
|
||||||
|
: booking.lexofficeOfferId
|
||||||
|
? 'text-yellow-400'
|
||||||
|
: 'text-gray-400'
|
||||||
|
}`}>
|
||||||
|
Auftragsbestätigung
|
||||||
|
</span>
|
||||||
|
{booking.lexofficeConfirmationId && (
|
||||||
|
<button
|
||||||
|
onClick={() => downloadPDF('confirmation')}
|
||||||
|
className="mt-1 flex items-center gap-1 px-2 py-1 bg-green-600/20 hover:bg-green-600/30 text-green-400 text-xs rounded transition-colors"
|
||||||
|
>
|
||||||
|
<FiDownload size={10} />
|
||||||
|
PDF
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connector Line */}
|
||||||
|
<div className={`h-0.5 flex-1 mx-2 ${booking.lexofficeConfirmationId ? 'bg-green-500' : 'bg-gray-600'}`}></div>
|
||||||
|
|
||||||
|
{/* Step 3: Rechnung */}
|
||||||
|
<div className="flex flex-col items-center flex-1">
|
||||||
|
<div className={`w-16 h-16 rounded-full flex items-center justify-center mb-2 ${
|
||||||
|
booking.lexofficeInvoiceId
|
||||||
|
? 'bg-green-500/20 border-2 border-green-500'
|
||||||
|
: booking.lexofficeConfirmationId
|
||||||
|
? 'bg-yellow-500/20 border-2 border-yellow-500'
|
||||||
|
: 'bg-gray-700 border-2 border-gray-600'
|
||||||
|
}`}>
|
||||||
|
{booking.lexofficeInvoiceId ? (
|
||||||
|
<FiCheckCircle className="text-green-400 text-2xl" />
|
||||||
|
) : booking.lexofficeConfirmationId ? (
|
||||||
|
<FiClock className="text-yellow-400 text-2xl" />
|
||||||
|
) : (
|
||||||
|
<FiClock className="text-gray-400 text-2xl" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-medium mb-1 ${
|
||||||
|
booking.lexofficeInvoiceId
|
||||||
|
? 'text-green-400'
|
||||||
|
: booking.lexofficeConfirmationId
|
||||||
|
? 'text-yellow-400'
|
||||||
|
: 'text-gray-400'
|
||||||
|
}`}>
|
||||||
|
Rechnung
|
||||||
|
</span>
|
||||||
|
{booking.lexofficeInvoiceId && (
|
||||||
|
<button
|
||||||
|
className="mt-1 flex items-center gap-1 px-2 py-1 bg-green-600/20 hover:bg-green-600/30 text-green-400 text-xs rounded transition-colors"
|
||||||
|
>
|
||||||
|
<FiDownload size={10} />
|
||||||
|
PDF
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Run Automation Button */}
|
||||||
|
{!hasRunAutomation && (
|
||||||
|
<button
|
||||||
|
onClick={runAutomation}
|
||||||
|
disabled={isRunning}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-gradient-to-r from-pink-600 to-red-600 text-white rounded-lg hover:from-pink-700 hover:to-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg"
|
||||||
|
>
|
||||||
|
{isRunning ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||||
|
Automation läuft...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FiPlay />
|
||||||
|
Automation starten
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirm Booking Button */}
|
||||||
|
{canConfirm && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-green-400 bg-green-500/10 border border-green-500/50 rounded-lg p-3">
|
||||||
|
<FiCheckCircle />
|
||||||
|
<span>Vertrag wurde unterschrieben am {new Date(booking.contractSignedAt!).toLocaleDateString('de-DE')}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={confirmBooking}
|
||||||
|
disabled={isConfirming}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-gradient-to-r from-green-600 to-emerald-600 text-white rounded-lg hover:from-green-700 hover:to-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg"
|
||||||
|
>
|
||||||
|
{isConfirming ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||||
|
Bestätige Buchung...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FiCheck />
|
||||||
|
Buchung bestätigen (RESERVED → CONFIRMED)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{booking.status === 'CONFIRMED' && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-blue-400 bg-blue-500/10 border border-blue-500/50 rounded-lg p-3">
|
||||||
|
<FiCheckCircle />
|
||||||
|
<span>Buchung ist bestätigt</span>
|
||||||
|
{booking.lexofficeConfirmationId && (
|
||||||
|
<span className="ml-auto text-xs text-gray-400">
|
||||||
|
LexOffice AB: {booking.lexofficeConfirmationId.slice(0, 8)}...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Result Display */}
|
||||||
|
{result && (
|
||||||
|
<div className="mt-6 p-4 bg-green-500/10 border border-green-500/50 rounded-lg">
|
||||||
|
<h4 className="text-sm font-bold text-green-400 mb-2">✅ Automation erfolgreich!</h4>
|
||||||
|
<div className="space-y-1 text-xs text-gray-300">
|
||||||
|
<div>Contract PDF: {result.contractGenerated ? '✅' : '❌'}</div>
|
||||||
|
<div>LexOffice: {result.lexofficeCreated ? '✅' : '❌'}</div>
|
||||||
|
<div>E-Mail: {result.emailSent ? '✅' : '❌'}</div>
|
||||||
|
<div>Kalender: {result.calendarSynced ? '✅' : '❌'}</div>
|
||||||
|
{result.errors?.length > 0 && (
|
||||||
|
<div className="mt-2 text-red-400">
|
||||||
|
Fehler: {result.errors.join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{error && (
|
||||||
|
<div className="mt-6 p-4 bg-red-500/10 border border-red-500/50 rounded-lg flex items-start gap-2">
|
||||||
|
<FiAlertCircle className="text-red-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-red-400 mb-1">Fehler</h4>
|
||||||
|
<p className="text-xs text-gray-300">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ export default function DashboardSidebar({ user }: DashboardSidebarProps) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-64 bg-gradient-to-br from-gray-800 to-gray-900 border-r border-gray-700 shadow-lg min-h-screen">
|
<aside className="w-64 bg-gradient-to-br from-gray-800 to-gray-900 border-r border-gray-700 shadow-lg min-h-screen flex flex-col">
|
||||||
<div className="p-6 border-b border-gray-700">
|
<div className="p-6 border-b border-gray-700">
|
||||||
<h1 className="text-xl font-bold text-white">
|
<h1 className="text-xl font-bold text-white">
|
||||||
SaveTheMoment <span className="text-red-400">Atlas</span>
|
SaveTheMoment <span className="text-red-400">Atlas</span>
|
||||||
@@ -50,7 +50,7 @@ export default function DashboardSidebar({ user }: DashboardSidebarProps) {
|
|||||||
<p className="text-sm text-gray-400 mt-1">{user?.name || 'Benutzer'}</p>
|
<p className="text-sm text-gray-400 mt-1">{user?.name || 'Benutzer'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="p-4 space-y-2">
|
<nav className="p-4 space-y-2 flex-1 overflow-y-auto">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const active = isActive(item.href);
|
const active = isActive(item.href);
|
||||||
@@ -104,12 +104,12 @@ export default function DashboardSidebar({ user }: DashboardSidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="absolute bottom-4 left-4 right-4">
|
<div className="p-4 border-t border-gray-700">
|
||||||
<button
|
<button
|
||||||
onClick={() => signOut({ callbackUrl: '/' })}
|
onClick={() => signOut({ callbackUrl: '/' })}
|
||||||
className="flex items-center gap-3 px-4 py-3 text-gray-300 hover:bg-red-500/20 hover:text-red-400 rounded-lg w-full transition-colors"
|
className="flex items-center justify-center gap-2 px-4 py-2 text-sm text-gray-300 hover:bg-red-500/20 hover:text-red-400 rounded-lg transition-colors border border-gray-700 hover:border-red-500/50 w-full"
|
||||||
>
|
>
|
||||||
<FiLogOut /> Abmelden
|
<FiLogOut size={16} /> Abmelden
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { FiCalendar, FiMapPin } from "react-icons/fi";
|
import { FiCalendar, FiMapPin } from "react-icons/fi";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -17,6 +17,21 @@ export default function NewBookingForm({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [equipmentList, setEquipmentList] = useState<any[]>([]);
|
||||||
|
const [selectedEquipment, setSelectedEquipment] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/equipment")
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => setEquipmentList(data.equipment || []))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleEquipment = (id: string) => {
|
||||||
|
setSelectedEquipment((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((e) => e !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
locationId: "",
|
locationId: "",
|
||||||
@@ -51,7 +66,7 @@ export default function NewBookingForm({
|
|||||||
const res = await fetch("/api/bookings/create", {
|
const res = await fetch("/api/bookings/create", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(formData),
|
body: JSON.stringify({ ...formData, equipmentIds: selectedEquipment }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -125,6 +140,7 @@ export default function NewBookingForm({
|
|||||||
required
|
required
|
||||||
className="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"
|
className="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"
|
||||||
>
|
>
|
||||||
|
<option value="VINTAGE">Vintage</option>
|
||||||
<option value="VINTAGE_SMILE">Vintage Smile</option>
|
<option value="VINTAGE_SMILE">Vintage Smile</option>
|
||||||
<option value="VINTAGE_PHOTOS">Vintage Photos</option>
|
<option value="VINTAGE_PHOTOS">Vintage Photos</option>
|
||||||
<option value="NOSTALGIE">Nostalgie</option>
|
<option value="NOSTALGIE">Nostalgie</option>
|
||||||
@@ -132,6 +148,34 @@ export default function NewBookingForm({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{equipmentList.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Zusatzausstattung
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{equipmentList.map((eq) => (
|
||||||
|
<label
|
||||||
|
key={eq.id}
|
||||||
|
className={`flex items-center gap-2 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||||
|
selectedEquipment.includes(eq.id)
|
||||||
|
? "bg-red-500/10 border-red-500/50 text-white"
|
||||||
|
: "bg-gray-700/50 border-gray-600 text-gray-300 hover:border-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedEquipment.includes(eq.id)}
|
||||||
|
onChange={() => toggleEquipment(eq.id)}
|
||||||
|
className="accent-red-500"
|
||||||
|
/>
|
||||||
|
{eq.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -229,6 +273,49 @@ export default function NewBookingForm({
|
|||||||
className="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"
|
className="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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Adresse
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.customerAddress}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, customerAddress: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Straße und Hausnummer"
|
||||||
|
className="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 placeholder-gray-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
PLZ
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.customerZip}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, customerZip: e.target.value })
|
||||||
|
}
|
||||||
|
className="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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Stadt
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.customerCity}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, customerCity: e.target.value })
|
||||||
|
}
|
||||||
|
className="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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -362,6 +449,34 @@ export default function NewBookingForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Abbau ab
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={formData.dismantleTimeEarliest}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, dismantleTimeEarliest: e.target.value })
|
||||||
|
}
|
||||||
|
className="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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Abbau spätestens
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={formData.dismantleTimeLatest}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, dismantleTimeLatest: e.target.value })
|
||||||
|
}
|
||||||
|
className="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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
Notizen (optional)
|
Notizen (optional)
|
||||||
|
|||||||
81
cron-email-sync.ts
Normal file
81
cron-email-sync.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import cron from 'node-cron';
|
||||||
|
import { emailSyncService } from '../lib/email-sync';
|
||||||
|
import { prisma } from '../lib/prisma';
|
||||||
|
|
||||||
|
console.log('🚀 E-Mail-Sync Cron-Job gestartet...\n');
|
||||||
|
|
||||||
|
// Sync alle 5 Minuten
|
||||||
|
cron.schedule('*/5 * * * *', async () => {
|
||||||
|
console.log(`\n⏰ [${new Date().toLocaleString('de-DE')}] Starte E-Mail-Sync...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const locations = await prisma.location.findMany({
|
||||||
|
where: { emailSyncEnabled: true },
|
||||||
|
select: { id: true, name: true, slug: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (locations.length === 0) {
|
||||||
|
console.log('⚠️ Keine Locations mit aktiviertem E-Mail-Sync gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📍 Sync für ${locations.length} Location(s)...\n`);
|
||||||
|
|
||||||
|
let totalNewEmails = 0;
|
||||||
|
let totalNewBookings = 0;
|
||||||
|
|
||||||
|
for (const location of locations) {
|
||||||
|
console.log(`🔄 ${location.name} (${location.slug})...`);
|
||||||
|
|
||||||
|
const result = await emailSyncService.syncLocationEmails(location.id);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log(` ✅ ${result.newEmails} neue E-Mails, ${result.newBookings} neue Buchungen`);
|
||||||
|
totalNewEmails += result.newEmails;
|
||||||
|
totalNewBookings += result.newBookings;
|
||||||
|
} else {
|
||||||
|
console.log(` ❌ Fehler: ${result.errors.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n📊 Gesamt: ${totalNewEmails} E-Mails, ${totalNewBookings} Buchungen`);
|
||||||
|
console.log('─'.repeat(60));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Cron-Job Fehler:', error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Cron-Job läuft - E-Mails werden alle 5 Minuten abgerufen');
|
||||||
|
console.log(' Drücken Sie CTRL+C zum Beenden\n');
|
||||||
|
|
||||||
|
// Sofort einmal ausführen beim Start
|
||||||
|
(async () => {
|
||||||
|
console.log('🔄 Initialer E-Mail-Sync beim Start...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const locations = await prisma.location.findMany({
|
||||||
|
where: { emailSyncEnabled: true },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const location of locations) {
|
||||||
|
console.log(`📍 ${location.name}...`);
|
||||||
|
const result = await emailSyncService.syncLocationEmails(location.id);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log(` ✅ ${result.newEmails} E-Mails, ${result.newBookings} Buchungen\n`);
|
||||||
|
} else {
|
||||||
|
console.log(` ❌ ${result.errors.join(', ')}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Initialer Sync Fehler:', error.message);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
console.log('\n\n👋 Beende E-Mail-Sync Cron-Job...');
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
189
lib/booking-automation.ts
Normal file
189
lib/booking-automation.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { prisma } from './prisma';
|
||||||
|
import { sendInitialBookingEmail } from './email-service';
|
||||||
|
import { nextcloudCalendar } from './nextcloud-calendar';
|
||||||
|
import { lexofficeService } from './lexoffice';
|
||||||
|
import { generateContractFromTemplate } from './pdf-template-service';
|
||||||
|
|
||||||
|
export class BookingAutomationService {
|
||||||
|
async runPostBookingActions(bookingId: string): Promise<{
|
||||||
|
emailSent: boolean;
|
||||||
|
calendarSynced: boolean;
|
||||||
|
lexofficeCreated: boolean;
|
||||||
|
contractGenerated: boolean;
|
||||||
|
errors: string[];
|
||||||
|
}> {
|
||||||
|
const errors: string[] = [];
|
||||||
|
let emailSent = false;
|
||||||
|
let calendarSynced = false;
|
||||||
|
let lexofficeCreated = false;
|
||||||
|
let contractGenerated = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const booking = await prisma.booking.findUnique({
|
||||||
|
where: { id: bookingId },
|
||||||
|
include: {
|
||||||
|
location: true,
|
||||||
|
photobox: true,
|
||||||
|
bookingEquipment: {
|
||||||
|
include: {
|
||||||
|
equipment: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
errors.push('Buchung nicht gefunden');
|
||||||
|
return { emailSent, calendarSynced, lexofficeCreated, contractGenerated, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`🤖 Automatische Aktionen für Buchung ${booking.bookingNumber}...`);
|
||||||
|
|
||||||
|
let quotationPdf: Buffer | null = null;
|
||||||
|
let contractPdf: Buffer | null = null;
|
||||||
|
|
||||||
|
// 1. LexOffice Contact + Quotation erstellen
|
||||||
|
try {
|
||||||
|
console.log(' 💼 Erstelle LexOffice-Kontakt und Angebot...');
|
||||||
|
|
||||||
|
const contactId = await lexofficeService.createContactFromBooking(bookingWithPriceConfig);
|
||||||
|
console.log(` ✅ LexOffice-Kontakt erstellt: ${contactId}`);
|
||||||
|
|
||||||
|
const quotationId = await lexofficeService.createQuotationFromBooking(bookingWithPriceConfig, contactId);
|
||||||
|
console.log(` ✅ LexOffice-Angebot erstellt: ${quotationId}`);
|
||||||
|
|
||||||
|
await prisma.booking.update({
|
||||||
|
where: { id: booking.id },
|
||||||
|
data: {
|
||||||
|
lexofficeContactId: contactId,
|
||||||
|
lexofficeOfferId: quotationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
quotationPdf = await lexofficeService.getQuotationPDF(quotationId);
|
||||||
|
console.log(' ✅ Angebots-PDF heruntergeladen');
|
||||||
|
|
||||||
|
lexofficeCreated = true;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(' ❌ LexOffice Fehler:', error.message);
|
||||||
|
errors.push(`LexOffice: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Mietvertrag-PDF generieren
|
||||||
|
try {
|
||||||
|
console.log(' 📄 Generiere Mietvertrag-PDF...');
|
||||||
|
|
||||||
|
contractPdf = await generateContractFromTemplate(
|
||||||
|
bookingWithPriceConfig,
|
||||||
|
booking.location,
|
||||||
|
booking.photobox
|
||||||
|
);
|
||||||
|
|
||||||
|
await prisma.booking.update({
|
||||||
|
where: { id: booking.id },
|
||||||
|
data: {
|
||||||
|
contractGenerated: true,
|
||||||
|
contractGeneratedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(' ✅ Mietvertrag-PDF generiert');
|
||||||
|
contractGenerated = true;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(' ❌ PDF-Generierung Fehler:', error.message);
|
||||||
|
errors.push(`PDF: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. E-Mail mit Angebot + Vertrag versenden
|
||||||
|
if (quotationPdf && contractPdf) {
|
||||||
|
try {
|
||||||
|
console.log(' 📧 Sende E-Mail mit Angebot und Vertrag...');
|
||||||
|
|
||||||
|
await sendInitialBookingEmail(bookingWithPriceConfig, quotationPdf, contractPdf);
|
||||||
|
|
||||||
|
await prisma.booking.update({
|
||||||
|
where: { id: booking.id },
|
||||||
|
data: {
|
||||||
|
contractSentAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
emailSent = true;
|
||||||
|
console.log(' ✅ E-Mail gesendet');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(' ❌ E-Mail Fehler:', error.message);
|
||||||
|
errors.push(`E-Mail: ${error.message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(' ⚠️ E-Mail nicht gesendet - PDFs fehlen');
|
||||||
|
errors.push('E-Mail: PDFs nicht verfügbar');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Automatischer Nextcloud Kalender-Sync
|
||||||
|
try {
|
||||||
|
console.log(' 📅 Synchronisiere mit Nextcloud-Kalender...');
|
||||||
|
|
||||||
|
await nextcloudCalendar.syncBookingToCalendar(booking);
|
||||||
|
|
||||||
|
await prisma.booking.update({
|
||||||
|
where: { id: booking.id },
|
||||||
|
data: {
|
||||||
|
calendarSynced: true,
|
||||||
|
calendarSyncedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
calendarSynced = true;
|
||||||
|
console.log(' ✅ Kalender synchronisiert');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(' ❌ Kalender-Sync Fehler:', error.message);
|
||||||
|
errors.push(`Kalender: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Admin-Benachrichtigung erstellen
|
||||||
|
try {
|
||||||
|
await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
type: 'NEW_BOOKING',
|
||||||
|
title: 'Neue Buchungsanfrage',
|
||||||
|
message: `${booking.customerName} hat eine ${booking.photobox?.model || 'Fotobox'} für ${booking.eventCity} am ${new Date(booking.eventDate).toLocaleDateString('de-DE')} angefragt.`,
|
||||||
|
metadata: {
|
||||||
|
bookingId: booking.id,
|
||||||
|
bookingNumber: booking.bookingNumber,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(' ✅ Admin-Benachrichtigung erstellt');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(' ❌ Notification Fehler:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Automatische Aktionen abgeschlossen (${errors.length} Fehler)`);
|
||||||
|
|
||||||
|
return { emailSent, calendarSynced, lexofficeCreated, contractGenerated, errors };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Booking Automation Fehler:', error);
|
||||||
|
errors.push(error.message);
|
||||||
|
return { emailSent, calendarSynced, lexofficeCreated, contractGenerated, errors };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bookingAutomationService = new BookingAutomationService();
|
||||||
88
lib/distance-calculator.ts
Normal file
88
lib/distance-calculator.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
interface Coordinates {
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DistanceResult {
|
||||||
|
distance: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DistanceCalculator {
|
||||||
|
private static readonly OSRM_API = 'https://router.project-osrm.org/route/v1/driving';
|
||||||
|
|
||||||
|
static async geocodeAddress(address: string): Promise<Coordinates | null> {
|
||||||
|
try {
|
||||||
|
const nominatimUrl = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address)}&limit=1`;
|
||||||
|
|
||||||
|
const response = await fetch(nominatimUrl, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'SaveTheMoment-Atlas/1.0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Geocoding failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
console.warn(`No results found for address: ${address}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lat: parseFloat(data[0].lat),
|
||||||
|
lon: parseFloat(data[0].lon),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Geocoding error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async calculateDistance(
|
||||||
|
fromAddress: string,
|
||||||
|
toAddress: string
|
||||||
|
): Promise<DistanceResult | null> {
|
||||||
|
try {
|
||||||
|
const fromCoords = await this.geocodeAddress(fromAddress);
|
||||||
|
const toCoords = await this.geocodeAddress(toAddress);
|
||||||
|
|
||||||
|
if (!fromCoords || !toCoords) {
|
||||||
|
console.error('Failed to geocode one or both addresses');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${this.OSRM_API}/${fromCoords.lon},${fromCoords.lat};${toCoords.lon},${toCoords.lat}?overview=false`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`OSRM API failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.routes || data.routes.length === 0) {
|
||||||
|
console.error('No route found');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = data.routes[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
distance: Math.round(route.distance / 1000 * 100) / 100,
|
||||||
|
duration: Math.round(route.duration / 60),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Distance calculation error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static formatAddress(address: string, zip: string, city: string): string {
|
||||||
|
return `${address}, ${zip} ${city}`.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,34 +2,25 @@ import nodemailer from 'nodemailer';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
|
|
||||||
let transporter: nodemailer.Transporter | null = null;
|
interface LocationSmtpConfig {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
secure: boolean;
|
||||||
|
from: string;
|
||||||
|
}
|
||||||
|
|
||||||
function getTransporter() {
|
function createTransporter(config: LocationSmtpConfig) {
|
||||||
if (transporter) return transporter;
|
return nodemailer.createTransport({
|
||||||
|
host: config.host,
|
||||||
const smtpHost = process.env.SMTP_HOST;
|
port: config.port,
|
||||||
const smtpPort = parseInt(process.env.SMTP_PORT || '587');
|
secure: config.secure,
|
||||||
const smtpUser = process.env.SMTP_USER;
|
|
||||||
const smtpPass = process.env.SMTP_PASS;
|
|
||||||
const smtpFrom = process.env.SMTP_FROM || 'noreply@savethemoment.photos';
|
|
||||||
|
|
||||||
if (!smtpHost || !smtpUser || !smtpPass) {
|
|
||||||
console.warn('⚠️ SMTP credentials not configured. Email sending disabled.');
|
|
||||||
throw new Error('SMTP not configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
transporter = nodemailer.createTransport({
|
|
||||||
host: smtpHost,
|
|
||||||
port: smtpPort,
|
|
||||||
secure: smtpPort === 465,
|
|
||||||
auth: {
|
auth: {
|
||||||
user: smtpUser,
|
user: config.user,
|
||||||
pass: smtpPass,
|
pass: config.password,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('✅ SMTP transporter initialized');
|
|
||||||
return transporter;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SendEmailOptions {
|
interface SendEmailOptions {
|
||||||
@@ -37,6 +28,7 @@ interface SendEmailOptions {
|
|||||||
subject: string;
|
subject: string;
|
||||||
text: string;
|
text: string;
|
||||||
html: string;
|
html: string;
|
||||||
|
smtpConfig: LocationSmtpConfig;
|
||||||
attachments?: {
|
attachments?: {
|
||||||
filename: string;
|
filename: string;
|
||||||
content?: Buffer;
|
content?: Buffer;
|
||||||
@@ -45,21 +37,59 @@ interface SendEmailOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function sendEmail(options: SendEmailOptions) {
|
export async function sendEmail(options: SendEmailOptions) {
|
||||||
|
const emailEnabled = process.env.EMAIL_ENABLED !== 'false';
|
||||||
|
const testMode = process.env.TEST_MODE === 'true';
|
||||||
|
const testRecipient = process.env.TEST_EMAIL_RECIPIENT;
|
||||||
|
|
||||||
|
// E-Mail komplett deaktiviert
|
||||||
|
if (!emailEnabled) {
|
||||||
|
console.log('📧 [EMAIL DISABLED] E-Mail würde gesendet an:', options.to);
|
||||||
|
console.log(' Betreff:', options.subject);
|
||||||
|
console.log(' Von:', options.smtpConfig.from);
|
||||||
|
console.log(' ⚠️ EMAIL_ENABLED=false - Kein echter Versand!');
|
||||||
|
return { success: true, messageId: 'test-disabled', mode: 'disabled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test-Modus: Umleitung an Test-E-Mail
|
||||||
|
let actualRecipient = options.to;
|
||||||
|
if (testMode && testRecipient) {
|
||||||
|
console.log('🧪 [TEST MODE] E-Mail umgeleitet!');
|
||||||
|
console.log(' Original-Empfänger:', options.to);
|
||||||
|
console.log(' Test-Empfänger:', testRecipient);
|
||||||
|
actualRecipient = testRecipient;
|
||||||
|
|
||||||
|
// Füge Hinweis in Betreff ein
|
||||||
|
options.subject = `[TEST] ${options.subject}`;
|
||||||
|
|
||||||
|
// Füge Hinweis in E-Mail ein
|
||||||
|
options.html = `
|
||||||
|
<div style="background: #FEF3C7; border: 2px solid #F59E0B; padding: 15px; margin-bottom: 20px; border-radius: 8px;">
|
||||||
|
<p style="margin: 0; color: #92400E; font-weight: bold;">🧪 TEST-MODUS AKTIV</p>
|
||||||
|
<p style="margin: 5px 0 0 0; color: #92400E; font-size: 14px;">
|
||||||
|
Diese E-Mail wäre ursprünglich an <strong>${options.to}</strong> gegangen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
${options.html}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const transport = getTransporter();
|
const transport = createTransporter(options.smtpConfig);
|
||||||
const from = process.env.SMTP_FROM || 'SaveTheMoment <noreply@savethemoment.photos>';
|
|
||||||
|
|
||||||
const info = await transport.sendMail({
|
const info = await transport.sendMail({
|
||||||
from,
|
from: options.smtpConfig.from,
|
||||||
to: options.to,
|
to: actualRecipient,
|
||||||
subject: options.subject,
|
subject: options.subject,
|
||||||
text: options.text,
|
text: options.text,
|
||||||
html: options.html,
|
html: options.html,
|
||||||
attachments: options.attachments,
|
attachments: options.attachments,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('✅ Email sent:', info.messageId);
|
console.log('✅ Email sent:', info.messageId, 'from:', options.smtpConfig.from);
|
||||||
return { success: true, messageId: info.messageId };
|
if (testMode) {
|
||||||
|
console.log(' 🧪 TEST MODE - E-Mail an:', actualRecipient, '(Original:', options.to, ')');
|
||||||
|
}
|
||||||
|
return { success: true, messageId: info.messageId, mode: testMode ? 'test' : 'production' };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('❌ Email send error:', error);
|
console.error('❌ Email send error:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -70,6 +100,21 @@ export async function sendContractEmail(
|
|||||||
booking: any,
|
booking: any,
|
||||||
contractPdfPath: string
|
contractPdfPath: string
|
||||||
) {
|
) {
|
||||||
|
const location = booking.location;
|
||||||
|
|
||||||
|
if (!location || !location.smtpHost || !location.smtpPassword) {
|
||||||
|
throw new Error(`SMTP not configured for location: ${location?.name || 'Unknown'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const smtpConfig: LocationSmtpConfig = {
|
||||||
|
host: location.smtpHost,
|
||||||
|
port: location.smtpPort || 465,
|
||||||
|
user: location.smtpUser || location.contactEmail,
|
||||||
|
password: location.smtpPassword,
|
||||||
|
secure: location.smtpSecure !== false,
|
||||||
|
from: `SaveTheMoment ${location.name} <${location.contactEmail}>`,
|
||||||
|
};
|
||||||
|
|
||||||
const signToken = Buffer.from(`${booking.id}-${Date.now()}`).toString('base64url');
|
const signToken = Buffer.from(`${booking.id}-${Date.now()}`).toString('base64url');
|
||||||
const signUrl = `${process.env.NEXTAUTH_URL}/contract/sign/${signToken}`;
|
const signUrl = `${process.env.NEXTAUTH_URL}/contract/sign/${signToken}`;
|
||||||
|
|
||||||
@@ -130,14 +175,14 @@ export async function sendContractEmail(
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>🎉 SaveTheMoment</h1>
|
<h1>🎉 SaveTheMoment ${location.name}</h1>
|
||||||
<p>Ihr Mietvertrag ist bereit!</p>
|
<p>Ihr Mietvertrag ist bereit!</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<p>Hallo ${booking.customerName},</p>
|
<p>Hallo ${booking.customerName},</p>
|
||||||
|
|
||||||
<p>vielen Dank für Ihre Buchung bei SaveTheMoment! Wir freuen uns sehr, Teil Ihres besonderen Anlasses zu sein.</p>
|
<p>vielen Dank für Ihre Buchung bei SaveTheMoment ${location.name}! Wir freuen uns sehr, Teil Ihres besonderen Anlasses zu sein.</p>
|
||||||
|
|
||||||
<div class="details">
|
<div class="details">
|
||||||
<h3>📋 Buchungsdetails</h3>
|
<h3>📋 Buchungsdetails</h3>
|
||||||
@@ -171,13 +216,13 @@ export async function sendContractEmail(
|
|||||||
<p>Bei Fragen stehen wir Ihnen jederzeit gerne zur Verfügung!</p>
|
<p>Bei Fragen stehen wir Ihnen jederzeit gerne zur Verfügung!</p>
|
||||||
|
|
||||||
<p>Mit freundlichen Grüßen<br>
|
<p>Mit freundlichen Grüßen<br>
|
||||||
Ihr SaveTheMoment Team</p>
|
Ihr SaveTheMoment Team ${location.name}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<p>SaveTheMoment Fotoboxen<br>
|
<p>SaveTheMoment ${location.name}<br>
|
||||||
E-Mail: info@savethemoment.photos<br>
|
E-Mail: ${location.contactEmail}<br>
|
||||||
Web: www.savethemoment.photos</p>
|
Web: ${location.websiteUrl || 'www.savethemoment.photos'}</p>
|
||||||
<p style="color: #999; font-size: 11px;">
|
<p style="color: #999; font-size: 11px;">
|
||||||
Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht direkt auf diese E-Mail.
|
Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht direkt auf diese E-Mail.
|
||||||
</p>
|
</p>
|
||||||
@@ -189,7 +234,7 @@ export async function sendContractEmail(
|
|||||||
const text = `
|
const text = `
|
||||||
Hallo ${booking.customerName},
|
Hallo ${booking.customerName},
|
||||||
|
|
||||||
vielen Dank für Ihre Buchung bei SaveTheMoment!
|
vielen Dank für Ihre Buchung bei SaveTheMoment ${location.name}!
|
||||||
|
|
||||||
Buchungsdetails:
|
Buchungsdetails:
|
||||||
- Buchungsnummer: ${booking.bookingNumber}
|
- Buchungsnummer: ${booking.bookingNumber}
|
||||||
@@ -206,12 +251,12 @@ Oder drucken Sie ihn aus und senden Sie ihn uns zurück.
|
|||||||
Bei Fragen stehen wir Ihnen jederzeit zur Verfügung!
|
Bei Fragen stehen wir Ihnen jederzeit zur Verfügung!
|
||||||
|
|
||||||
Mit freundlichen Grüßen
|
Mit freundlichen Grüßen
|
||||||
Ihr SaveTheMoment Team
|
Ihr SaveTheMoment Team ${location.name}
|
||||||
|
|
||||||
---
|
---
|
||||||
SaveTheMoment Fotoboxen
|
SaveTheMoment ${location.name}
|
||||||
E-Mail: info@savethemoment.photos
|
E-Mail: ${location.contactEmail}
|
||||||
Web: www.savethemoment.photos
|
Web: ${location.websiteUrl || 'www.savethemoment.photos'}
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
let pdfBuffer: Buffer;
|
let pdfBuffer: Buffer;
|
||||||
@@ -227,6 +272,7 @@ Web: www.savethemoment.photos
|
|||||||
subject,
|
subject,
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
|
smtpConfig,
|
||||||
attachments: [
|
attachments: [
|
||||||
{
|
{
|
||||||
filename: `Mietvertrag_${booking.bookingNumber}.pdf`,
|
filename: `Mietvertrag_${booking.bookingNumber}.pdf`,
|
||||||
@@ -236,7 +282,234 @@ Web: www.savethemoment.photos
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendInitialBookingEmail(
|
||||||
|
booking: any,
|
||||||
|
quotationPdf: Buffer,
|
||||||
|
contractPdf: Buffer
|
||||||
|
) {
|
||||||
|
const location = booking.location;
|
||||||
|
|
||||||
|
if (!location || !location.smtpHost || !location.smtpPassword) {
|
||||||
|
throw new Error(`SMTP not configured for location: ${location?.name || 'Unknown'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const smtpConfig: LocationSmtpConfig = {
|
||||||
|
host: location.smtpHost,
|
||||||
|
port: location.smtpPort || 465,
|
||||||
|
user: location.smtpUser || location.contactEmail,
|
||||||
|
password: location.smtpPassword,
|
||||||
|
secure: location.smtpSecure !== false,
|
||||||
|
from: `SaveTheMoment ${location.name} <${location.contactEmail}>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const signToken = Buffer.from(`${booking.id}-${Date.now()}`).toString('base64url');
|
||||||
|
const signUrl = `${process.env.NEXTAUTH_URL}/contract/sign/${signToken}`;
|
||||||
|
|
||||||
|
const subject = `Ihre Anfrage bei SaveTheMoment ${location.name} - ${booking.bookingNumber}`;
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #DC2626 0%, #EC4899 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 10px 10px 0 0;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
background: #f9fafb;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 0 0 10px 10px;
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(135deg, #DC2626 0%, #EC4899 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 15px 30px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.details {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-left: 4px solid #DC2626;
|
||||||
|
}
|
||||||
|
.price-box {
|
||||||
|
background: #FEF3C7;
|
||||||
|
border: 2px solid #F59E0B;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>🎉 SaveTheMoment ${location.name}</h1>
|
||||||
|
<p>Vielen Dank für Ihre Anfrage!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>Hallo ${booking.customerName},</p>
|
||||||
|
|
||||||
|
<p>herzlichen Dank für Ihre Anfrage! Wir freuen uns sehr, dass Sie sich für SaveTheMoment entschieden haben.</p>
|
||||||
|
|
||||||
|
<div class="details">
|
||||||
|
<h3>📋 Ihre Buchungsdetails</h3>
|
||||||
|
<p><strong>Buchungsnummer:</strong> ${booking.bookingNumber}</p>
|
||||||
|
<p><strong>Event-Datum:</strong> ${new Date(booking.eventDate).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
})}</p>
|
||||||
|
<p><strong>Event-Location:</strong> ${booking.eventLocation || booking.eventAddress}</p>
|
||||||
|
<p><strong>Fotobox:</strong> ${booking.photobox?.model || 'N/A'}</p>
|
||||||
|
${booking.distance ? `<p><strong>Entfernung:</strong> ${booking.distance.toFixed(1)} km (einfach)</p>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${booking.calculatedPrice ? `
|
||||||
|
<div class="price-box">
|
||||||
|
<h3 style="margin: 0 0 10px 0; color: #92400E;">💰 Gesamtpreis</h3>
|
||||||
|
<p style="font-size: 28px; font-weight: bold; margin: 0; color: #92400E;">
|
||||||
|
${booking.calculatedPrice.toFixed(2)} €
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 14px; margin: 5px 0 0 0; color: #92400E;">inkl. 19% MwSt.</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<p><strong>📎 Im Anhang finden Sie:</strong></p>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Ihr persönliches Angebot</strong> mit allen Details und Positionen</li>
|
||||||
|
<li><strong>Ihren Mietvertrag</strong> zum Durchlesen</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p><strong>✅ Nächste Schritte:</strong></p>
|
||||||
|
<ol>
|
||||||
|
<li>Prüfen Sie bitte das Angebot und den Mietvertrag</li>
|
||||||
|
<li>Signieren Sie den Vertrag online oder laden Sie ihn unterschrieben hoch</li>
|
||||||
|
<li>Nach Ihrer Unterschrift wird Ihre Buchung verbindlich bestätigt</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<center>
|
||||||
|
<a href="${signUrl}" class="button">
|
||||||
|
✍️ Vertrag jetzt online signieren
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
|
||||||
|
<p>Alternativ können Sie den Vertrag auch ausdrucken, unterschreiben und uns per E-Mail zurücksenden.</p>
|
||||||
|
|
||||||
|
<p><strong>Haben Sie Fragen oder Änderungswünsche?</strong><br>
|
||||||
|
Antworten Sie einfach auf diese E-Mail – wir sind für Sie da!</p>
|
||||||
|
|
||||||
|
<p>Mit freundlichen Grüßen<br>
|
||||||
|
Ihr SaveTheMoment Team ${location.name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>SaveTheMoment ${location.name}<br>
|
||||||
|
E-Mail: ${location.contactEmail}<br>
|
||||||
|
Web: ${location.websiteUrl || 'www.savethemoment.photos'}</p>
|
||||||
|
<p style="color: #999; font-size: 11px;">
|
||||||
|
Diese E-Mail wurde automatisch generiert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const text = `
|
||||||
|
Hallo ${booking.customerName},
|
||||||
|
|
||||||
|
vielen Dank für Ihre Anfrage bei SaveTheMoment ${location.name}!
|
||||||
|
|
||||||
|
Buchungsdetails:
|
||||||
|
- Buchungsnummer: ${booking.bookingNumber}
|
||||||
|
- Event-Datum: ${new Date(booking.eventDate).toLocaleDateString('de-DE')}
|
||||||
|
- Location: ${booking.eventLocation || booking.eventAddress}
|
||||||
|
- Fotobox: ${booking.photobox?.model || 'N/A'}
|
||||||
|
${booking.distance ? `- Entfernung: ${booking.distance.toFixed(1)} km` : ''}
|
||||||
|
${booking.calculatedPrice ? `\nGesamtpreis: ${booking.calculatedPrice.toFixed(2)} € (inkl. 19% MwSt.)` : ''}
|
||||||
|
|
||||||
|
Im Anhang finden Sie:
|
||||||
|
1. Ihr persönliches Angebot
|
||||||
|
2. Ihren Mietvertrag
|
||||||
|
|
||||||
|
Nächste Schritte:
|
||||||
|
1. Prüfen Sie bitte das Angebot und den Mietvertrag
|
||||||
|
2. Signieren Sie den Vertrag online: ${signUrl}
|
||||||
|
3. Nach Ihrer Unterschrift wird Ihre Buchung verbindlich bestätigt
|
||||||
|
|
||||||
|
Bei Fragen stehen wir Ihnen jederzeit zur Verfügung!
|
||||||
|
|
||||||
|
Mit freundlichen Grüßen
|
||||||
|
Ihr SaveTheMoment Team ${location.name}
|
||||||
|
|
||||||
|
---
|
||||||
|
SaveTheMoment ${location.name}
|
||||||
|
E-Mail: ${location.contactEmail}
|
||||||
|
Web: ${location.websiteUrl || 'www.savethemoment.photos'}
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
return sendEmail({
|
||||||
|
to: booking.customerEmail,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
smtpConfig,
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: `Angebot_${booking.bookingNumber}.pdf`,
|
||||||
|
content: quotationPdf,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filename: `Mietvertrag_${booking.bookingNumber}.pdf`,
|
||||||
|
content: contractPdf,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendBookingConfirmationEmail(booking: any) {
|
export async function sendBookingConfirmationEmail(booking: any) {
|
||||||
|
const location = booking.location;
|
||||||
|
|
||||||
|
if (!location || !location.smtpHost || !location.smtpPassword) {
|
||||||
|
throw new Error(`SMTP not configured for location: ${location?.name || 'Unknown'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const smtpConfig: LocationSmtpConfig = {
|
||||||
|
host: location.smtpHost,
|
||||||
|
port: location.smtpPort || 465,
|
||||||
|
user: location.smtpUser || location.contactEmail,
|
||||||
|
password: location.smtpPassword,
|
||||||
|
secure: location.smtpSecure !== false,
|
||||||
|
from: `SaveTheMoment ${location.name} <${location.contactEmail}>`,
|
||||||
|
};
|
||||||
|
|
||||||
const subject = `Buchungsbestätigung - ${booking.bookingNumber}`;
|
const subject = `Buchungsbestätigung - ${booking.bookingNumber}`;
|
||||||
|
|
||||||
const html = `
|
const html = `
|
||||||
@@ -257,7 +530,7 @@ export async function sendBookingConfirmationEmail(booking: any) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<p>Hallo ${booking.customerName},</p>
|
<p>Hallo ${booking.customerName},</p>
|
||||||
<p>Ihre Buchung wurde erfolgreich bestätigt!</p>
|
<p>Ihre Buchung bei SaveTheMoment ${location.name} wurde erfolgreich bestätigt!</p>
|
||||||
<div class="details">
|
<div class="details">
|
||||||
<h3>Buchungsdetails</h3>
|
<h3>Buchungsdetails</h3>
|
||||||
<p><strong>Buchungsnummer:</strong> ${booking.bookingNumber}</p>
|
<p><strong>Buchungsnummer:</strong> ${booking.bookingNumber}</p>
|
||||||
@@ -265,7 +538,7 @@ export async function sendBookingConfirmationEmail(booking: any) {
|
|||||||
<p><strong>Location:</strong> ${booking.eventLocation || booking.eventAddress}</p>
|
<p><strong>Location:</strong> ${booking.eventLocation || booking.eventAddress}</p>
|
||||||
</div>
|
</div>
|
||||||
<p>Wir freuen uns auf Ihr Event!</p>
|
<p>Wir freuen uns auf Ihr Event!</p>
|
||||||
<p>Mit freundlichen Grüßen<br>Ihr SaveTheMoment Team</p>
|
<p>Mit freundlichen Grüßen<br>Ihr SaveTheMoment Team ${location.name}</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -274,7 +547,7 @@ export async function sendBookingConfirmationEmail(booking: any) {
|
|||||||
const text = `
|
const text = `
|
||||||
Hallo ${booking.customerName},
|
Hallo ${booking.customerName},
|
||||||
|
|
||||||
Ihre Buchung wurde erfolgreich bestätigt!
|
Ihre Buchung bei SaveTheMoment ${location.name} wurde erfolgreich bestätigt!
|
||||||
|
|
||||||
Buchungsnummer: ${booking.bookingNumber}
|
Buchungsnummer: ${booking.bookingNumber}
|
||||||
Event-Datum: ${new Date(booking.eventDate).toLocaleDateString('de-DE')}
|
Event-Datum: ${new Date(booking.eventDate).toLocaleDateString('de-DE')}
|
||||||
@@ -283,7 +556,7 @@ Location: ${booking.eventLocation || booking.eventAddress}
|
|||||||
Wir freuen uns auf Ihr Event!
|
Wir freuen uns auf Ihr Event!
|
||||||
|
|
||||||
Mit freundlichen Grüßen
|
Mit freundlichen Grüßen
|
||||||
Ihr SaveTheMoment Team
|
Ihr SaveTheMoment Team ${location.name}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return sendEmail({
|
return sendEmail({
|
||||||
@@ -291,5 +564,6 @@ Ihr SaveTheMoment Team
|
|||||||
subject,
|
subject,
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
|
smtpConfig,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Imap from 'imap';
|
|||||||
import { simpleParser } from 'mailparser';
|
import { simpleParser } from 'mailparser';
|
||||||
import { prisma } from './prisma';
|
import { prisma } from './prisma';
|
||||||
import { emailParser } from './email-parser';
|
import { emailParser } from './email-parser';
|
||||||
|
import { bookingAutomationService } from './booking-automation';
|
||||||
|
|
||||||
export interface ImapConfig {
|
export interface ImapConfig {
|
||||||
host: string;
|
host: string;
|
||||||
@@ -335,6 +336,12 @@ export class EmailSyncService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🤖 Automatische Post-Booking Aktionen
|
||||||
|
console.log('📢 Starte automatische Aktionen...');
|
||||||
|
bookingAutomationService.runPostBookingActions(booking.id).catch(err => {
|
||||||
|
console.error('⚠️ Automatische Aktionen fehlgeschlagen:', err);
|
||||||
|
});
|
||||||
|
|
||||||
return booking;
|
return booking;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create booking from parsed data:', error);
|
console.error('Failed to create booking from parsed data:', error);
|
||||||
|
|||||||
260
lib/lexoffice.ts
260
lib/lexoffice.ts
@@ -146,14 +146,22 @@ export class LexOfficeService {
|
|||||||
return this.request('GET', `/contacts/${contactId}`);
|
return this.request('GET', `/contacts/${contactId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createQuotation(quotation: LexOfficeQuotation): Promise<{ id: string; resourceUri: string; createdDate: string; updatedDate: string; voucherNumber: string }> {
|
async createQuotation(quotation: LexOfficeQuotation, finalize: boolean = false): Promise<{ id: string; resourceUri: string; createdDate: string; updatedDate: string; voucherNumber: string }> {
|
||||||
return this.request('POST', '/quotations', quotation);
|
const url = finalize ? '/quotations?finalize=true' : '/quotations';
|
||||||
|
console.log(`📍 Creating quotation with URL: ${this.baseUrl}${url}, finalize=${finalize}`);
|
||||||
|
return this.request('POST', url, quotation);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getQuotation(quotationId: string): Promise<LexOfficeQuotation> {
|
async getQuotation(quotationId: string): Promise<LexOfficeQuotation> {
|
||||||
return this.request('GET', `/quotations/${quotationId}`);
|
return this.request('GET', `/quotations/${quotationId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async finalizeQuotation(quotationId: string): Promise<{ id: string; resourceUri: string }> {
|
||||||
|
return this.request('PUT', `/quotations/${quotationId}/pursue`, {
|
||||||
|
precedingSalesVoucherId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async createInvoice(invoice: LexOfficeInvoice): Promise<{ id: string; resourceUri: string; createdDate: string; updatedDate: string; voucherNumber: string }> {
|
async createInvoice(invoice: LexOfficeInvoice): Promise<{ id: string; resourceUri: string; createdDate: string; updatedDate: string; voucherNumber: string }> {
|
||||||
return this.request('POST', '/invoices', invoice);
|
return this.request('POST', '/invoices', invoice);
|
||||||
}
|
}
|
||||||
@@ -169,6 +177,52 @@ export class LexOfficeService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getQuotationPDF(quotationId: string): Promise<Buffer> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/quotations/${quotationId}/document`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${this.apiKey}`,
|
||||||
|
'Accept': 'application/pdf',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`LexOffice PDF Error: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
return Buffer.from(arrayBuffer);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LexOffice PDF download failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInvoicePDF(invoiceId: string): Promise<Buffer> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/invoices/${invoiceId}/document`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${this.apiKey}`,
|
||||||
|
'Accept': 'application/pdf',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`LexOffice PDF Error: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
return Buffer.from(arrayBuffer);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LexOffice PDF download failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async createContactFromBooking(booking: any): Promise<string> {
|
async createContactFromBooking(booking: any): Promise<string> {
|
||||||
const contact: Partial<LexOfficeContact> = {
|
const contact: Partial<LexOfficeContact> = {
|
||||||
roles: {
|
roles: {
|
||||||
@@ -191,21 +245,26 @@ export class LexOfficeService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (booking.invoiceType === 'BUSINESS' && booking.companyName) {
|
if (booking.invoiceType === 'BUSINESS' && booking.companyName) {
|
||||||
|
const nameParts = booking.customerName.trim().split(' ');
|
||||||
|
const firstName = nameParts[0] || '';
|
||||||
|
const lastName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : nameParts[0] || 'Ansprechpartner';
|
||||||
contact.company = {
|
contact.company = {
|
||||||
name: booking.companyName,
|
name: booking.companyName,
|
||||||
contactPersons: [{
|
contactPersons: [{
|
||||||
firstName: booking.customerName.split(' ')[0],
|
firstName: firstName,
|
||||||
lastName: booking.customerName.split(' ').slice(1).join(' '),
|
lastName: lastName,
|
||||||
primary: true,
|
primary: true,
|
||||||
emailAddress: booking.customerEmail,
|
emailAddress: booking.customerEmail,
|
||||||
phoneNumber: booking.customerPhone,
|
phoneNumber: booking.customerPhone,
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const [firstName, ...lastNameParts] = booking.customerName.split(' ');
|
const nameParts = booking.customerName.trim().split(' ');
|
||||||
|
const firstName = nameParts[0] || '';
|
||||||
|
const lastName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : nameParts[0] || 'Unbekannt';
|
||||||
contact.person = {
|
contact.person = {
|
||||||
firstName: firstName,
|
firstName: firstName,
|
||||||
lastName: lastNameParts.join(' '),
|
lastName: lastName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,46 +273,203 @@ export class LexOfficeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createQuotationFromBooking(booking: any, contactId: string): Promise<string> {
|
async createQuotationFromBooking(booking: any, contactId: string): Promise<string> {
|
||||||
const quotation: LexOfficeQuotation = {
|
const lineItems: Array<any> = [];
|
||||||
voucherDate: new Date().toISOString().split('T')[0],
|
|
||||||
address: {
|
console.log('📊 Booking Data:', {
|
||||||
contactId: contactId,
|
id: booking.id,
|
||||||
countryCode: 'DE',
|
bookingNumber: booking.bookingNumber,
|
||||||
|
hasPhotobox: !!booking.photobox,
|
||||||
|
photoboxModel: booking.photobox?.model,
|
||||||
|
locationId: booking.locationId,
|
||||||
|
withPrintFlat: booking.withPrintFlat,
|
||||||
|
});
|
||||||
|
|
||||||
|
// WICHTIG: priceConfig separat laden, da keine direkte Relation existiert
|
||||||
|
let priceConfig = booking.priceConfig;
|
||||||
|
|
||||||
|
if (!priceConfig && booking.photobox?.model && booking.locationId) {
|
||||||
|
console.log('🔍 Lade PriceConfig nach...');
|
||||||
|
const { prisma } = await import('./prisma');
|
||||||
|
priceConfig = await prisma.priceConfig.findUnique({
|
||||||
|
where: {
|
||||||
|
locationId_model: {
|
||||||
|
locationId: booking.locationId,
|
||||||
|
model: booking.photobox.model,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('📊 PriceConfig geladen:', priceConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!priceConfig) {
|
||||||
|
throw new Error('Keine Preiskonfiguration gefunden. Bitte konfiguriere zuerst die Preise für dieses Fotobox-Modell.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Fotobox als Hauptposition (IMMER als custom lineItem)
|
||||||
|
const withPrintFlat = booking.withPrintFlat !== false; // Default: true
|
||||||
|
const boxName = booking.photobox?.model || 'Fotobox';
|
||||||
|
const flatSuffix = withPrintFlat ? ' mit Druckflatrate' : ' (nur digital)';
|
||||||
|
|
||||||
|
const photoboxItem: any = {
|
||||||
|
type: 'custom',
|
||||||
|
name: `${boxName}${flatSuffix}`,
|
||||||
|
description: `Event am ${new Date(booking.eventDate).toLocaleDateString('de-DE')} in ${booking.eventCity}`,
|
||||||
|
quantity: 1,
|
||||||
|
unitName: 'Stück',
|
||||||
|
unitPrice: {
|
||||||
|
currency: 'EUR',
|
||||||
|
netAmount: priceConfig?.basePrice || 1,
|
||||||
|
taxRatePercentage: 19,
|
||||||
},
|
},
|
||||||
lineItems: [
|
};
|
||||||
{
|
|
||||||
|
lineItems.push(photoboxItem);
|
||||||
|
|
||||||
|
console.log('📦 Photobox LineItem (custom):', photoboxItem);
|
||||||
|
|
||||||
|
// 2. Kilometerpauschale (falls Distanz vorhanden)
|
||||||
|
if (booking.distance && booking.distance > 0) {
|
||||||
|
// 2a. Kilometer-Pauschale (bis X km) - IMMER als custom lineItem
|
||||||
|
if (priceConfig?.kmFlatRate && priceConfig.kmFlatRate > 0) {
|
||||||
|
const kmFlatItem: any = {
|
||||||
type: 'custom',
|
type: 'custom',
|
||||||
name: 'Fotobox-Vermietung',
|
name: `Kilometerpauschale (bis ${priceConfig.kmFlatRateUpTo}km)`,
|
||||||
description: `Event am ${new Date(booking.eventDate).toLocaleDateString('de-DE')} in ${booking.eventCity}`,
|
description: `Entfernung: ${booking.distance.toFixed(1)}km`,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
|
unitName: 'Pauschale',
|
||||||
|
unitPrice: {
|
||||||
|
currency: 'EUR',
|
||||||
|
netAmount: priceConfig.kmFlatRate / 1.19,
|
||||||
|
taxRatePercentage: 19,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
lineItems.push(kmFlatItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b. Zusätzliche Kilometer (wenn über Flatrate) - IMMER als custom lineItem
|
||||||
|
if (priceConfig && booking.distance > priceConfig.kmFlatRateUpTo) {
|
||||||
|
const extraKm = booking.distance - priceConfig.kmFlatRateUpTo;
|
||||||
|
const totalExtraKm = extraKm * priceConfig.kmMultiplier;
|
||||||
|
|
||||||
|
const kmExtraItem: any = {
|
||||||
|
type: 'custom',
|
||||||
|
name: `Zusatzkilometer`,
|
||||||
|
description: `${extraKm.toFixed(1)}km × ${priceConfig.kmMultiplier} Strecken = ${totalExtraKm.toFixed(1)}km`,
|
||||||
|
quantity: totalExtraKm,
|
||||||
|
unitName: 'km',
|
||||||
|
unitPrice: {
|
||||||
|
currency: 'EUR',
|
||||||
|
netAmount: priceConfig.pricePerKm,
|
||||||
|
taxRatePercentage: 19,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
lineItems.push(kmExtraItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Equipment/Extras (wenn vorhanden) - IMMER als custom lineItem
|
||||||
|
if (booking.bookingEquipment && booking.bookingEquipment.length > 0) {
|
||||||
|
for (const eq of booking.bookingEquipment) {
|
||||||
|
const equipmentItem: any = {
|
||||||
|
type: 'custom',
|
||||||
|
name: eq.equipment.name,
|
||||||
|
quantity: eq.quantity || 1,
|
||||||
unitName: 'Stück',
|
unitName: 'Stück',
|
||||||
unitPrice: {
|
unitPrice: {
|
||||||
currency: 'EUR',
|
currency: 'EUR',
|
||||||
netAmount: booking.calculatedPrice || 0,
|
netAmount: eq.equipment.price || 1,
|
||||||
taxRatePercentage: 19,
|
taxRatePercentage: 19,
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
],
|
|
||||||
|
lineItems.push(equipmentItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(now.getDate()).padStart(2, '0');
|
||||||
|
|
||||||
|
const isDST = (date: Date) => {
|
||||||
|
const jan = new Date(date.getFullYear(), 0, 1).getTimezoneOffset();
|
||||||
|
const jul = new Date(date.getFullYear(), 6, 1).getTimezoneOffset();
|
||||||
|
return Math.max(jan, jul) !== date.getTimezoneOffset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const timezone = isDST(now) ? '+02:00' : '+01:00';
|
||||||
|
const voucherDate = `${year}-${month}-${day}T00:00:00.000${timezone}`;
|
||||||
|
|
||||||
|
const expirationDateObj = new Date(now);
|
||||||
|
expirationDateObj.setDate(expirationDateObj.getDate() + 14);
|
||||||
|
const expYear = expirationDateObj.getFullYear();
|
||||||
|
const expMonth = String(expirationDateObj.getMonth() + 1).padStart(2, '0');
|
||||||
|
const expDay = String(expirationDateObj.getDate()).padStart(2, '0');
|
||||||
|
const expirationDate = `${expYear}-${expMonth}-${expDay}T00:00:00.000${timezone}`;
|
||||||
|
|
||||||
|
const quotation: LexOfficeQuotation = {
|
||||||
|
voucherDate,
|
||||||
|
expirationDate,
|
||||||
|
address: {
|
||||||
|
contactId: contactId,
|
||||||
|
name: booking.customerName,
|
||||||
|
street: booking.customerAddress || undefined,
|
||||||
|
zip: booking.customerZip || undefined,
|
||||||
|
city: booking.customerCity || undefined,
|
||||||
|
countryCode: 'DE',
|
||||||
|
},
|
||||||
|
lineItems,
|
||||||
totalPrice: {
|
totalPrice: {
|
||||||
currency: 'EUR',
|
currency: 'EUR',
|
||||||
},
|
},
|
||||||
taxConditions: {
|
taxConditions: {
|
||||||
taxType: 'net',
|
taxType: 'net',
|
||||||
},
|
},
|
||||||
title: `Angebot Fotobox-Vermietung - ${booking.bookingNumber}`,
|
|
||||||
introduction: 'Vielen Dank für Ihre Anfrage! Gerne erstellen wir Ihnen folgendes Angebot:',
|
introduction: 'Vielen Dank für Ihre Anfrage! Gerne erstellen wir Ihnen folgendes Angebot:',
|
||||||
remark: 'Wir freuen uns auf Ihre Bestellung!',
|
remark: 'Wir freuen uns auf Ihre Bestellung!',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await this.createQuotation(quotation);
|
console.log('📤 Sending to LexOffice:', JSON.stringify(quotation, null, 2));
|
||||||
|
|
||||||
|
// Schritt 1: Erstelle Quotation als Draft
|
||||||
|
const result = await this.createQuotation(quotation, false);
|
||||||
|
console.log('✅ Quotation created (draft):', result.id);
|
||||||
|
|
||||||
|
// Schritt 2: Finalisiere Quotation sofort
|
||||||
|
try {
|
||||||
|
console.log('🔄 Finalizing quotation...');
|
||||||
|
await this.finalizeQuotation(result.id);
|
||||||
|
console.log('✅ Quotation finalized to OPEN status');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('⚠️ Quotation finalization failed:', error.message);
|
||||||
|
console.log('ℹ️ Quotation bleibt im DRAFT status');
|
||||||
|
}
|
||||||
|
|
||||||
return result.id;
|
return result.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createConfirmationFromBooking(booking: any, contactId: string): Promise<string> {
|
async createConfirmationFromBooking(booking: any, contactId: string): Promise<string> {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(now.getDate()).padStart(2, '0');
|
||||||
|
|
||||||
|
const isDST = (date: Date) => {
|
||||||
|
const jan = new Date(date.getFullYear(), 0, 1).getTimezoneOffset();
|
||||||
|
const jul = new Date(date.getFullYear(), 6, 1).getTimezoneOffset();
|
||||||
|
return Math.max(jan, jul) !== date.getTimezoneOffset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const timezone = isDST(now) ? '+02:00' : '+01:00';
|
||||||
|
const voucherDate = `${year}-${month}-${day}T00:00:00.000${timezone}`;
|
||||||
|
|
||||||
const invoice: LexOfficeInvoice = {
|
const invoice: LexOfficeInvoice = {
|
||||||
voucherDate: new Date().toISOString().split('T')[0],
|
voucherDate,
|
||||||
address: {
|
address: {
|
||||||
contactId: contactId,
|
contactId: contactId,
|
||||||
|
name: booking.customerName,
|
||||||
countryCode: 'DE',
|
countryCode: 'DE',
|
||||||
},
|
},
|
||||||
lineItems: [
|
lineItems: [
|
||||||
@@ -276,7 +492,7 @@ export class LexOfficeService {
|
|||||||
taxConditions: {
|
taxConditions: {
|
||||||
taxType: 'net',
|
taxType: 'net',
|
||||||
},
|
},
|
||||||
title: `Auftragsbestätigung - ${booking.bookingNumber}`,
|
title: `AB ${booking.bookingNumber}`,
|
||||||
introduction: 'Vielen Dank für Ihre Bestellung! Hiermit bestätigen wir Ihren Auftrag:',
|
introduction: 'Vielen Dank für Ihre Bestellung! Hiermit bestätigen wir Ihren Auftrag:',
|
||||||
remark: 'Wir freuen uns auf Ihre Veranstaltung!',
|
remark: 'Wir freuen uns auf Ihre Veranstaltung!',
|
||||||
shippingConditions: {
|
shippingConditions: {
|
||||||
|
|||||||
@@ -13,19 +13,46 @@ export interface CalendarEvent {
|
|||||||
export class NextcloudCalendarService {
|
export class NextcloudCalendarService {
|
||||||
private client: any;
|
private client: any;
|
||||||
private initialized: boolean = false;
|
private initialized: boolean = false;
|
||||||
|
private initPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
if (this.initialized) return;
|
// Wenn bereits am Initialisieren, warte auf Abschluss
|
||||||
|
if (this.initPromise) {
|
||||||
|
return this.initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn bereits initialisiert, fertig
|
||||||
|
if (this.initialized && this.client) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neue Initialisierung starten
|
||||||
|
this.initPromise = this._doInitialize();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.initPromise;
|
||||||
|
} finally {
|
||||||
|
this.initPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _doInitialize() {
|
||||||
const serverUrl = process.env.NEXTCLOUD_URL;
|
const serverUrl = process.env.NEXTCLOUD_URL;
|
||||||
const username = process.env.NEXTCLOUD_USERNAME;
|
const username = process.env.NEXTCLOUD_USERNAME;
|
||||||
const password = process.env.NEXTCLOUD_PASSWORD;
|
const password = process.env.NEXTCLOUD_PASSWORD;
|
||||||
|
|
||||||
|
console.log('🔍 Nextcloud credentials check:');
|
||||||
|
console.log(' URL:', serverUrl);
|
||||||
|
console.log(' Username:', username);
|
||||||
|
console.log(' Password length:', password?.length, 'chars');
|
||||||
|
|
||||||
if (!serverUrl || !username || !password) {
|
if (!serverUrl || !username || !password) {
|
||||||
throw new Error('Nextcloud credentials not configured');
|
throw new Error('Nextcloud credentials not configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('⏳ Creating Nextcloud CalDAV client...');
|
||||||
|
|
||||||
this.client = await createDAVClient({
|
this.client = await createDAVClient({
|
||||||
serverUrl: `${serverUrl}/remote.php/dav`,
|
serverUrl: `${serverUrl}/remote.php/dav`,
|
||||||
credentials: {
|
credentials: {
|
||||||
@@ -37,10 +64,12 @@ export class NextcloudCalendarService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
console.log('✅ Nextcloud CalDAV client initialized');
|
console.log('✅ Nextcloud CalDAV client initialized successfully');
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('❌ Failed to initialize Nextcloud CalDAV client:', error);
|
console.error('❌ Failed to initialize Nextcloud CalDAV client:', error);
|
||||||
throw error;
|
this.initialized = false;
|
||||||
|
this.client = null;
|
||||||
|
throw new Error(`Nextcloud initialization failed: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,22 +149,33 @@ export class NextcloudCalendarService {
|
|||||||
throw new Error('No calendars found in Nextcloud');
|
throw new Error('No calendars found in Nextcloud');
|
||||||
}
|
}
|
||||||
|
|
||||||
const calendar = calendars[0];
|
// Suche nach "Buchungen" Kalender, sonst verwende ersten
|
||||||
|
let calendar = calendars.find((cal: any) =>
|
||||||
|
cal.displayName?.toLowerCase().includes('buchung')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
console.warn('⚠️ Kein "Buchungen"-Kalender gefunden, verwende:', calendars[0].displayName);
|
||||||
|
calendar = calendars[0];
|
||||||
|
} else {
|
||||||
|
console.log('✅ Verwende Kalender:', calendar.displayName);
|
||||||
|
}
|
||||||
|
|
||||||
const event: CalendarEvent = {
|
const event: CalendarEvent = {
|
||||||
uid: `savethemoment-booking-${booking.id}`,
|
uid: `savethemoment-booking-${booking.id}`,
|
||||||
summary: `${booking.customerName} - ${booking.location?.name || 'Unbekannt'}`,
|
summary: `${booking.customerName} - ${booking.location?.name || 'Unbekannt'}`,
|
||||||
description: `
|
description: `
|
||||||
Buchung #${booking.id}
|
Buchung #${booking.bookingNumber || booking.id}
|
||||||
Kunde: ${booking.customerName}
|
Kunde: ${booking.customerName}
|
||||||
E-Mail: ${booking.customerEmail}
|
E-Mail: ${booking.customerEmail}
|
||||||
Telefon: ${booking.customerPhone || 'N/A'}
|
Telefon: ${booking.customerPhone || 'N/A'}
|
||||||
Event-Typ: ${booking.eventType}
|
Event-Location: ${booking.eventLocation || booking.eventAddress}
|
||||||
Status: ${booking.status}
|
Status: ${booking.status}
|
||||||
Fotobox: ${booking.photobox?.name || 'Keine Box'}
|
Fotobox: ${booking.photobox?.model || 'Keine Box'}
|
||||||
Standort: ${booking.location?.name || 'Unbekannt'}
|
Standort: ${booking.location?.name || 'Unbekannt'}
|
||||||
|
Preis: ${booking.calculatedPrice || 0}€
|
||||||
`.trim(),
|
`.trim(),
|
||||||
location: booking.location?.address || '',
|
location: `${booking.eventAddress || ''}, ${booking.eventZip || ''} ${booking.eventCity || ''}`.trim(),
|
||||||
startDate: new Date(booking.eventDate),
|
startDate: new Date(booking.eventDate),
|
||||||
endDate: new Date(new Date(booking.eventDate).getTime() + 4 * 60 * 60 * 1000),
|
endDate: new Date(new Date(booking.eventDate).getTime() + 4 * 60 * 60 * 1000),
|
||||||
status: booking.status,
|
status: booking.status,
|
||||||
@@ -143,10 +183,12 @@ Standort: ${booking.location?.name || 'Unbekannt'}
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await this.createEvent(calendar.url, event);
|
await this.createEvent(calendar.url, event);
|
||||||
|
console.log('✅ Event in Nextcloud erstellt:', event.summary);
|
||||||
return event;
|
return event;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (error.message?.includes('already exists') || error.response?.status === 412) {
|
if (error.message?.includes('already exists') || error.response?.status === 412) {
|
||||||
await this.updateEvent(calendar.url, event);
|
await this.updateEvent(calendar.url, event);
|
||||||
|
console.log('✅ Event in Nextcloud aktualisiert:', event.summary);
|
||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -162,11 +204,20 @@ Standort: ${booking.location?.name || 'Unbekannt'}
|
|||||||
throw new Error('No calendars found in Nextcloud');
|
throw new Error('No calendars found in Nextcloud');
|
||||||
}
|
}
|
||||||
|
|
||||||
const calendar = calendars[0];
|
// Suche nach "Buchungen" Kalender, sonst verwende ersten
|
||||||
|
let calendar = calendars.find((cal: any) =>
|
||||||
|
cal.displayName?.toLowerCase().includes('buchung')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
calendar = calendars[0];
|
||||||
|
}
|
||||||
|
|
||||||
const eventUid = `savethemoment-booking-${bookingId}`;
|
const eventUid = `savethemoment-booking-${bookingId}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.deleteEvent(calendar.url, eventUid);
|
await this.deleteEvent(calendar.url, eventUid);
|
||||||
|
console.log('✅ Event aus Nextcloud gelöscht:', eventUid);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error removing booking from calendar:', error);
|
console.error('Error removing booking from calendar:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
108
lib/price-calculator.ts
Normal file
108
lib/price-calculator.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
interface PriceConfig {
|
||||||
|
basePrice: number;
|
||||||
|
kmFlatRate: number;
|
||||||
|
kmFlatRateUpTo: number;
|
||||||
|
pricePerKm: number;
|
||||||
|
kmMultiplier: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PriceBreakdown {
|
||||||
|
basePrice: number;
|
||||||
|
kmFlatRate: number;
|
||||||
|
kmAdditionalNet: number;
|
||||||
|
kmAdditionalGross: number;
|
||||||
|
kmTotalGross: number;
|
||||||
|
totalPrice: number;
|
||||||
|
distance?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PriceCalculator {
|
||||||
|
private static readonly VAT_RATE = 1.19;
|
||||||
|
|
||||||
|
static calculateKmCharge(
|
||||||
|
distance: number,
|
||||||
|
config: PriceConfig
|
||||||
|
): {
|
||||||
|
kmTotalGross: number;
|
||||||
|
kmFlatRate: number;
|
||||||
|
kmAdditionalNet: number;
|
||||||
|
kmAdditionalGross: number;
|
||||||
|
} {
|
||||||
|
const { kmFlatRate, kmFlatRateUpTo, pricePerKm, kmMultiplier } = config;
|
||||||
|
|
||||||
|
if (distance <= kmFlatRateUpTo) {
|
||||||
|
return {
|
||||||
|
kmTotalGross: kmFlatRate,
|
||||||
|
kmFlatRate: kmFlatRate,
|
||||||
|
kmAdditionalNet: 0,
|
||||||
|
kmAdditionalGross: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraKm = distance - kmFlatRateUpTo;
|
||||||
|
const kmAdditionalNet = extraKm * kmMultiplier * pricePerKm;
|
||||||
|
const kmAdditionalGross = Math.round(kmAdditionalNet * this.VAT_RATE * 100) / 100;
|
||||||
|
const kmTotalGross = Math.round((kmFlatRate + kmAdditionalGross) * 100) / 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
kmTotalGross,
|
||||||
|
kmFlatRate,
|
||||||
|
kmAdditionalNet: Math.round(kmAdditionalNet * 100) / 100,
|
||||||
|
kmAdditionalGross,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static calculateTotalPrice(
|
||||||
|
basePrice: number,
|
||||||
|
distance: number | null | undefined,
|
||||||
|
config: PriceConfig
|
||||||
|
): PriceBreakdown {
|
||||||
|
if (!distance || distance <= 0) {
|
||||||
|
return {
|
||||||
|
basePrice,
|
||||||
|
kmFlatRate: 0,
|
||||||
|
kmAdditionalNet: 0,
|
||||||
|
kmAdditionalGross: 0,
|
||||||
|
kmTotalGross: 0,
|
||||||
|
totalPrice: basePrice,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const kmCharge = this.calculateKmCharge(distance, config);
|
||||||
|
const totalPrice = Math.round((basePrice + kmCharge.kmTotalGross) * 100) / 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
basePrice,
|
||||||
|
kmFlatRate: kmCharge.kmFlatRate,
|
||||||
|
kmAdditionalNet: kmCharge.kmAdditionalNet,
|
||||||
|
kmAdditionalGross: kmCharge.kmAdditionalGross,
|
||||||
|
kmTotalGross: kmCharge.kmTotalGross,
|
||||||
|
totalPrice,
|
||||||
|
distance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static formatPriceBreakdown(breakdown: PriceBreakdown): string {
|
||||||
|
const lines = [
|
||||||
|
`Grundpreis Fotobox: ${breakdown.basePrice.toFixed(2)}€`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (breakdown.distance && breakdown.distance > 0) {
|
||||||
|
lines.push(`\nKilometerpauschale (${breakdown.distance.toFixed(1)}km):`);
|
||||||
|
|
||||||
|
if (breakdown.kmFlatRate > 0) {
|
||||||
|
lines.push(` - Pauschale: ${breakdown.kmFlatRate.toFixed(2)}€`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (breakdown.kmAdditionalGross > 0) {
|
||||||
|
lines.push(` - Zusätzlich: ${breakdown.kmAdditionalGross.toFixed(2)}€`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(` = Gesamt KM: ${breakdown.kmTotalGross.toFixed(2)}€`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`\nGesamtpreis: ${breakdown.totalPrice.toFixed(2)}€`);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
18
package-lock.json
generated
18
package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"@google-cloud/vision": "^5.3.4",
|
"@google-cloud/vision": "^5.3.4",
|
||||||
"@prisma/client": "^5.19.0",
|
"@prisma/client": "^5.19.0",
|
||||||
"@react-pdf/renderer": "^4.3.1",
|
"@react-pdf/renderer": "^4.3.1",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"mailparser": "^3.7.1",
|
"mailparser": "^3.7.1",
|
||||||
"next": "^14.2.0",
|
"next": "^14.2.0",
|
||||||
"next-auth": "^4.24.0",
|
"next-auth": "^4.24.0",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
"openai": "^6.8.1",
|
"openai": "^6.8.1",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
@@ -1485,6 +1487,12 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node-cron": {
|
||||||
|
"version": "3.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
|
||||||
|
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@@ -4305,7 +4313,6 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -6135,6 +6142,15 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-cron": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-domexception": {
|
"node_modules/node-domexception": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@google-cloud/vision": "^5.3.4",
|
"@google-cloud/vision": "^5.3.4",
|
||||||
"@prisma/client": "^5.19.0",
|
"@prisma/client": "^5.19.0",
|
||||||
"@react-pdf/renderer": "^4.3.1",
|
"@react-pdf/renderer": "^4.3.1",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"mailparser": "^3.7.1",
|
"mailparser": "^3.7.1",
|
||||||
"next": "^14.2.0",
|
"next": "^14.2.0",
|
||||||
"next-auth": "^4.24.0",
|
"next-auth": "^4.24.0",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
"openai": "^6.8.1",
|
"openai": "^6.8.1",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'DRIVER');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "BookingStatus" AS ENUM ('RESERVED', 'CONFIRMED', 'READY_FOR_ASSIGNMENT', 'OPEN_FOR_DRIVERS', 'ASSIGNED', 'COMPLETED', 'CANCELLED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PhotoboxStatus" AS ENUM ('AVAILABLE', 'IN_USE', 'MAINTENANCE', 'DAMAGED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PhotoboxModel" AS ENUM ('VINTAGE_SMILE', 'VINTAGE_PHOTOS', 'NOSTALGIE', 'MAGIC_MIRROR');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "InvoiceType" AS ENUM ('PRIVATE', 'BUSINESS');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "EquipmentType" AS ENUM ('PRINTER', 'CARPET', 'VIP_BARRIER', 'ACCESSORIES_KIT', 'PRINTER_PAPER', 'TRIPOD', 'OTHER');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "EquipmentStatus" AS ENUM ('AVAILABLE', 'IN_USE', 'MAINTENANCE', 'DAMAGED', 'RESERVED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "TourStatus" AS ENUM ('PLANNED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"password" TEXT NOT NULL,
|
||||||
|
"role" "UserRole" NOT NULL DEFAULT 'DRIVER',
|
||||||
|
"phoneNumber" TEXT,
|
||||||
|
"vehiclePlate" TEXT,
|
||||||
|
"vehicleModel" TEXT,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"available" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Location" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"city" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"websiteUrl" TEXT NOT NULL,
|
||||||
|
"contactEmail" TEXT NOT NULL,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"warehouseAddress" TEXT,
|
||||||
|
"warehouseZip" TEXT,
|
||||||
|
"warehouseCity" TEXT,
|
||||||
|
"imapHost" TEXT,
|
||||||
|
"imapPort" INTEGER,
|
||||||
|
"imapUser" TEXT,
|
||||||
|
"imapPassword" TEXT,
|
||||||
|
"imapSecure" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"smtpHost" TEXT,
|
||||||
|
"smtpPort" INTEGER,
|
||||||
|
"smtpUser" TEXT,
|
||||||
|
"smtpPassword" TEXT,
|
||||||
|
"smtpSecure" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"emailSyncEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"lastEmailSync" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PriceConfig" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"locationId" TEXT NOT NULL,
|
||||||
|
"model" "PhotoboxModel" NOT NULL,
|
||||||
|
"basePrice" DOUBLE PRECISION NOT NULL,
|
||||||
|
"kmFlatRate" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
"kmFlatRateUpTo" INTEGER NOT NULL DEFAULT 15,
|
||||||
|
"pricePerKm" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
"kmMultiplier" INTEGER NOT NULL DEFAULT 4,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PriceConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Photobox" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"locationId" TEXT NOT NULL,
|
||||||
|
"model" "PhotoboxModel" NOT NULL,
|
||||||
|
"serialNumber" TEXT NOT NULL,
|
||||||
|
"status" "PhotoboxStatus" NOT NULL DEFAULT 'AVAILABLE',
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"description" TEXT,
|
||||||
|
"purchaseDate" TIMESTAMP(3),
|
||||||
|
"lastMaintenance" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Photobox_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Booking" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"bookingNumber" TEXT NOT NULL,
|
||||||
|
"locationId" TEXT NOT NULL,
|
||||||
|
"photoboxId" TEXT,
|
||||||
|
"status" "BookingStatus" NOT NULL DEFAULT 'RESERVED',
|
||||||
|
"customerName" TEXT NOT NULL,
|
||||||
|
"customerEmail" TEXT NOT NULL,
|
||||||
|
"customerPhone" TEXT NOT NULL,
|
||||||
|
"customerAddress" TEXT,
|
||||||
|
"customerCity" TEXT,
|
||||||
|
"customerZip" TEXT,
|
||||||
|
"invoiceType" "InvoiceType" NOT NULL DEFAULT 'PRIVATE',
|
||||||
|
"companyName" TEXT,
|
||||||
|
"eventDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"eventAddress" TEXT NOT NULL,
|
||||||
|
"eventCity" TEXT NOT NULL,
|
||||||
|
"eventZip" TEXT NOT NULL,
|
||||||
|
"eventLocation" TEXT,
|
||||||
|
"setupTimeStart" TIMESTAMP(3) NOT NULL,
|
||||||
|
"setupTimeLatest" TIMESTAMP(3) NOT NULL,
|
||||||
|
"dismantleTimeEarliest" TIMESTAMP(3),
|
||||||
|
"dismantleTimeLatest" TIMESTAMP(3),
|
||||||
|
"distance" DOUBLE PRECISION,
|
||||||
|
"calculatedPrice" DOUBLE PRECISION,
|
||||||
|
"contractSigned" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"contractSignedAt" TIMESTAMP(3),
|
||||||
|
"contractGenerated" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"contractGeneratedAt" TIMESTAMP(3),
|
||||||
|
"contractSentAt" TIMESTAMP(3),
|
||||||
|
"contractSignedOnline" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"contractPdfUrl" TEXT,
|
||||||
|
"contractSignatureData" TEXT,
|
||||||
|
"contractSignedBy" TEXT,
|
||||||
|
"contractSignedIp" TEXT,
|
||||||
|
"contractUploadedBy" TEXT,
|
||||||
|
"lexofficeOfferId" TEXT,
|
||||||
|
"lexofficeInvoiceId" TEXT,
|
||||||
|
"lexofficeContactId" TEXT,
|
||||||
|
"lexofficeConfirmationId" TEXT,
|
||||||
|
"confirmationSentAt" TIMESTAMP(3),
|
||||||
|
"aiParsed" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"aiResponseDraft" TEXT,
|
||||||
|
"aiProcessedAt" TIMESTAMP(3),
|
||||||
|
"readyForAssignment" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"openForDrivers" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"calendarEventId" TEXT,
|
||||||
|
"calendarSynced" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"calendarSyncedAt" TIMESTAMP(3),
|
||||||
|
"tourId" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"internalNotes" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Booking_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SetupWindow" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"bookingId" TEXT NOT NULL,
|
||||||
|
"setupDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"setupTimeStart" TIMESTAMP(3) NOT NULL,
|
||||||
|
"setupTimeEnd" TIMESTAMP(3) NOT NULL,
|
||||||
|
"preferred" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"selected" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "SetupWindow_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Tour" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tourDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"tourNumber" TEXT NOT NULL,
|
||||||
|
"driverId" TEXT,
|
||||||
|
"routeOptimized" JSONB,
|
||||||
|
"totalDistance" DOUBLE PRECISION,
|
||||||
|
"estimatedDuration" INTEGER,
|
||||||
|
"status" "TourStatus" NOT NULL DEFAULT 'PLANNED',
|
||||||
|
"startedAt" TIMESTAMP(3),
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Tour_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Notification" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"message" TEXT NOT NULL,
|
||||||
|
"read" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"metadata" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Email" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"locationSlug" TEXT,
|
||||||
|
"from" TEXT NOT NULL,
|
||||||
|
"to" TEXT NOT NULL,
|
||||||
|
"subject" TEXT NOT NULL,
|
||||||
|
"textBody" TEXT,
|
||||||
|
"htmlBody" TEXT,
|
||||||
|
"messageId" TEXT,
|
||||||
|
"inReplyTo" TEXT,
|
||||||
|
"bookingId" TEXT,
|
||||||
|
"parsed" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"parsedData" JSONB,
|
||||||
|
"direction" TEXT NOT NULL DEFAULT 'INBOUND',
|
||||||
|
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Email_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Project" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Equipment" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"type" "EquipmentType" NOT NULL,
|
||||||
|
"brand" TEXT,
|
||||||
|
"model" TEXT,
|
||||||
|
"serialNumber" TEXT,
|
||||||
|
"quantity" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"status" "EquipmentStatus" NOT NULL DEFAULT 'AVAILABLE',
|
||||||
|
"locationId" TEXT,
|
||||||
|
"projectId" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"purchaseDate" TIMESTAMP(3),
|
||||||
|
"purchasePrice" DECIMAL(65,30),
|
||||||
|
"minStockLevel" INTEGER,
|
||||||
|
"currentStock" INTEGER,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Equipment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "BookingEquipment" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"bookingId" TEXT NOT NULL,
|
||||||
|
"equipmentId" TEXT NOT NULL,
|
||||||
|
"quantity" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "BookingEquipment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DriverAvailability" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"bookingId" TEXT NOT NULL,
|
||||||
|
"driverId" TEXT NOT NULL,
|
||||||
|
"available" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"message" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "DriverAvailability_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Location_slug_key" ON "Location"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Location_slug_idx" ON "Location"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PriceConfig_locationId_model_key" ON "PriceConfig"("locationId", "model");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Photobox_serialNumber_key" ON "Photobox"("serialNumber");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Photobox_locationId_model_idx" ON "Photobox"("locationId", "model");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Photobox_status_idx" ON "Photobox"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Booking_bookingNumber_key" ON "Booking"("bookingNumber");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Booking_eventDate_idx" ON "Booking"("eventDate");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Booking_status_idx" ON "Booking"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Booking_locationId_idx" ON "Booking"("locationId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SetupWindow_bookingId_idx" ON "SetupWindow"("bookingId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SetupWindow_setupDate_idx" ON "SetupWindow"("setupDate");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Tour_tourNumber_key" ON "Tour"("tourNumber");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Tour_tourDate_idx" ON "Tour"("tourDate");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Tour_driverId_idx" ON "Tour"("driverId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Tour_status_idx" ON "Tour"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Notification_userId_read_idx" ON "Notification"("userId", "read");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Email_messageId_key" ON "Email"("messageId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Email_locationSlug_idx" ON "Email"("locationSlug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Email_bookingId_idx" ON "Email"("bookingId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Email_receivedAt_idx" ON "Email"("receivedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Equipment_serialNumber_key" ON "Equipment"("serialNumber");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Equipment_type_idx" ON "Equipment"("type");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Equipment_status_idx" ON "Equipment"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Equipment_locationId_idx" ON "Equipment"("locationId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BookingEquipment_bookingId_idx" ON "BookingEquipment"("bookingId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BookingEquipment_equipmentId_idx" ON "BookingEquipment"("equipmentId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "BookingEquipment_bookingId_equipmentId_key" ON "BookingEquipment"("bookingId", "equipmentId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DriverAvailability_bookingId_idx" ON "DriverAvailability"("bookingId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DriverAvailability_driverId_idx" ON "DriverAvailability"("driverId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "DriverAvailability_bookingId_driverId_key" ON "DriverAvailability"("bookingId", "driverId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PriceConfig" ADD CONSTRAINT "PriceConfig_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Photobox" ADD CONSTRAINT "Photobox_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_photoboxId_fkey" FOREIGN KEY ("photoboxId") REFERENCES "Photobox"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SetupWindow" ADD CONSTRAINT "SetupWindow_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Tour" ADD CONSTRAINT "Tour_driverId_fkey" FOREIGN KEY ("driverId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Email" ADD CONSTRAINT "Email_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Equipment" ADD CONSTRAINT "Equipment_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Equipment" ADD CONSTRAINT "Equipment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "BookingEquipment" ADD CONSTRAINT "BookingEquipment_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "BookingEquipment" ADD CONSTRAINT "BookingEquipment_equipmentId_fkey" FOREIGN KEY ("equipmentId") REFERENCES "Equipment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DriverAvailability" ADD CONSTRAINT "DriverAvailability_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DriverAvailability" ADD CONSTRAINT "DriverAvailability_driverId_fkey" FOREIGN KEY ("driverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Booking" ADD COLUMN "withPrintFlat" BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Equipment" ADD COLUMN "lexofficeArticleId" TEXT,
|
||||||
|
ADD COLUMN "price" DOUBLE PRECISION;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PriceConfig" ADD COLUMN "lexofficeArticleId" TEXT,
|
||||||
|
ADD COLUMN "lexofficeArticleIdWithFlat" TEXT,
|
||||||
|
ADD COLUMN "lexofficeKmExtraArticleId" TEXT,
|
||||||
|
ADD COLUMN "lexofficeKmFlatArticleId" TEXT;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "StopStatus" AS ENUM ('PENDING', 'EN_ROUTE', 'ARRIVED', 'SETUP_STARTED', 'SETUP_COMPLETE', 'PICKUP_STARTED', 'PICKUP_COMPLETE', 'SKIPPED', 'ISSUE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PhotoType" AS ENUM ('SETUP_BEFORE', 'SETUP_AFTER', 'PICKUP_BEFORE', 'PICKUP_AFTER', 'ISSUE', 'OTHER');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TourStop" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tourId" TEXT NOT NULL,
|
||||||
|
"bookingId" TEXT NOT NULL,
|
||||||
|
"stopOrder" INTEGER NOT NULL,
|
||||||
|
"stopType" TEXT NOT NULL DEFAULT 'DELIVERY',
|
||||||
|
"status" "StopStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"arrivedAt" TIMESTAMP(3),
|
||||||
|
"setupStartedAt" TIMESTAMP(3),
|
||||||
|
"setupCompleteAt" TIMESTAMP(3),
|
||||||
|
"pickupStartedAt" TIMESTAMP(3),
|
||||||
|
"pickupCompleteAt" TIMESTAMP(3),
|
||||||
|
"arrivalLatitude" DOUBLE PRECISION,
|
||||||
|
"arrivalLongitude" DOUBLE PRECISION,
|
||||||
|
"notes" TEXT,
|
||||||
|
"issueDescription" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "TourStop_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DriverLocation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"driverId" TEXT NOT NULL,
|
||||||
|
"latitude" DOUBLE PRECISION NOT NULL,
|
||||||
|
"longitude" DOUBLE PRECISION NOT NULL,
|
||||||
|
"accuracy" DOUBLE PRECISION,
|
||||||
|
"heading" DOUBLE PRECISION,
|
||||||
|
"speed" DOUBLE PRECISION,
|
||||||
|
"tourId" TEXT,
|
||||||
|
"currentStopId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "DriverLocation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DeliveryPhoto" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tourStopId" TEXT NOT NULL,
|
||||||
|
"photoType" "PhotoType" NOT NULL,
|
||||||
|
"fileName" TEXT NOT NULL,
|
||||||
|
"filePath" TEXT NOT NULL,
|
||||||
|
"fileSize" INTEGER,
|
||||||
|
"mimeType" TEXT,
|
||||||
|
"latitude" DOUBLE PRECISION,
|
||||||
|
"longitude" DOUBLE PRECISION,
|
||||||
|
"caption" TEXT,
|
||||||
|
"uploadedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "DeliveryPhoto_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TourStop_tourId_stopOrder_idx" ON "TourStop"("tourId", "stopOrder");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TourStop_status_idx" ON "TourStop"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TourStop_tourId_bookingId_key" ON "TourStop"("tourId", "bookingId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DriverLocation_driverId_createdAt_idx" ON "DriverLocation"("driverId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DriverLocation_tourId_idx" ON "DriverLocation"("tourId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DeliveryPhoto_tourStopId_idx" ON "DeliveryPhoto"("tourStopId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DeliveryPhoto_photoType_idx" ON "DeliveryPhoto"("photoType");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TourStop" ADD CONSTRAINT "TourStop_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TourStop" ADD CONSTRAINT "TourStop_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DriverLocation" ADD CONSTRAINT "DriverLocation_driverId_fkey" FOREIGN KEY ("driverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DeliveryPhoto" ADD CONSTRAINT "DeliveryPhoto_tourStopId_fkey" FOREIGN KEY ("tourStopId") REFERENCES "TourStop"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -34,6 +34,7 @@ enum PhotoboxModel {
|
|||||||
VINTAGE_PHOTOS
|
VINTAGE_PHOTOS
|
||||||
NOSTALGIE
|
NOSTALGIE
|
||||||
MAGIC_MIRROR
|
MAGIC_MIRROR
|
||||||
|
VINTAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
enum InvoiceType {
|
enum InvoiceType {
|
||||||
@@ -74,6 +75,7 @@ model User {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
driverTours Tour[]
|
driverTours Tour[]
|
||||||
|
driverLocations DriverLocation[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
driverAvailability DriverAvailability[]
|
driverAvailability DriverAvailability[]
|
||||||
}
|
}
|
||||||
@@ -87,6 +89,10 @@ model Location {
|
|||||||
contactEmail String
|
contactEmail String
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
|
|
||||||
|
warehouseAddress String?
|
||||||
|
warehouseZip String?
|
||||||
|
warehouseCity String?
|
||||||
|
|
||||||
imapHost String?
|
imapHost String?
|
||||||
imapPort Int?
|
imapPort Int?
|
||||||
imapUser String?
|
imapUser String?
|
||||||
@@ -120,8 +126,16 @@ model PriceConfig {
|
|||||||
|
|
||||||
model PhotoboxModel
|
model PhotoboxModel
|
||||||
basePrice Float
|
basePrice Float
|
||||||
pricePerKm Float
|
|
||||||
includedKm Int @default(0)
|
kmFlatRate Float @default(0)
|
||||||
|
kmFlatRateUpTo Int @default(15)
|
||||||
|
pricePerKm Float @default(0)
|
||||||
|
kmMultiplier Int @default(4)
|
||||||
|
|
||||||
|
lexofficeArticleId String?
|
||||||
|
lexofficeArticleIdWithFlat String?
|
||||||
|
lexofficeKmFlatArticleId String?
|
||||||
|
lexofficeKmExtraArticleId String?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -185,6 +199,8 @@ model Booking {
|
|||||||
dismantleTimeEarliest DateTime?
|
dismantleTimeEarliest DateTime?
|
||||||
dismantleTimeLatest DateTime?
|
dismantleTimeLatest DateTime?
|
||||||
|
|
||||||
|
withPrintFlat Boolean @default(true)
|
||||||
|
|
||||||
distance Float?
|
distance Float?
|
||||||
calculatedPrice Float?
|
calculatedPrice Float?
|
||||||
|
|
||||||
@@ -223,6 +239,7 @@ model Booking {
|
|||||||
|
|
||||||
tourId String?
|
tourId String?
|
||||||
tour Tour? @relation(fields: [tourId], references: [id])
|
tour Tour? @relation(fields: [tourId], references: [id])
|
||||||
|
tourStops TourStop[]
|
||||||
|
|
||||||
notes String?
|
notes String?
|
||||||
internalNotes String?
|
internalNotes String?
|
||||||
@@ -267,6 +284,27 @@ enum TourStatus {
|
|||||||
CANCELLED
|
CANCELLED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum StopStatus {
|
||||||
|
PENDING // Noch nicht erreicht
|
||||||
|
EN_ROUTE // Fahrer ist unterwegs
|
||||||
|
ARRIVED // Fahrer ist angekommen
|
||||||
|
SETUP_STARTED // Aufbau begonnen
|
||||||
|
SETUP_COMPLETE // Aufbau abgeschlossen
|
||||||
|
PICKUP_STARTED // Abbau/Abholung begonnen
|
||||||
|
PICKUP_COMPLETE // Abholung abgeschlossen
|
||||||
|
SKIPPED // Übersprungen
|
||||||
|
ISSUE // Problem aufgetreten
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PhotoType {
|
||||||
|
SETUP_BEFORE // Vor dem Aufbau
|
||||||
|
SETUP_AFTER // Nach dem Aufbau
|
||||||
|
PICKUP_BEFORE // Vor dem Abbau
|
||||||
|
PICKUP_AFTER // Nach dem Abbau
|
||||||
|
ISSUE // Problem-Dokumentation
|
||||||
|
OTHER // Sonstiges
|
||||||
|
}
|
||||||
|
|
||||||
model Tour {
|
model Tour {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tourDate DateTime
|
tourDate DateTime
|
||||||
@@ -276,6 +314,7 @@ model Tour {
|
|||||||
driver User? @relation(fields: [driverId], references: [id])
|
driver User? @relation(fields: [driverId], references: [id])
|
||||||
|
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
|
tourStops TourStop[]
|
||||||
|
|
||||||
routeOptimized Json?
|
routeOptimized Json?
|
||||||
totalDistance Float?
|
totalDistance Float?
|
||||||
@@ -295,6 +334,93 @@ model Tour {
|
|||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model TourStop {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tourId String
|
||||||
|
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
bookingId String
|
||||||
|
booking Booking @relation(fields: [bookingId], references: [id])
|
||||||
|
|
||||||
|
stopOrder Int // Reihenfolge der Stopps (1, 2, 3, ...)
|
||||||
|
stopType String @default("DELIVERY") // DELIVERY, PICKUP, BOTH
|
||||||
|
|
||||||
|
status StopStatus @default(PENDING)
|
||||||
|
|
||||||
|
// Timestamps für jeden Status
|
||||||
|
arrivedAt DateTime?
|
||||||
|
setupStartedAt DateTime?
|
||||||
|
setupCompleteAt DateTime?
|
||||||
|
pickupStartedAt DateTime?
|
||||||
|
pickupCompleteAt DateTime?
|
||||||
|
|
||||||
|
// Optional: GPS-Position bei Ankunft
|
||||||
|
arrivalLatitude Float?
|
||||||
|
arrivalLongitude Float?
|
||||||
|
|
||||||
|
// Notizen vom Fahrer
|
||||||
|
notes String? @db.Text
|
||||||
|
issueDescription String? @db.Text
|
||||||
|
|
||||||
|
photos DeliveryPhoto[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([tourId, bookingId])
|
||||||
|
@@index([tourId, stopOrder])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model DriverLocation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
driverId String
|
||||||
|
driver User @relation(fields: [driverId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
latitude Float
|
||||||
|
longitude Float
|
||||||
|
accuracy Float? // GPS-Genauigkeit in Metern
|
||||||
|
heading Float? // Richtung in Grad (0-360)
|
||||||
|
speed Float? // Geschwindigkeit in km/h
|
||||||
|
|
||||||
|
tourId String?
|
||||||
|
|
||||||
|
// Optional: Welcher Stopp ist aktuell?
|
||||||
|
currentStopId String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([driverId, createdAt])
|
||||||
|
@@index([tourId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model DeliveryPhoto {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
tourStopId String
|
||||||
|
tourStop TourStop @relation(fields: [tourStopId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
photoType PhotoType
|
||||||
|
|
||||||
|
// Dateispeicherung (z.B. /uploads/photos/...)
|
||||||
|
fileName String
|
||||||
|
filePath String
|
||||||
|
fileSize Int?
|
||||||
|
mimeType String?
|
||||||
|
|
||||||
|
// Optional: GPS-Position wo das Foto aufgenommen wurde
|
||||||
|
latitude Float?
|
||||||
|
longitude Float?
|
||||||
|
|
||||||
|
caption String? @db.Text
|
||||||
|
|
||||||
|
uploadedAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([tourStopId])
|
||||||
|
@@index([photoType])
|
||||||
|
}
|
||||||
|
|
||||||
model Notification {
|
model Notification {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String?
|
userId String?
|
||||||
@@ -372,6 +498,9 @@ model Equipment {
|
|||||||
notes String?
|
notes String?
|
||||||
purchaseDate DateTime?
|
purchaseDate DateTime?
|
||||||
purchasePrice Decimal?
|
purchasePrice Decimal?
|
||||||
|
price Float?
|
||||||
|
|
||||||
|
lexofficeArticleId String?
|
||||||
|
|
||||||
minStockLevel Int?
|
minStockLevel Int?
|
||||||
currentStock Int?
|
currentStock Int?
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ async function main() {
|
|||||||
slug: 'luebeck',
|
slug: 'luebeck',
|
||||||
websiteUrl: 'https://fotobox-luebeck.de',
|
websiteUrl: 'https://fotobox-luebeck.de',
|
||||||
contactEmail: 'info@fotobox-luebeck.de',
|
contactEmail: 'info@fotobox-luebeck.de',
|
||||||
|
warehouseAddress: 'Wahmstraße 83',
|
||||||
|
warehouseZip: '23552',
|
||||||
|
warehouseCity: 'Lübeck',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,6 +69,9 @@ async function main() {
|
|||||||
slug: 'hamburg',
|
slug: 'hamburg',
|
||||||
websiteUrl: 'https://hamburg-fotobox.de',
|
websiteUrl: 'https://hamburg-fotobox.de',
|
||||||
contactEmail: 'info@hamburg-fotobox.de',
|
contactEmail: 'info@hamburg-fotobox.de',
|
||||||
|
warehouseAddress: 'Wahmstraße 83',
|
||||||
|
warehouseZip: '23552',
|
||||||
|
warehouseCity: 'Lübeck',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,6 +84,9 @@ async function main() {
|
|||||||
slug: 'kiel',
|
slug: 'kiel',
|
||||||
websiteUrl: 'https://fotobox-kiel.de',
|
websiteUrl: 'https://fotobox-kiel.de',
|
||||||
contactEmail: 'info@fotobox-kiel.de',
|
contactEmail: 'info@fotobox-kiel.de',
|
||||||
|
warehouseAddress: 'Wahmstraße 83',
|
||||||
|
warehouseZip: '23552',
|
||||||
|
warehouseCity: 'Lübeck',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,6 +99,9 @@ async function main() {
|
|||||||
slug: 'berlin',
|
slug: 'berlin',
|
||||||
websiteUrl: 'https://fotobox-potsdam.de',
|
websiteUrl: 'https://fotobox-potsdam.de',
|
||||||
contactEmail: 'info@fotobox-potsdam.de',
|
contactEmail: 'info@fotobox-potsdam.de',
|
||||||
|
warehouseAddress: 'Wahmstraße 83',
|
||||||
|
warehouseZip: '23552',
|
||||||
|
warehouseCity: 'Lübeck',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,35 +114,38 @@ async function main() {
|
|||||||
slug: 'rostock',
|
slug: 'rostock',
|
||||||
websiteUrl: 'https://fotobox-rostock.de',
|
websiteUrl: 'https://fotobox-rostock.de',
|
||||||
contactEmail: 'info@fotobox-rostock.de',
|
contactEmail: 'info@fotobox-rostock.de',
|
||||||
|
warehouseAddress: 'Wahmstraße 83',
|
||||||
|
warehouseZip: '23552',
|
||||||
|
warehouseCity: 'Lübeck',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.priceConfig.createMany({
|
await prisma.priceConfig.createMany({
|
||||||
data: [
|
data: [
|
||||||
{ locationId: luebeck.id, model: 'VINTAGE_SMILE', basePrice: 399, pricePerKm: 0.8, includedKm: 30 },
|
{ locationId: luebeck.id, model: 'VINTAGE_SMILE', basePrice: 399, kmFlatRate: 60, kmFlatRateUpTo: 15, pricePerKm: 0.40, kmMultiplier: 4 },
|
||||||
{ locationId: luebeck.id, model: 'VINTAGE_PHOTOS', basePrice: 449, pricePerKm: 0.8, includedKm: 30 },
|
{ locationId: luebeck.id, model: 'VINTAGE_PHOTOS', basePrice: 449, kmFlatRate: 60, kmFlatRateUpTo: 15, pricePerKm: 0.40, kmMultiplier: 4 },
|
||||||
{ locationId: luebeck.id, model: 'NOSTALGIE', basePrice: 499, pricePerKm: 0.8, includedKm: 30 },
|
{ locationId: luebeck.id, model: 'NOSTALGIE', basePrice: 499, kmFlatRate: 60, kmFlatRateUpTo: 15, pricePerKm: 0.40, kmMultiplier: 4 },
|
||||||
{ locationId: luebeck.id, model: 'MAGIC_MIRROR', basePrice: 599, pricePerKm: 0.8, includedKm: 30 },
|
{ locationId: luebeck.id, model: 'MAGIC_MIRROR', basePrice: 599, kmFlatRate: 60, kmFlatRateUpTo: 15, pricePerKm: 0.40, kmMultiplier: 4 },
|
||||||
|
|
||||||
{ locationId: hamburg.id, model: 'VINTAGE_SMILE', basePrice: 419, pricePerKm: 0.9, includedKm: 25 },
|
{ locationId: hamburg.id, model: 'VINTAGE_SMILE', basePrice: 419, kmFlatRate: 100, kmFlatRateUpTo: 60, pricePerKm: 0.40, kmMultiplier: 4 },
|
||||||
{ locationId: hamburg.id, model: 'VINTAGE_PHOTOS', basePrice: 469, pricePerKm: 0.9, includedKm: 25 },
|
{ locationId: hamburg.id, model: 'VINTAGE_PHOTOS', basePrice: 469, kmFlatRate: 100, kmFlatRateUpTo: 60, pricePerKm: 0.40, kmMultiplier: 4 },
|
||||||
{ locationId: hamburg.id, model: 'NOSTALGIE', basePrice: 519, pricePerKm: 0.9, includedKm: 25 },
|
{ locationId: hamburg.id, model: 'NOSTALGIE', basePrice: 519, kmFlatRate: 100, kmFlatRateUpTo: 60, pricePerKm: 0.40, kmMultiplier: 4 },
|
||||||
{ locationId: hamburg.id, model: 'MAGIC_MIRROR', basePrice: 619, pricePerKm: 0.9, includedKm: 25 },
|
{ locationId: hamburg.id, model: 'MAGIC_MIRROR', basePrice: 619, kmFlatRate: 100, kmFlatRateUpTo: 60, pricePerKm: 0.40, kmMultiplier: 4 },
|
||||||
|
|
||||||
{ locationId: kiel.id, model: 'VINTAGE_SMILE', basePrice: 389, pricePerKm: 0.75, includedKm: 35 },
|
{ locationId: kiel.id, model: 'VINTAGE_SMILE', basePrice: 389, kmFlatRate: 60, kmFlatRateUpTo: 15, pricePerKm: 0.40, kmMultiplier: 4 },
|
||||||
{ locationId: kiel.id, model: 'VINTAGE_PHOTOS', basePrice: 439, pricePerKm: 0.75, includedKm: 35 },
|
{ locationId: kiel.id, model: 'VINTAGE_PHOTOS', basePrice: 439, kmFlatRate: 60, kmFlatRateUpTo: 15, pricePerKm: 0.40, kmMultiplier: 4 },
|
||||||
{ locationId: kiel.id, model: 'NOSTALGIE', basePrice: 489, pricePerKm: 0.75, includedKm: 35 },
|
{ locationId: kiel.id, model: 'NOSTALGIE', basePrice: 489, kmFlatRate: 60, kmFlatRateUpTo: 15, pricePerKm: 0.40, kmMultiplier: 4 },
|
||||||
{ locationId: kiel.id, model: 'MAGIC_MIRROR', basePrice: 589, pricePerKm: 0.75, includedKm: 35 },
|
{ locationId: kiel.id, model: 'MAGIC_MIRROR', basePrice: 589, kmFlatRate: 60, kmFlatRateUpTo: 15, pricePerKm: 0.40, kmMultiplier: 4 },
|
||||||
|
|
||||||
{ locationId: berlin.id, model: 'VINTAGE_SMILE', basePrice: 409, pricePerKm: 0.85, includedKm: 30 },
|
{ locationId: berlin.id, model: 'VINTAGE_SMILE', basePrice: 409, kmFlatRate: 60, kmFlatRateUpTo: 15, pricePerKm: 0.40, kmMultiplier: 4 },
|
||||||
{ locationId: berlin.id, model: 'VINTAGE_PHOTOS', basePrice: 459, pricePerKm: 0.85, includedKm: 30 },
|
{ locationId: berlin.id, model: 'VINTAGE_PHOTOS', basePrice: 459, kmFlatRate: 60, kmFlatRateUpTo: 15, pricePerKm: 0.40, kmMultiplier: 4 },
|
||||||
{ locationId: berlin.id, model: 'NOSTALGIE', basePrice: 509, pricePerKm: 0.85, includedKm: 30 },
|
{ locationId: berlin.id, model: 'NOSTALGIE', basePrice: 509, kmFlatRate: 60, kmFlatRateUpTo: 15, pricePerKm: 0.40, kmMultiplier: 4 },
|
||||||
{ locationId: berlin.id, model: 'MAGIC_MIRROR', basePrice: 609, pricePerKm: 0.85, includedKm: 30 },
|
{ locationId: berlin.id, model: 'MAGIC_MIRROR', basePrice: 609, kmFlatRate: 60, kmFlatRateUpTo: 15, pricePerKm: 0.40, kmMultiplier: 4 },
|
||||||
|
|
||||||
{ locationId: rostock.id, model: 'VINTAGE_SMILE', basePrice: 379, pricePerKm: 0.75, includedKm: 35 },
|
{ locationId: rostock.id, model: 'VINTAGE_SMILE', basePrice: 379, kmFlatRate: 60, kmFlatRateUpTo: 15, pricePerKm: 0.40, kmMultiplier: 4 },
|
||||||
{ locationId: rostock.id, model: 'VINTAGE_PHOTOS', basePrice: 429, pricePerKm: 0.75, includedKm: 35 },
|
{ locationId: rostock.id, model: 'VINTAGE_PHOTOS', basePrice: 429, kmFlatRate: 60, kmFlatRateUpTo: 15, pricePerKm: 0.40, kmMultiplier: 4 },
|
||||||
{ locationId: rostock.id, model: 'NOSTALGIE', basePrice: 479, pricePerKm: 0.75, includedKm: 35 },
|
{ locationId: rostock.id, model: 'NOSTALGIE', basePrice: 479, kmFlatRate: 60, kmFlatRateUpTo: 15, pricePerKm: 0.40, kmMultiplier: 4 },
|
||||||
{ locationId: rostock.id, model: 'MAGIC_MIRROR', basePrice: 579, pricePerKm: 0.75, includedKm: 35 },
|
{ locationId: rostock.id, model: 'MAGIC_MIRROR', basePrice: 579, kmFlatRate: 60, kmFlatRateUpTo: 15, pricePerKm: 0.40, kmMultiplier: 4 },
|
||||||
],
|
],
|
||||||
skipDuplicates: true,
|
skipDuplicates: true,
|
||||||
});
|
});
|
||||||
|
|||||||
73
scripts/check-email-sync.ts
Normal file
73
scripts/check-email-sync.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function checkEmailSync() {
|
||||||
|
console.log('🔍 Prüfe E-Mail-Sync Status...\n');
|
||||||
|
|
||||||
|
const luebeck = await prisma.location.findUnique({
|
||||||
|
where: { slug: 'luebeck' },
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
emailSyncEnabled: true,
|
||||||
|
lastEmailSync: true,
|
||||||
|
imapHost: true,
|
||||||
|
imapUser: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!luebeck) {
|
||||||
|
console.log('❌ Lübeck Location nicht gefunden!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📍 Lübeck:');
|
||||||
|
console.log(` E-Mail-Sync: ${luebeck.emailSyncEnabled ? '✅ Aktiviert' : '❌ Deaktiviert'}`);
|
||||||
|
console.log(` IMAP konfiguriert: ${luebeck.imapHost ? '✅ Ja' : '❌ Nein'}`);
|
||||||
|
console.log(` Letzter Sync: ${luebeck.lastEmailSync ? new Date(luebeck.lastEmailSync).toLocaleString('de-DE') : 'Noch nie'}`);
|
||||||
|
|
||||||
|
if (!luebeck.emailSyncEnabled) {
|
||||||
|
console.log('\n⚠️ E-Mail-Sync ist deaktiviert!');
|
||||||
|
console.log(' Buchungen werden NICHT automatisch erfasst.');
|
||||||
|
console.log(' → Entweder manuell im Dashboard anlegen');
|
||||||
|
console.log(' → Oder E-Mail-Sync aktivieren\n');
|
||||||
|
} else {
|
||||||
|
console.log('\n✅ E-Mail-Sync ist aktiviert!');
|
||||||
|
console.log(' → E-Mails werden automatisch abgerufen (Cron-Job oder manuell)');
|
||||||
|
console.log(' → Buchungen erscheinen im Dashboard\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob es neue Buchungen gibt
|
||||||
|
const recentBookings = await prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
locationId: luebeck ? undefined : undefined,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
take: 5,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
bookingNumber: true,
|
||||||
|
customerName: true,
|
||||||
|
createdAt: true,
|
||||||
|
location: {
|
||||||
|
select: { name: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (recentBookings.length > 0) {
|
||||||
|
console.log('📊 Letzte 5 Buchungen:');
|
||||||
|
recentBookings.forEach((booking, idx) => {
|
||||||
|
console.log(` ${idx + 1}. ${booking.bookingNumber} - ${booking.customerName} (${booking.location.name})`);
|
||||||
|
console.log(` Erstellt: ${new Date(booking.createdAt).toLocaleString('de-DE')}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('📊 Keine Buchungen gefunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
checkEmailSync();
|
||||||
31
scripts/check-equipment.ts
Normal file
31
scripts/check-equipment.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { prisma } from '../lib/prisma';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🔍 Prüfe Equipment-Einträge...\n');
|
||||||
|
|
||||||
|
const count = await prisma.equipment.count();
|
||||||
|
console.log(`📊 Anzahl Equipment-Einträge: ${count}`);
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
const equipment = await prisma.equipment.findMany();
|
||||||
|
console.log('\n📦 Vorhandene Equipment:');
|
||||||
|
console.table(equipment.map(e => ({
|
||||||
|
id: e.id.slice(0, 8) + '...',
|
||||||
|
name: e.name,
|
||||||
|
price: e.price,
|
||||||
|
lexofficeId: e.lexofficeArticleId?.slice(0, 8) + '...' || 'null',
|
||||||
|
})));
|
||||||
|
} else {
|
||||||
|
console.log('\n⚠️ Keine Equipment-Einträge in der Datenbank!');
|
||||||
|
console.log('ℹ️ Du musst zuerst Equipment-Artikel anlegen.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
56
scripts/check-quotation-status.ts
Normal file
56
scripts/check-quotation-status.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { prisma } from '../lib/prisma';
|
||||||
|
import { lexofficeService } from '../lib/lexoffice';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🔍 Prüfe LexOffice Quotation Status...\n');
|
||||||
|
|
||||||
|
// Hole die Test-Buchung
|
||||||
|
const booking = await prisma.booking.findFirst({
|
||||||
|
where: { bookingNumber: 'STM-2511-9237' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
bookingNumber: true,
|
||||||
|
lexofficeOfferId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
console.log('❌ Buchung nicht gefunden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📋 Buchung:', booking.bookingNumber);
|
||||||
|
console.log('🆔 LexOffice Offer ID:', booking.lexofficeOfferId);
|
||||||
|
|
||||||
|
if (!booking.lexofficeOfferId) {
|
||||||
|
console.log('❌ Keine LexOffice Angebots-ID vorhanden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('\n🔍 Lade Quotation Details von LexOffice...');
|
||||||
|
const quotation = await lexofficeService.getQuotation(booking.lexofficeOfferId);
|
||||||
|
|
||||||
|
console.log('\n📊 Quotation Details:');
|
||||||
|
console.log(' Voucher Number:', quotation.voucherNumber);
|
||||||
|
console.log(' Created:', quotation.createdDate);
|
||||||
|
console.log(' Updated:', quotation.updatedDate);
|
||||||
|
console.log('\n Full Response:', JSON.stringify(quotation, null, 2));
|
||||||
|
|
||||||
|
console.log('\n📄 Versuche PDF Download...');
|
||||||
|
const pdf = await lexofficeService.getQuotationPDF(booking.lexofficeOfferId);
|
||||||
|
console.log('✅ PDF erfolgreich heruntergeladen! Größe:', pdf.length, 'bytes');
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('\n❌ Fehler:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
40
scripts/clear-lexoffice-article-ids.ts
Normal file
40
scripts/clear-lexoffice-article-ids.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🧹 Entferne LexOffice-Artikel-IDs...');
|
||||||
|
|
||||||
|
const result = await prisma.priceConfig.updateMany({
|
||||||
|
data: {
|
||||||
|
lexofficeArticleId: null,
|
||||||
|
lexofficeArticleIdWithFlat: null,
|
||||||
|
lexofficeKmFlatArticleId: null,
|
||||||
|
lexofficeKmExtraArticleId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ ${result.count} PriceConfigs aktualisiert`);
|
||||||
|
|
||||||
|
const configs = await prisma.priceConfig.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
model: true,
|
||||||
|
basePrice: true,
|
||||||
|
lexofficeArticleId: true,
|
||||||
|
lexofficeArticleIdWithFlat: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n📊 Aktuelle PriceConfigs:');
|
||||||
|
console.table(configs);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
77
scripts/configure-luebeck-km.ts
Normal file
77
scripts/configure-luebeck-km.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function configureLuebeckLocation() {
|
||||||
|
console.log('🔧 Konfiguriere Lübeck Location & Kilometerpauschalen...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const location = await prisma.location.findUnique({
|
||||||
|
where: { slug: 'luebeck' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!location) {
|
||||||
|
console.error('❌ Lübeck Location nicht gefunden!');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.location.update({
|
||||||
|
where: { id: location.id },
|
||||||
|
data: {
|
||||||
|
warehouseAddress: 'Wahmstraße 83',
|
||||||
|
warehouseZip: '23552',
|
||||||
|
warehouseCity: 'Lübeck',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Lager-Adresse gesetzt: Wahmstraße 83, 23552 Lübeck\n');
|
||||||
|
|
||||||
|
const models = ['VINTAGE_SMILE', 'VINTAGE_PHOTOS', 'NOSTALGIE', 'MAGIC_MIRROR'];
|
||||||
|
|
||||||
|
for (const model of models) {
|
||||||
|
const existing = await prisma.priceConfig.findUnique({
|
||||||
|
where: {
|
||||||
|
locationId_model: {
|
||||||
|
locationId: location.id,
|
||||||
|
model: model as any,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await prisma.priceConfig.update({
|
||||||
|
where: {
|
||||||
|
locationId_model: {
|
||||||
|
locationId: location.id,
|
||||||
|
model: model as any,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
kmFlatRate: 60.0,
|
||||||
|
kmFlatRateUpTo: 15,
|
||||||
|
pricePerKm: 0.40,
|
||||||
|
kmMultiplier: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ ${model}: Kilometerpauschale aktualisiert`);
|
||||||
|
} else {
|
||||||
|
console.log(`⚠️ ${model}: Keine PriceConfig gefunden, überspringe...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎉 Lübeck Location erfolgreich konfiguriert!');
|
||||||
|
console.log('\nKonfiguration:');
|
||||||
|
console.log(' 📍 Lager: Wahmstraße 83, 23552 Lübeck');
|
||||||
|
console.log(' 💰 Pauschale bis 15km: 60,00€ brutto');
|
||||||
|
console.log(' 💰 Darüber: 0,40€ netto/km × 4 Strecken');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Fehler:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configureLuebeckLocation();
|
||||||
35
scripts/finalize-existing-quotation.ts
Normal file
35
scripts/finalize-existing-quotation.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { prisma } from '../lib/prisma';
|
||||||
|
import { lexofficeService } from '../lib/lexoffice';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🔄 Finalisiere bestehende Quotation...\n');
|
||||||
|
|
||||||
|
const quotationId = 'c66b3347-4411-449c-897f-e0d84cb42601';
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('📤 Rufe PUT /quotations/{id}/pursue auf...');
|
||||||
|
const result = await lexofficeService.finalizeQuotation(quotationId);
|
||||||
|
console.log('✅ Erfolgreich!', result);
|
||||||
|
|
||||||
|
console.log('\n🔍 Prüfe neuen Status...');
|
||||||
|
const quotation = await lexofficeService.getQuotation(quotationId);
|
||||||
|
console.log('Status:', quotation.voucherStatus);
|
||||||
|
|
||||||
|
console.log('\n📄 Versuche PDF Download...');
|
||||||
|
const pdf = await lexofficeService.getQuotationPDF(quotationId);
|
||||||
|
console.log('✅ PDF erfolgreich! Größe:', pdf.length, 'bytes');
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Fehler:', error.message);
|
||||||
|
console.error('Details:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
89
scripts/list-lexoffice-articles.js
Normal file
89
scripts/list-lexoffice-articles.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function loadEnv() {
|
||||||
|
const envPath = path.join(__dirname, '..', '.env');
|
||||||
|
const envContent = fs.readFileSync(envPath, 'utf-8');
|
||||||
|
const env = {};
|
||||||
|
|
||||||
|
envContent.split('\n').forEach(line => {
|
||||||
|
const match = line.match(/^([^=:#]+)=(.*)$/);
|
||||||
|
if (match) {
|
||||||
|
const key = match[1].trim();
|
||||||
|
let value = match[2].trim();
|
||||||
|
|
||||||
|
if (value.startsWith('"') && value.endsWith('"')) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
} else if (value.startsWith("'") && value.endsWith("'")) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
env[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listLexOfficeArticles() {
|
||||||
|
console.log('🔍 Lade LexOffice Artikel...\n');
|
||||||
|
|
||||||
|
const env = loadEnv();
|
||||||
|
const apiKey = env.LEXOFFICE_API_KEY;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
console.error('❌ LEXOFFICE_API_KEY nicht in .env gefunden!');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://api.lexoffice.io/v1/articles?page=0&size=100', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`LexOffice API Error: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.content || data.content.length === 0) {
|
||||||
|
console.log('⚠️ Keine Artikel gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Gefunden: ${data.content.length} Artikel\n`);
|
||||||
|
console.log('─'.repeat(80));
|
||||||
|
|
||||||
|
data.content.forEach((article, index) => {
|
||||||
|
console.log(`\n${index + 1}. ${article.title || article.articleNumber || 'Unbekannt'}`);
|
||||||
|
console.log(` ID: ${article.id}`);
|
||||||
|
console.log(` Artikelnummer: ${article.articleNumber || 'N/A'}`);
|
||||||
|
console.log(` Typ: ${article.type || 'N/A'}`);
|
||||||
|
|
||||||
|
if (article.price) {
|
||||||
|
const netAmount = article.price.netAmount || 0;
|
||||||
|
console.log(` Preis: ${netAmount.toFixed(2)}€ netto`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (article.description) {
|
||||||
|
const desc = article.description.substring(0, 60);
|
||||||
|
console.log(` Beschreibung: ${desc}${article.description.length > 60 ? '...' : ''}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n' + '─'.repeat(80));
|
||||||
|
console.log('\n💡 Kopieren Sie die IDs für Ihr Produkt-Mapping!');
|
||||||
|
console.log('📝 Format: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX\n');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Fehler:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listLexOfficeArticles();
|
||||||
53
scripts/list-lexoffice-articles.ts
Normal file
53
scripts/list-lexoffice-articles.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
config();
|
||||||
|
|
||||||
|
async function listLexOfficeArticles() {
|
||||||
|
console.log('🔍 Lade LexOffice Artikel...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://api.lexoffice.io/v1/articles?page=0&size=100', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${process.env.LEXOFFICE_API_KEY}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`LexOffice API Error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.content || data.content.length === 0) {
|
||||||
|
console.log('⚠️ Keine Artikel gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Gefunden: ${data.content.length} Artikel\n`);
|
||||||
|
console.log('─'.repeat(80));
|
||||||
|
|
||||||
|
data.content.forEach((article: any, index: number) => {
|
||||||
|
console.log(`\n${index + 1}. ${article.title || article.articleNumber || 'Unbekannt'}`);
|
||||||
|
console.log(` ID: ${article.id}`);
|
||||||
|
console.log(` Artikelnummer: ${article.articleNumber || 'N/A'}`);
|
||||||
|
console.log(` Typ: ${article.type || 'N/A'}`);
|
||||||
|
|
||||||
|
if (article.price) {
|
||||||
|
console.log(` Preis: ${article.price.netAmount || 0}€ netto`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (article.description) {
|
||||||
|
console.log(` Beschreibung: ${article.description.substring(0, 60)}...`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n' + '─'.repeat(80));
|
||||||
|
console.log('\n💡 Kopieren Sie die IDs für Ihr Produkt-Mapping!');
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Fehler:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listLexOfficeArticles();
|
||||||
69
scripts/manual-email-sync.ts
Normal file
69
scripts/manual-email-sync.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { emailSyncService } from '../lib/email-sync';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function manualEmailSync() {
|
||||||
|
console.log('🔄 Manueller E-Mail-Sync gestartet...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const locations = await prisma.location.findMany({
|
||||||
|
where: { emailSyncEnabled: true },
|
||||||
|
select: { id: true, name: true, slug: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (locations.length === 0) {
|
||||||
|
console.log('⚠️ Keine Locations mit aktiviertem E-Mail-Sync gefunden.\n');
|
||||||
|
console.log('💡 Bitte aktivieren Sie E-Mail-Sync in den Location-Einstellungen.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📍 Gefunden: ${locations.length} Location(s) mit E-Mail-Sync\n`);
|
||||||
|
|
||||||
|
let totalNewEmails = 0;
|
||||||
|
let totalNewBookings = 0;
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const location of locations) {
|
||||||
|
console.log(`🔄 ${location.name} (${location.slug})...`);
|
||||||
|
|
||||||
|
const result = await emailSyncService.syncLocationEmails(location.id);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log(` ✅ ${result.newEmails} neue E-Mails`);
|
||||||
|
console.log(` ✅ ${result.newBookings} neue Buchungen\n`);
|
||||||
|
totalNewEmails += result.newEmails;
|
||||||
|
totalNewBookings += result.newBookings;
|
||||||
|
} else {
|
||||||
|
console.log(` ❌ Fehler:`);
|
||||||
|
result.errors.forEach(err => console.log(` - ${err}`));
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
location: location.name,
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('─'.repeat(60));
|
||||||
|
console.log('📊 Zusammenfassung:');
|
||||||
|
console.log(` Locations: ${locations.length}`);
|
||||||
|
console.log(` Neue E-Mails: ${totalNewEmails}`);
|
||||||
|
console.log(` Neue Buchungen: ${totalNewBookings}`);
|
||||||
|
console.log('─'.repeat(60));
|
||||||
|
|
||||||
|
if (totalNewBookings > 0) {
|
||||||
|
console.log('\n✅ Neue Buchungen im Dashboard verfügbar!');
|
||||||
|
console.log(' → http://localhost:3000/dashboard/bookings\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Fehler beim E-Mail-Sync:', error.message);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manualEmailSync();
|
||||||
42
scripts/reset-lexoffice-ids.ts
Normal file
42
scripts/reset-lexoffice-ids.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { prisma } from '../lib/prisma';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🗑️ Lösche alte LexOffice IDs für erneuten Test...\n');
|
||||||
|
|
||||||
|
const booking = await prisma.booking.findFirst({
|
||||||
|
where: { bookingNumber: 'STM-2511-9237' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
console.log('❌ Buchung nicht gefunden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📋 Buchung:', booking.bookingNumber);
|
||||||
|
console.log('🆔 Aktuelle LexOffice Offer ID:', booking.lexofficeOfferId);
|
||||||
|
|
||||||
|
if (booking.lexofficeOfferId) {
|
||||||
|
await prisma.booking.update({
|
||||||
|
where: { id: booking.id },
|
||||||
|
data: {
|
||||||
|
lexofficeOfferId: null,
|
||||||
|
lexofficeContactId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n✅ LexOffice IDs gelöscht!');
|
||||||
|
console.log('ℹ️ Der "Automation starten" Button sollte jetzt wieder erscheinen.');
|
||||||
|
console.log('⚠️ WICHTIG: Lösche die alte Quotation in LexOffice manuell (AN-221646)');
|
||||||
|
} else {
|
||||||
|
console.log('\n✅ Keine LexOffice IDs vorhanden - nichts zu löschen.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
68
scripts/restore-lexoffice-article-ids.ts
Normal file
68
scripts/restore-lexoffice-article-ids.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🔄 Stelle LexOffice-Artikel-IDs wieder her...\n');
|
||||||
|
|
||||||
|
// 1. Fotobox-Artikel-IDs für ALLE PriceConfigs setzen
|
||||||
|
const photoboxUpdate = await prisma.priceConfig.updateMany({
|
||||||
|
data: {
|
||||||
|
lexofficeArticleId: '5d9d3716-f81e-4e46-b5cf-13988f489cc2', // ohne Druckflatrate
|
||||||
|
lexofficeArticleIdWithFlat: '3f26c02c-d705-41a6-9b49-2e2e96e77036', // mit Druckflatrate
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`✅ ${photoboxUpdate.count} PriceConfigs mit Fotobox-Artikel-IDs aktualisiert`);
|
||||||
|
|
||||||
|
// 2. Kilometerpauschale für Lübeck
|
||||||
|
const luebeckLocation = await prisma.location.findFirst({
|
||||||
|
where: { city: 'Lübeck' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (luebeckLocation) {
|
||||||
|
const kmUpdate = await prisma.priceConfig.updateMany({
|
||||||
|
where: { locationId: luebeckLocation.id },
|
||||||
|
data: {
|
||||||
|
lexofficeKmFlatArticleId: 'd3e2b21b-e899-412d-b53e-c82a0a94fcfa',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`✅ ${kmUpdate.count} PriceConfigs für Lübeck mit Kilometerpauschale-ID aktualisiert`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Equipment-IDs prüfen
|
||||||
|
const equipment = await prisma.equipment.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
lexofficeArticleId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n📦 Vorhandene Equipment-Artikel:');
|
||||||
|
console.table(equipment);
|
||||||
|
|
||||||
|
// 4. Finale Übersicht
|
||||||
|
console.log('\n📊 Finale PriceConfig-Übersicht:');
|
||||||
|
const configs = await prisma.priceConfig.findMany({
|
||||||
|
include: {
|
||||||
|
location: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.table(configs.map(c => ({
|
||||||
|
model: c.model,
|
||||||
|
city: c.location?.city,
|
||||||
|
articleId: c.lexofficeArticleId?.slice(0, 8) + '...',
|
||||||
|
articleIdWithFlat: c.lexofficeArticleIdWithFlat?.slice(0, 8) + '...',
|
||||||
|
kmFlatArticleId: c.lexofficeKmFlatArticleId?.slice(0, 8) + '...' || 'null',
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
114
scripts/set-correct-article-ids.ts
Normal file
114
scripts/set-correct-article-ids.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🔄 Setze korrekte LexOffice-Artikel-IDs pro Modell...\n');
|
||||||
|
|
||||||
|
// Mapping: Modell -> Artikel-IDs
|
||||||
|
const modelArticleIds: Record<string, { withoutFlat: string; withFlat: string }> = {
|
||||||
|
'VINTAGE_SMILE': {
|
||||||
|
withoutFlat: '5d9d3716-f81e-4e46-b5cf-13988f489cc2',
|
||||||
|
withFlat: '3f26c02c-d705-41a6-9b49-2e2e96e77036',
|
||||||
|
},
|
||||||
|
'VINTAGE_PHOTOS': {
|
||||||
|
withoutFlat: '5d9d3716-f81e-4e46-b5cf-13988f489cc2',
|
||||||
|
withFlat: '3f26c02c-d705-41a6-9b49-2e2e96e77036',
|
||||||
|
},
|
||||||
|
'NOSTALGIE': {
|
||||||
|
withoutFlat: '8954bd20-570c-4a7d-9ac3-8ee756652a89',
|
||||||
|
withFlat: '701bd150-48c0-4937-b628-f4a754d86264',
|
||||||
|
},
|
||||||
|
'MAGIC_MIRROR': {
|
||||||
|
withoutFlat: '72bbe51b-c0bb-437a-963b-248cb105553a',
|
||||||
|
withFlat: '39ec59e7-57a6-4d6d-80f6-645e589c4b2c',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update pro Modell
|
||||||
|
for (const [model, ids] of Object.entries(modelArticleIds)) {
|
||||||
|
const result = await prisma.priceConfig.updateMany({
|
||||||
|
where: { model },
|
||||||
|
data: {
|
||||||
|
lexofficeArticleId: ids.withoutFlat,
|
||||||
|
lexofficeArticleIdWithFlat: ids.withFlat,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`✅ ${model}: ${result.count} PriceConfigs aktualisiert`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kilometerpauschale für Lübeck
|
||||||
|
const luebeck = await prisma.location.findFirst({ where: { city: 'Lübeck' } });
|
||||||
|
if (luebeck) {
|
||||||
|
const kmResult = await prisma.priceConfig.updateMany({
|
||||||
|
where: { locationId: luebeck.id },
|
||||||
|
data: { lexofficeKmFlatArticleId: 'd3e2b21b-e899-412d-b53e-c82a0a94fcfa' },
|
||||||
|
});
|
||||||
|
console.log(`✅ Lübeck Kilometerpauschale: ${kmResult.count} PriceConfigs aktualisiert`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n📦 Equipment-Artikel-IDs aktualisieren...\n');
|
||||||
|
|
||||||
|
// Equipment-Mapping: Name -> Artikel-ID
|
||||||
|
const equipmentArticleIds: Record<string, string> = {
|
||||||
|
'Accessoires': 'c62d4dad-4f04-4330-9019-f9804bb43ddc',
|
||||||
|
'VIP-Bänder': 'e3942394-94d7-45ea-a31b-a1b035f6f34e',
|
||||||
|
'Erweiterte Druck-Flat DIY': '774aba7f-2c20-4d65-b8d0-4793f27d2d71',
|
||||||
|
'Kartenspiel Fotoboxaufgaben': '17d563fe-5f00-4591-a414-d013d7ce68e0',
|
||||||
|
'Roter Teppich': 'd138da65-4e23-4a88-813a-1f3725a75a15',
|
||||||
|
'Service-Techniker vor Ort': 'ec972616-8bc5-4334-876d-b442838a8bbf',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [name, articleId] of Object.entries(equipmentArticleIds)) {
|
||||||
|
try {
|
||||||
|
const equipment = await prisma.equipment.findFirst({
|
||||||
|
where: { name: { contains: name, mode: 'insensitive' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (equipment) {
|
||||||
|
await prisma.equipment.update({
|
||||||
|
where: { id: equipment.id },
|
||||||
|
data: { lexofficeArticleId: articleId },
|
||||||
|
});
|
||||||
|
console.log(`✅ ${name}: Artikel-ID gesetzt`);
|
||||||
|
} else {
|
||||||
|
console.log(`⚠️ ${name}: Equipment nicht gefunden - übersprungen`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`⚠️ ${name}: Fehler - ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n📊 Finale Übersicht:');
|
||||||
|
const configs = await prisma.priceConfig.findMany({
|
||||||
|
include: { location: true },
|
||||||
|
orderBy: [{ location: { city: 'asc' } }, { model: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
console.table(configs.map(c => ({
|
||||||
|
city: c.location?.city,
|
||||||
|
model: c.model,
|
||||||
|
withoutFlat: c.lexofficeArticleId?.slice(0, 8) + '...',
|
||||||
|
withFlat: c.lexofficeArticleIdWithFlat?.slice(0, 8) + '...',
|
||||||
|
kmFlat: c.lexofficeKmFlatArticleId?.slice(0, 8) + '...' || 'null',
|
||||||
|
})));
|
||||||
|
|
||||||
|
const equipment = await prisma.equipment.findMany({
|
||||||
|
select: { name: true, lexofficeArticleId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n📦 Equipment mit Artikel-IDs:');
|
||||||
|
console.table(equipment.map(e => ({
|
||||||
|
name: e.name,
|
||||||
|
articleId: e.lexofficeArticleId?.slice(0, 8) + '...' || 'null',
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
113
scripts/setup-lexoffice-mapping.ts
Normal file
113
scripts/setup-lexoffice-mapping.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// LexOffice Artikel-IDs (von Dennis bereitgestellt)
|
||||||
|
const LEXOFFICE_ARTICLES = {
|
||||||
|
// Fotoboxen
|
||||||
|
VINTAGE: {
|
||||||
|
withFlat: '3f26c02c-d705-41a6-9b49-2e2e96e77036', // Fotobox Vintage Flat
|
||||||
|
withoutFlat: '5d9d3716-f81e-4e46-b5cf-13988f489cc2', // Fotobox Vintage
|
||||||
|
},
|
||||||
|
NOSTALGIE: {
|
||||||
|
withFlat: '701bd150-48c0-4937-b628-f4a754d86264', // Fotobox Nostalgie Flat
|
||||||
|
withoutFlat: '8954bd20-570c-4a7d-9ac3-8ee756652a89', // Fotobox Nostalgie
|
||||||
|
},
|
||||||
|
MAGIC_MIRROR: {
|
||||||
|
withFlat: '39ec59e7-57a6-4d6d-80f6-645e589c4b2c', // Fotobox Magic Mirror Flat
|
||||||
|
withoutFlat: '72bbe51b-c0bb-437a-963b-248cb105553a', // Fotobox Magic Mirror
|
||||||
|
},
|
||||||
|
|
||||||
|
// Equipment/Extras
|
||||||
|
ACCESSORIES: 'c62d4dad-4f04-4330-9019-f9804bb43ddc',
|
||||||
|
VIP_BARRIER: 'e3942394-94d7-45ea-a31b-a1b035f6f34e',
|
||||||
|
PRINT_FLAT_DIY: '774aba7f-2c20-4d65-b8d0-4793f27d2d71',
|
||||||
|
CARD_GAME: '17d563fe-5f00-4591-a414-d013d7ce68e0',
|
||||||
|
RED_CARPET: 'd138da65-4e23-4a88-813a-1f3725a75a15',
|
||||||
|
SERVICE_TECH: 'ec972616-8bc5-4334-876d-b442838a8bbf',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function setupLexOfficeMapping() {
|
||||||
|
console.log('🔧 Konfiguriere LexOffice Artikel-IDs...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const locations = await prisma.location.findMany();
|
||||||
|
|
||||||
|
for (const location of locations) {
|
||||||
|
console.log(`📍 ${location.name}:`);
|
||||||
|
|
||||||
|
// VINTAGE_SMILE / VINTAGE_PHOTOS (gleiche Box)
|
||||||
|
const vintageConfigs = await prisma.priceConfig.findMany({
|
||||||
|
where: {
|
||||||
|
locationId: location.id,
|
||||||
|
model: {
|
||||||
|
in: ['VINTAGE_SMILE', 'VINTAGE_PHOTOS'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const config of vintageConfigs) {
|
||||||
|
await prisma.priceConfig.update({
|
||||||
|
where: { id: config.id },
|
||||||
|
data: {
|
||||||
|
lexofficeArticleId: LEXOFFICE_ARTICLES.VINTAGE.withoutFlat,
|
||||||
|
lexofficeArticleIdWithFlat: LEXOFFICE_ARTICLES.VINTAGE.withFlat,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(` ✅ ${config.model}: LexOffice IDs gesetzt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOSTALGIE
|
||||||
|
const nostalgieConfig = await prisma.priceConfig.findFirst({
|
||||||
|
where: {
|
||||||
|
locationId: location.id,
|
||||||
|
model: 'NOSTALGIE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nostalgieConfig) {
|
||||||
|
await prisma.priceConfig.update({
|
||||||
|
where: { id: nostalgieConfig.id },
|
||||||
|
data: {
|
||||||
|
lexofficeArticleId: LEXOFFICE_ARTICLES.NOSTALGIE.withoutFlat,
|
||||||
|
lexofficeArticleIdWithFlat: LEXOFFICE_ARTICLES.NOSTALGIE.withFlat,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(` ✅ NOSTALGIE: LexOffice IDs gesetzt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAGIC_MIRROR
|
||||||
|
const magicMirrorConfig = await prisma.priceConfig.findFirst({
|
||||||
|
where: {
|
||||||
|
locationId: location.id,
|
||||||
|
model: 'MAGIC_MIRROR',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (magicMirrorConfig) {
|
||||||
|
await prisma.priceConfig.update({
|
||||||
|
where: { id: magicMirrorConfig.id },
|
||||||
|
data: {
|
||||||
|
lexofficeArticleId: LEXOFFICE_ARTICLES.MAGIC_MIRROR.withoutFlat,
|
||||||
|
lexofficeArticleIdWithFlat: LEXOFFICE_ARTICLES.MAGIC_MIRROR.withFlat,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(` ✅ MAGIC_MIRROR: LexOffice IDs gesetzt`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎉 LexOffice Artikel-Mapping erfolgreich konfiguriert!');
|
||||||
|
console.log('\n📝 Nächste Schritte:');
|
||||||
|
console.log(' 1. Testbuchung erstellen (mit/ohne Druckflatrate)');
|
||||||
|
console.log(' 2. LexOffice Angebot generieren');
|
||||||
|
console.log(' 3. Prüfen ob korrekte Artikel-IDs verwendet werden\n');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Fehler:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupLexOfficeMapping();
|
||||||
73
scripts/sync-nextcloud-bookings.ts
Normal file
73
scripts/sync-nextcloud-bookings.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
config(); // Load .env file
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { nextcloudCalendar } from '../lib/nextcloud-calendar.ts';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function syncExistingBookings() {
|
||||||
|
console.log('🔄 Synchronisiere bestehende Buchungen mit Nextcloud...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 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\n`);
|
||||||
|
|
||||||
|
if (bookings.length === 0) {
|
||||||
|
console.log('ℹ️ Keine Buchungen zum Synchronisieren gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let synced = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const booking of bookings) {
|
||||||
|
try {
|
||||||
|
console.log(`📅 Synchronisiere: ${booking.bookingNumber} - ${booking.customerName}`);
|
||||||
|
console.log(` Event: ${new Date(booking.eventDate).toLocaleDateString('de-DE')}`);
|
||||||
|
console.log(` Standort: ${booking.location?.name || 'Unbekannt'}`);
|
||||||
|
|
||||||
|
await nextcloudCalendar.syncBookingToCalendar(booking);
|
||||||
|
synced++;
|
||||||
|
console.log(` ✅ Erfolgreich!\n`);
|
||||||
|
} catch (error: any) {
|
||||||
|
failed++;
|
||||||
|
console.error(` ❌ Fehler: ${error.message}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('─'.repeat(50));
|
||||||
|
console.log(`✅ Erfolgreich synchronisiert: ${synced}`);
|
||||||
|
console.log(`❌ Fehlgeschlagen: ${failed}`);
|
||||||
|
console.log(`📊 Gesamt: ${bookings.length}`);
|
||||||
|
console.log('\n🎉 Synchronisation abgeschlossen!');
|
||||||
|
console.log(' Prüfen Sie Nextcloud → Kalender "Buchungen (Dennis Forte)"');
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Fehler beim Synchronisieren:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncExistingBookings()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Fatal error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
85
scripts/test-article-access.ts
Normal file
85
scripts/test-article-access.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { lexofficeService } from '../lib/lexoffice';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🔍 Teste LexOffice Artikel-Zugriff...\n');
|
||||||
|
|
||||||
|
const testArticleIds = [
|
||||||
|
'5d9d3716-f81e-4e46-b5cf-13988f489cc2', // Vintage ohne Flat
|
||||||
|
'3f26c02c-d705-41a6-9b49-2e2e96e77036', // Vintage mit Flat
|
||||||
|
'8954bd20-570c-4a7d-9ac3-8ee756652a89', // Nostalgie ohne Flat
|
||||||
|
'701bd150-48c0-4937-b628-f4a754d86264', // Nostalgie mit Flat
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const articleId of testArticleIds) {
|
||||||
|
try {
|
||||||
|
console.log(`\n📦 Teste Artikel-ID: ${articleId}`);
|
||||||
|
|
||||||
|
// Versuche den Artikel abzurufen
|
||||||
|
const response = await fetch(`https://api.lexoffice.io/v1/articles/${articleId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${process.env.LEXOFFICE_API_KEY}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const article = await response.json();
|
||||||
|
console.log('✅ Artikel gefunden:', article.title || article.name);
|
||||||
|
console.log(' Preis:', article.price?.netAmount, 'EUR');
|
||||||
|
} else {
|
||||||
|
const error = await response.text();
|
||||||
|
console.log('❌ Artikel nicht gefunden:', response.status, error);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log('❌ Fehler:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n\n🔍 Teste Quotation mit custom lineItem (ohne Artikel-ID)...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const testQuotation = {
|
||||||
|
voucherDate: new Date().toISOString().split('T')[0] + 'T00:00:00.000+01:00',
|
||||||
|
expirationDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + 'T00:00:00.000+01:00',
|
||||||
|
address: {
|
||||||
|
name: 'Test Kunde',
|
||||||
|
countryCode: 'DE',
|
||||||
|
},
|
||||||
|
lineItems: [
|
||||||
|
{
|
||||||
|
type: 'custom' as const,
|
||||||
|
name: 'Test Fotobox VINTAGE_PHOTOS mit Druckflatrate',
|
||||||
|
description: 'Test Beschreibung',
|
||||||
|
quantity: 1,
|
||||||
|
unitName: 'Stück',
|
||||||
|
unitPrice: {
|
||||||
|
currency: 'EUR',
|
||||||
|
netAmount: 449,
|
||||||
|
taxRatePercentage: 19,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalPrice: {
|
||||||
|
currency: 'EUR',
|
||||||
|
},
|
||||||
|
taxConditions: {
|
||||||
|
taxType: 'net' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('📤 Erstelle Test-Quotation mit custom lineItem...');
|
||||||
|
const result = await lexofficeService.createQuotation(testQuotation, true);
|
||||||
|
console.log('✅ Erfolgreich erstellt:', result.id);
|
||||||
|
console.log(' Voucher Number:', result.voucherNumber);
|
||||||
|
|
||||||
|
console.log('\n📄 Teste PDF Download...');
|
||||||
|
const pdf = await lexofficeService.getQuotationPDF(result.id);
|
||||||
|
console.log('✅ PDF erfolgreich heruntergeladen! Größe:', pdf.length, 'bytes');
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log('❌ Fehler:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
53
scripts/test-booking-automation.ts
Normal file
53
scripts/test-booking-automation.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { bookingAutomationService } from '../lib/booking-automation.ts';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function testAutomation() {
|
||||||
|
console.log('🧪 Teste Booking Automation...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Hole neueste Buchung
|
||||||
|
const latestBooking = await prisma.booking.findFirst({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
bookingNumber: true,
|
||||||
|
customerName: true,
|
||||||
|
eventDate: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!latestBooking) {
|
||||||
|
console.log('❌ Keine Buchung gefunden!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📋 Buchung: ${latestBooking.bookingNumber} - ${latestBooking.customerName}`);
|
||||||
|
console.log(` Event: ${new Date(latestBooking.eventDate).toLocaleDateString('de-DE')}\n`);
|
||||||
|
|
||||||
|
console.log('🤖 Starte automatische Aktionen...\n');
|
||||||
|
|
||||||
|
const result = await bookingAutomationService.runPostBookingActions(latestBooking.id);
|
||||||
|
|
||||||
|
console.log('\n' + '─'.repeat(60));
|
||||||
|
console.log('📊 Ergebnis:');
|
||||||
|
console.log(` ✅ E-Mail gesendet: ${result.emailSent ? 'Ja' : 'Nein'}`);
|
||||||
|
console.log(` ✅ Kalender synchronisiert: ${result.calendarSynced ? 'Ja' : 'Nein'}`);
|
||||||
|
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
console.log(`\n❌ Fehler (${result.errors.length}):`);
|
||||||
|
result.errors.forEach(err => console.log(` - ${err}`));
|
||||||
|
} else {
|
||||||
|
console.log('\n✅ Alle Aktionen erfolgreich!');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('\n❌ Fehler:', error.message);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testAutomation();
|
||||||
54
scripts/test-lexoffice-finalize.ts
Normal file
54
scripts/test-lexoffice-finalize.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { LexOfficeService } from '../lib/lexoffice';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const lexoffice = new LexOfficeService();
|
||||||
|
|
||||||
|
console.log('🧪 Teste LexOffice Quotation finalize Parameter...\n');
|
||||||
|
|
||||||
|
// Einfaches Test-Angebot erstellen
|
||||||
|
const testQuotation = {
|
||||||
|
voucherDate: new Date().toISOString().split('T')[0] + 'T00:00:00.000+01:00',
|
||||||
|
expirationDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + 'T00:00:00.000+01:00',
|
||||||
|
address: {
|
||||||
|
name: 'Test Kunde',
|
||||||
|
countryCode: 'DE',
|
||||||
|
},
|
||||||
|
lineItems: [
|
||||||
|
{
|
||||||
|
type: 'custom' as const,
|
||||||
|
name: 'Test Artikel',
|
||||||
|
quantity: 1,
|
||||||
|
unitName: 'Stück',
|
||||||
|
unitPrice: {
|
||||||
|
currency: 'EUR',
|
||||||
|
netAmount: 100,
|
||||||
|
taxRatePercentage: 19,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalPrice: {
|
||||||
|
currency: 'EUR',
|
||||||
|
},
|
||||||
|
taxConditions: {
|
||||||
|
taxType: 'net' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('📤 Erstelle Quotation MIT finalize=true...');
|
||||||
|
const result = await lexoffice.createQuotation(testQuotation, true);
|
||||||
|
console.log('✅ Quotation erstellt:', result);
|
||||||
|
|
||||||
|
console.log('\n🔍 Lade Quotation Details...');
|
||||||
|
const details = await lexoffice.getQuotation(result.id);
|
||||||
|
console.log('📊 Quotation Status:', JSON.stringify(details, null, 2));
|
||||||
|
|
||||||
|
console.log('\n📄 Versuche PDF Download...');
|
||||||
|
const pdf = await lexoffice.getQuotationPDF(result.id);
|
||||||
|
console.log('✅ PDF erfolgreich heruntergeladen! Größe:', pdf.length, 'bytes');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Fehler:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
209
sync-nextcloud-bookings.js
Normal file
209
sync-nextcloud-bookings.js
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const { createDAVClient } = require('tsdav');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
function loadEnv() {
|
||||||
|
const envPath = path.join(__dirname, '.env');
|
||||||
|
const envContent = fs.readFileSync(envPath, 'utf-8');
|
||||||
|
const env = {};
|
||||||
|
|
||||||
|
envContent.split('\n').forEach(line => {
|
||||||
|
const match = line.match(/^([^=:#]+)=(.*)$/);
|
||||||
|
if (match) {
|
||||||
|
const key = match[1].trim();
|
||||||
|
let value = match[2].trim();
|
||||||
|
|
||||||
|
if (value.startsWith('"') && value.endsWith('"')) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
value = value.replace(/\\"/g, '"');
|
||||||
|
value = value.replace(/\\\\/g, '\\');
|
||||||
|
} else if (value.startsWith("'") && value.endsWith("'")) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
env[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncBookingToCalendar(client, booking, env) {
|
||||||
|
try {
|
||||||
|
const calendars = await client.fetchCalendars();
|
||||||
|
|
||||||
|
if (calendars.length === 0) {
|
||||||
|
throw new Error('No calendars found in Nextcloud');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suche nach "Buchungen" Kalender, sonst verwende ersten
|
||||||
|
let calendar = calendars.find((cal) =>
|
||||||
|
cal.displayName?.toLowerCase().includes('buchung')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
console.log(` ⚠️ Kein "Buchungen"-Kalender gefunden, verwende: ${calendars[0].displayName}`);
|
||||||
|
calendar = calendars[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(booking.eventDate);
|
||||||
|
const endDate = new Date(startDate.getTime() + 4 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
summary: `${booking.customerName} - ${booking.location?.name || 'Unbekannt'}`,
|
||||||
|
description: `
|
||||||
|
Buchung #${booking.bookingNumber || booking.id}
|
||||||
|
Kunde: ${booking.customerName}
|
||||||
|
E-Mail: ${booking.customerEmail}
|
||||||
|
Telefon: ${booking.customerPhone || 'N/A'}
|
||||||
|
Event-Location: ${booking.eventLocation || booking.eventAddress}
|
||||||
|
Status: ${booking.status}
|
||||||
|
Fotobox: ${booking.photobox?.model || 'Keine Box'}
|
||||||
|
Standort: ${booking.location?.name || 'Unbekannt'}
|
||||||
|
Preis: ${booking.calculatedPrice || 0}€
|
||||||
|
`.trim(),
|
||||||
|
location: `${booking.eventAddress || ''}, ${booking.eventZip || ''} ${booking.eventCity || ''}`.trim(),
|
||||||
|
start: startDate.toISOString(),
|
||||||
|
end: endDate.toISOString(),
|
||||||
|
uid: `savethemoment-booking-${booking.id}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create iCalendar format
|
||||||
|
const icsContent = `BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//SaveTheMoment Atlas//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:${event.uid}
|
||||||
|
DTSTAMP:${new Date().toISOString().replace(/[-:]/g, '').split('.')[0]}Z
|
||||||
|
DTSTART:${startDate.toISOString().replace(/[-:]/g, '').split('.')[0]}Z
|
||||||
|
DTEND:${endDate.toISOString().replace(/[-:]/g, '').split('.')[0]}Z
|
||||||
|
SUMMARY:${event.summary}
|
||||||
|
DESCRIPTION:${event.description.replace(/\n/g, '\\n')}
|
||||||
|
LOCATION:${event.location}
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR`;
|
||||||
|
|
||||||
|
// Check if event already exists
|
||||||
|
const calendarObjects = await client.fetchCalendarObjects({
|
||||||
|
calendar,
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingEvent = calendarObjects.find(obj =>
|
||||||
|
obj.data && obj.data.includes(`UID:${event.uid}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEvent) {
|
||||||
|
// Update existing event
|
||||||
|
await client.updateCalendarObject({
|
||||||
|
calendarObject: {
|
||||||
|
url: existingEvent.url,
|
||||||
|
data: icsContent,
|
||||||
|
etag: existingEvent.etag,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new event
|
||||||
|
await client.createCalendarObject({
|
||||||
|
calendar,
|
||||||
|
filename: `${event.uid}.ics`,
|
||||||
|
iCalString: icsContent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncExistingBookings() {
|
||||||
|
console.log('🔄 Synchronisiere bestehende Buchungen mit Nextcloud...\n');
|
||||||
|
|
||||||
|
const env = loadEnv();
|
||||||
|
const serverUrl = env.NEXTCLOUD_URL;
|
||||||
|
const username = env.NEXTCLOUD_USERNAME;
|
||||||
|
const password = env.NEXTCLOUD_PASSWORD;
|
||||||
|
|
||||||
|
if (!serverUrl || !username || !password) {
|
||||||
|
console.error('❌ Missing Nextcloud credentials in .env file!');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('⏳ Verbinde mit Nextcloud...');
|
||||||
|
const client = await createDAVClient({
|
||||||
|
serverUrl: `${serverUrl}/remote.php/dav`,
|
||||||
|
credentials: {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
authMethod: 'Basic',
|
||||||
|
defaultAccountType: 'caldav',
|
||||||
|
});
|
||||||
|
console.log('✅ Nextcloud-Verbindung hergestellt!\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\n`);
|
||||||
|
|
||||||
|
if (bookings.length === 0) {
|
||||||
|
console.log('ℹ️ Keine Buchungen zum Synchronisieren gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let synced = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const booking of bookings) {
|
||||||
|
try {
|
||||||
|
console.log(`📅 Synchronisiere: ${booking.bookingNumber} - ${booking.customerName}`);
|
||||||
|
console.log(` Event: ${new Date(booking.eventDate).toLocaleDateString('de-DE')}`);
|
||||||
|
console.log(` Standort: ${booking.location?.name || 'Unbekannt'}`);
|
||||||
|
|
||||||
|
await syncBookingToCalendar(client, booking, env);
|
||||||
|
synced++;
|
||||||
|
console.log(` ✅ Erfolgreich!\n`);
|
||||||
|
} catch (error) {
|
||||||
|
failed++;
|
||||||
|
console.error(` ❌ Fehler: ${error.message}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('─'.repeat(50));
|
||||||
|
console.log(`✅ Erfolgreich synchronisiert: ${synced}`);
|
||||||
|
console.log(`❌ Fehlgeschlagen: ${failed}`);
|
||||||
|
console.log(`📊 Gesamt: ${bookings.length}`);
|
||||||
|
console.log('\n🎉 Synchronisation abgeschlossen!');
|
||||||
|
console.log(' Prüfen Sie Nextcloud → Kalender "Buchungen (Dennis Forte)"');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Fehler beim Synchronisieren:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncExistingBookings()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Fatal error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
70
test-nextcloud-sync-bookings.js
Normal file
70
test-nextcloud-sync-bookings.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const { nextcloudCalendar } = require('./lib/nextcloud-calendar');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function syncExistingBookings() {
|
||||||
|
console.log('🔄 Synchronisiere bestehende Buchungen mit Nextcloud...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 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\n`);
|
||||||
|
|
||||||
|
if (bookings.length === 0) {
|
||||||
|
console.log('ℹ️ Keine Buchungen zum Synchronisieren gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let synced = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const booking of bookings) {
|
||||||
|
try {
|
||||||
|
console.log(`📅 Synchronisiere: ${booking.bookingNumber} - ${booking.customerName}`);
|
||||||
|
console.log(` Event: ${new Date(booking.eventDate).toLocaleDateString('de-DE')}`);
|
||||||
|
console.log(` Standort: ${booking.location?.name || 'Unbekannt'}`);
|
||||||
|
|
||||||
|
await nextcloudCalendar.syncBookingToCalendar(booking);
|
||||||
|
synced++;
|
||||||
|
console.log(` ✅ Erfolgreich!\n`);
|
||||||
|
} catch (error) {
|
||||||
|
failed++;
|
||||||
|
console.error(` ❌ Fehler: ${error.message}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('─'.repeat(50));
|
||||||
|
console.log(`✅ Erfolgreich synchronisiert: ${synced}`);
|
||||||
|
console.log(`❌ Fehlgeschlagen: ${failed}`);
|
||||||
|
console.log(`📊 Gesamt: ${bookings.length}`);
|
||||||
|
console.log('\n🎉 Synchronisation abgeschlossen!');
|
||||||
|
console.log(' Prüfen Sie Nextcloud → Kalender "Buchungen"');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Fehler beim Synchronisieren:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncExistingBookings()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Fatal error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user