- 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
458 lines
18 KiB
TypeScript
458 lines
18 KiB
TypeScript
'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>
|
|
);
|
|
}
|