Files
Atlas/app/driver/tours/[id]/page.tsx
Julia Wehden a2c95c70e7 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
2026-03-19 16:21:55 +01:00

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>
);
}