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:
Julia Wehden
2026-03-19 16:21:55 +01:00
parent 0b6e429329
commit a2c95c70e7
79 changed files with 7396 additions and 538 deletions

View 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"
>
&larr; 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>
);
}

View File

@@ -2,14 +2,16 @@ import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { redirect } from 'next/navigation';
import BookingDetail from '@/components/BookingDetail';
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 } }) {
const session = await getServerSession(authOptions);
if (!session) {
redirect('/login');
if (!session || session.user.role !== 'ADMIN') {
redirect('/auth/signin');
}
const booking = await prisma.booking.findUnique({
@@ -17,16 +19,6 @@ export default async function BookingDetailPage({ params }: { params: { id: stri
include: {
location: 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');
}
const emails = await prisma.email.findMany({
where: { bookingId: booking.id },
orderBy: { receivedAt: 'desc' },
});
const getStatusColor = (status: string) => {
switch (status) {
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 (
<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} />
<DashboardSidebar user={session.user} />
<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>
</div>
</div>