mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 21:48:18 +00:00
916 lines
37 KiB
TypeScript
916 lines
37 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Plus, Edit, Trash2, X, Search, Train, Save, GripVertical } from 'lucide-react';
|
|
import DataTable from '@/components/ui/DataTable';
|
|
import Badge from '@/components/ui/Badge';
|
|
import ActionButton from '@/components/ui/ActionButton';
|
|
import Modal from '@/components/ui/Modal';
|
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
|
import { routesApi } from '@/lib/api/routes';
|
|
import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api';
|
|
import DateTimePicker from '@/components/ui/DateTimePicker';
|
|
|
|
// EAT ↔ UTC helpers (same as schedules page)
|
|
const EAT_MS = 3 * 60 * 60 * 1000;
|
|
const isoToEAT = (iso: string): string =>
|
|
new Date(new Date(iso).getTime() + EAT_MS).toISOString().slice(0, 16);
|
|
const eatToISO = (local: string): string =>
|
|
new Date(new Date(local + ':00Z').getTime() - EAT_MS).toISOString();
|
|
|
|
interface RouteStop {
|
|
stationId: string;
|
|
sequence: number;
|
|
distanceKm?: number;
|
|
distanceFromOrigin?: number;
|
|
checkinMinutesBefore?: number;
|
|
plannedArrivalTime?: string;
|
|
plannedDepartureTime?: string;
|
|
}
|
|
|
|
type Tab = 'routes' | 'coaches';
|
|
|
|
function RouteCoachesTab({ routes }: { routes: any[] }) {
|
|
const queryClient = useQueryClient();
|
|
const [selectedRouteId, setSelectedRouteId] = useState('');
|
|
const [coachRows, setCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
|
|
|
|
const { data: coaches } = useQuery({
|
|
queryKey: ['coaches-all'],
|
|
queryFn: () => fleetApi.getCoaches(),
|
|
});
|
|
|
|
const { data: template, isLoading: templateLoading } = useQuery({
|
|
queryKey: ['route-coaches', selectedRouteId],
|
|
queryFn: () => routeCoachTemplatesApi.get(selectedRouteId),
|
|
enabled: !!selectedRouteId,
|
|
});
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: () => routeCoachTemplatesApi.set(selectedRouteId, coachRows),
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['route-coaches', selectedRouteId] }),
|
|
});
|
|
|
|
const clearMutation = useMutation({
|
|
mutationFn: () => routeCoachTemplatesApi.clear(selectedRouteId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['route-coaches', selectedRouteId] });
|
|
setCoachRows([]);
|
|
},
|
|
});
|
|
|
|
// Sync coachRows when template loads
|
|
useEffect(() => {
|
|
const rows: any[] = Array.isArray(template) ? template : (template as any)?.coaches ?? [];
|
|
if (rows.length) setCoachRows(rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber })));
|
|
}, [template]);
|
|
|
|
const allCoaches: any[] = (coaches as any)?.items ?? (Array.isArray(coaches) ? coaches : []);
|
|
|
|
const addRow = () => setCoachRows([...coachRows, { coachId: '', positionNumber: coachRows.length + 1 }]);
|
|
const removeRow = (i: number) => {
|
|
const updated = coachRows.filter((_, idx) => idx !== i);
|
|
setCoachRows(updated.map((r, idx) => ({ ...r, positionNumber: idx + 1 })));
|
|
};
|
|
const updateRow = (i: number, field: 'coachId', val: string) => {
|
|
const updated = [...coachRows];
|
|
updated[i] = { ...updated[i], [field]: val };
|
|
setCoachRows(updated);
|
|
};
|
|
|
|
const selectedCoachIds = new Set(coachRows.map((r) => r.coachId).filter(Boolean));
|
|
|
|
const handleCoachDragStart = (e: React.DragEvent, index: number) => {
|
|
e.dataTransfer.setData('coach-index', index.toString());
|
|
};
|
|
const handleCoachDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
(e.currentTarget as HTMLElement).style.opacity = '0.5';
|
|
};
|
|
const handleCoachDragLeave = (e: React.DragEvent) => {
|
|
(e.currentTarget as HTMLElement).style.opacity = '1';
|
|
};
|
|
const handleCoachDrop = (e: React.DragEvent, targetIndex: number) => {
|
|
e.preventDefault();
|
|
(e.currentTarget as HTMLElement).style.opacity = '1';
|
|
const sourceIndex = parseInt(e.dataTransfer.getData('coach-index'));
|
|
if (sourceIndex === targetIndex) return;
|
|
const reordered = [...coachRows];
|
|
const [moved] = reordered.splice(sourceIndex, 1);
|
|
reordered.splice(targetIndex, 0, moved);
|
|
setCoachRows(reordered.map((r, idx) => ({ ...r, positionNumber: idx + 1 })));
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex-1">
|
|
<label className="label">Select Route</label>
|
|
<select className="input" value={selectedRouteId} onChange={(e) => { setSelectedRouteId(e.target.value); setCoachRows([]); }}>
|
|
<option value="">— choose a route —</option>
|
|
{routes.map((r: any) => <option key={r.id} value={r.id}>{r.name} ({r.code})</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{selectedRouteId && (
|
|
<>
|
|
{templateLoading ? (
|
|
<p className="text-sm text-muted-foreground">Loading template…</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{coachRows.length > 0 && (
|
|
<p className="text-xs text-muted-foreground">Drag <GripVertical className="inline h-3 w-3" /> to reorder coaches</p>
|
|
)}
|
|
{coachRows.map((row, i) => (
|
|
<div
|
|
key={i}
|
|
draggable
|
|
onDragStart={(e) => handleCoachDragStart(e, i)}
|
|
onDragOver={handleCoachDragOver}
|
|
onDragLeave={handleCoachDragLeave}
|
|
onDrop={(e) => handleCoachDrop(e, i)}
|
|
className="flex gap-2 items-center p-3 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors"
|
|
>
|
|
<GripVertical className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
|
<div className="w-8 text-center text-sm font-medium text-muted-foreground flex-shrink-0">
|
|
{row.positionNumber}
|
|
</div>
|
|
<div className="flex-1">
|
|
<select className="input input-sm" value={row.coachId} onChange={(e) => updateRow(i, 'coachId', e.target.value)}>
|
|
<option value="">Select Coach</option>
|
|
{allCoaches
|
|
.filter((c: any) => !selectedCoachIds.has(c.id) || c.id === row.coachId)
|
|
.map((c: any) => (
|
|
<option key={c.id} value={c.id}>{c.number} — {c.coachType?.name ?? c.type}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<button type="button" onClick={() => removeRow(i)} className="p-1 text-destructive hover:bg-destructive/10 rounded flex-shrink-0">
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
|
|
<div className="flex justify-between pt-2">
|
|
<ActionButton type="button" variant="secondary" size="sm" icon={Plus} onClick={addRow}>Add Coach</ActionButton>
|
|
<div className="flex gap-2">
|
|
{coachRows.length > 0 && (
|
|
<ActionButton type="button" variant="danger" size="sm" onClick={() => clearMutation.mutate()} loading={clearMutation.isPending}>
|
|
Clear
|
|
</ActionButton>
|
|
)}
|
|
<ActionButton type="button" size="sm" icon={Save} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
|
|
Save Template
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function RoutesPage() {
|
|
const [activeTab, setActiveTab] = useState<Tab>('routes');
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [editingRoute, setEditingRoute] = useState<any>(null);
|
|
const [editLoading, setEditLoading] = useState(false);
|
|
const [stops, setStops] = useState<RouteStop[]>([]);
|
|
const [originStationId, setOriginStationId] = useState('');
|
|
const [destinationStationId, setDestinationStationId] = useState('');
|
|
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
|
|
const [originCheckinMinutes, setOriginCheckinMinutes] = useState<number | undefined>(undefined);
|
|
const [destinationCheckinMinutes, setDestinationCheckinMinutes] = useState<number | undefined>(undefined);
|
|
const [originDepartureTime, setOriginDepartureTime] = useState<string>('');
|
|
const [destinationArrivalTime, setDestinationArrivalTime] = useState<string>('');
|
|
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null });
|
|
const [search, setSearch] = useState('');
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data: routes, isLoading: routesLoading } = useQuery({
|
|
queryKey: ['routes'],
|
|
queryFn: async () => {
|
|
const result = await routesApi.getAll();
|
|
return result;
|
|
},
|
|
});
|
|
|
|
const { data: stations } = useQuery({
|
|
queryKey: ['stations'],
|
|
queryFn: stationsApi.getAll,
|
|
});
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: routesApi.create,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['routes'] });
|
|
setShowModal(false);
|
|
setEditingRoute(null);
|
|
},
|
|
});
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: ({ id, data }: { id: string; data: any }) => routesApi.update(id, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['routes'] });
|
|
setShowModal(false);
|
|
setEditingRoute(null);
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => routesApi.delete(id, cascade),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['routes'] });
|
|
},
|
|
onError: (e: any) => {
|
|
const msg = e?.response?.data?.message || e?.message || 'Failed to delete route';
|
|
const isFkError = msg?.includes('Cannot delete') || e?.response?.status === 400;
|
|
if (isFkError && !deleteConfirm.cascade) {
|
|
setDeleteConfirm(prev => ({ ...prev, cascade: true, cascadeChecked: false, error: Array.isArray(msg) ? msg.join(' ') : msg }));
|
|
} else {
|
|
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg }));
|
|
}
|
|
},
|
|
});
|
|
|
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
const formData = new FormData(e.currentTarget);
|
|
|
|
if (!originStationId || !destinationStationId) {
|
|
alert('Please select origin and destination stations');
|
|
return;
|
|
}
|
|
|
|
if (originStationId === destinationStationId) {
|
|
alert('Origin and destination must be different');
|
|
return;
|
|
}
|
|
|
|
// Keep current stop order (already rearranged by user)
|
|
const sortedMiddleStops = stops;
|
|
|
|
// distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm)
|
|
const stopsArray = [
|
|
{
|
|
stationId: originStationId,
|
|
sequence: 1,
|
|
distanceKm: 0,
|
|
checkinMinutesBefore: originCheckinMinutes ?? undefined,
|
|
plannedDepartureTime: originDepartureTime ? eatToISO(originDepartureTime) : undefined,
|
|
},
|
|
...sortedMiddleStops.map((stop, idx) => ({
|
|
stationId: stop.stationId,
|
|
sequence: idx + 2,
|
|
distanceKm: stop.distanceFromOrigin || 0,
|
|
checkinMinutesBefore: stop.checkinMinutesBefore ?? undefined,
|
|
plannedArrivalTime: stop.plannedArrivalTime ? eatToISO(stop.plannedArrivalTime) : undefined,
|
|
plannedDepartureTime: stop.plannedDepartureTime ? eatToISO(stop.plannedDepartureTime) : undefined,
|
|
})),
|
|
{
|
|
stationId: destinationStationId,
|
|
sequence: sortedMiddleStops.length + 2,
|
|
distanceKm: destinationDistance || 0,
|
|
checkinMinutesBefore: destinationCheckinMinutes ?? undefined,
|
|
plannedArrivalTime: destinationArrivalTime ? eatToISO(destinationArrivalTime) : undefined,
|
|
},
|
|
];
|
|
|
|
const checkinRaw = formData.get('checkinMinutesBefore') as string;
|
|
const routeData = {
|
|
code: formData.get('code') as string,
|
|
name: formData.get('name') as string,
|
|
description: formData.get('description') as string || undefined,
|
|
active: !editingRoute ? (formData.get('active') !== 'false') : undefined,
|
|
effectiveFrom: formData.get('effectiveFrom') as string,
|
|
effectiveUntil: formData.get('effectiveUntil') as string || undefined,
|
|
checkinMinutesBefore: checkinRaw ? parseInt(checkinRaw) : undefined,
|
|
stops: stopsArray,
|
|
};
|
|
|
|
if (editingRoute) {
|
|
await updateMutation.mutateAsync({ id: editingRoute.id, data: routeData });
|
|
} else {
|
|
await createMutation.mutateAsync(routeData);
|
|
}
|
|
};
|
|
|
|
const addStop = () => {
|
|
setStops([...stops, { stationId: '', sequence: stops.length + 1, distanceFromOrigin: 0 }]);
|
|
};
|
|
|
|
const removeStop = (index: number) => {
|
|
setStops(stops.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const updateStop = (index: number, field: keyof RouteStop, value: any) => {
|
|
const updated = [...stops];
|
|
updated[index] = { ...updated[index], [field]: value };
|
|
setStops(updated);
|
|
};
|
|
|
|
const handleDragStart = (e: React.DragEvent, index: number) => {
|
|
e.dataTransfer.setData('text/plain', index.toString());
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
(e.currentTarget as HTMLElement).style.opacity = '0.5';
|
|
};
|
|
|
|
const handleDragLeave = (e: React.DragEvent) => {
|
|
(e.currentTarget as HTMLElement).style.opacity = '1';
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent, targetIndex: number) => {
|
|
e.preventDefault();
|
|
(e.currentTarget as HTMLElement).style.opacity = '1';
|
|
const sourceIndex = parseInt(e.dataTransfer.getData('text/plain'));
|
|
if (sourceIndex === targetIndex) return;
|
|
const newStops = [...stops];
|
|
const [draggedStop] = newStops.splice(sourceIndex, 1);
|
|
newStops.splice(targetIndex, 0, draggedStop);
|
|
setStops(newStops);
|
|
};
|
|
|
|
const generateRouteCode = (originId: string, destId: string) => {
|
|
if (!originId || !destId) return '';
|
|
const origin = stations?.items?.find((s: any) => s.id === originId);
|
|
const dest = stations?.items?.find((s: any) => s.id === destId);
|
|
return origin && dest ? `${origin.code}-${dest.code}` : '';
|
|
};
|
|
|
|
const generateRouteName = (originId: string, destId: string) => {
|
|
if (!originId || !destId) return '';
|
|
const origin = stations?.items?.find((s: any) => s.id === originId);
|
|
const dest = stations?.items?.find((s: any) => s.id === destId);
|
|
return origin && dest ? `${origin.name} - ${dest.name}` : '';
|
|
};
|
|
|
|
const handleDelete = (route: any) => {
|
|
setDeleteConfirm({ isOpen: true, route, error: undefined });
|
|
};
|
|
|
|
const confirmDelete = async () => {
|
|
if (!deleteConfirm.route) return;
|
|
try {
|
|
await deleteMutation.mutateAsync({ id: deleteConfirm.route.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked });
|
|
setDeleteConfirm({ isOpen: false, route: null });
|
|
} catch {
|
|
// error is set by onError handler
|
|
}
|
|
};
|
|
|
|
const routeColumns = [
|
|
{ key: 'code', label: 'Route Code', sortable: true },
|
|
{ key: 'name', label: 'Route Name', sortable: true },
|
|
{ key: 'description', label: 'Description', render: (route: any) => route.description || 'N/A' },
|
|
{
|
|
key: 'checkinMinutesBefore',
|
|
label: 'Check-in Cutoff',
|
|
render: (route: any) => `${route.checkinMinutesBefore ?? 30} min`,
|
|
},
|
|
{
|
|
key: 'active',
|
|
label: 'Status',
|
|
render: (route: any) => (
|
|
<Badge variant="status" status={route.active ? 'CONFIRMED' : 'CANCELLED'}>
|
|
{route.active ? 'Active' : 'Inactive'}
|
|
</Badge>
|
|
),
|
|
},
|
|
];
|
|
|
|
const filteredRoutes = (routes as any)?.items || (Array.isArray(routes) ? routes : []);
|
|
const displayedRoutes = filteredRoutes.filter((route: any) => {
|
|
if (!search) return true;
|
|
const searchLower = search.toLowerCase();
|
|
return (
|
|
route.code?.toLowerCase().includes(searchLower) ||
|
|
route.name?.toLowerCase().includes(searchLower) ||
|
|
route.description?.toLowerCase().includes(searchLower)
|
|
);
|
|
});
|
|
|
|
const openEditModal = async (route: any) => {
|
|
setEditLoading(true);
|
|
try {
|
|
const full = await routesApi.getById(route.id) as any;
|
|
const routeStops: any[] = full?.stops || [];
|
|
setEditingRoute(full ?? route);
|
|
if (routeStops.length >= 2) {
|
|
const originStop = routeStops[0];
|
|
const destStop = routeStops[routeStops.length - 1];
|
|
setOriginStationId(originStop.stationId);
|
|
setOriginCheckinMinutes(originStop.checkinMinutesBefore ?? undefined);
|
|
setOriginDepartureTime(originStop.plannedDepartureTime ? isoToEAT(originStop.plannedDepartureTime) : '');
|
|
setDestinationStationId(destStop.stationId);
|
|
setDestinationCheckinMinutes(destStop.checkinMinutesBefore ?? undefined);
|
|
setDestinationArrivalTime(destStop.plannedArrivalTime ? isoToEAT(destStop.plannedArrivalTime) : '');
|
|
setDestinationDistance(destStop.distanceKm || 0);
|
|
setStops(routeStops.slice(1, -1).map((s: any) => ({
|
|
stationId: s.stationId,
|
|
sequence: s.sequence,
|
|
distanceKm: s.distanceKm,
|
|
distanceFromOrigin: s.distanceKm || 0,
|
|
checkinMinutesBefore: s.checkinMinutesBefore ?? undefined,
|
|
plannedArrivalTime: s.plannedArrivalTime ? isoToEAT(s.plannedArrivalTime) : '',
|
|
plannedDepartureTime: s.plannedDepartureTime ? isoToEAT(s.plannedDepartureTime) : '',
|
|
})));
|
|
}
|
|
setShowModal(true);
|
|
} finally {
|
|
setEditLoading(false);
|
|
}
|
|
};
|
|
|
|
const routeActions = [
|
|
{
|
|
label: 'Edit',
|
|
onClick: openEditModal,
|
|
variant: 'secondary' as const,
|
|
icon: Edit,
|
|
},
|
|
{
|
|
label: 'Delete',
|
|
onClick: handleDelete,
|
|
variant: 'danger' as const,
|
|
icon: Trash2,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold">Routes</h1>
|
|
<p className="text-muted-foreground">Manage railway routes and coach templates</p>
|
|
</div>
|
|
{activeTab === 'routes' && (
|
|
<ActionButton
|
|
icon={Plus}
|
|
onClick={() => {
|
|
setEditingRoute(null);
|
|
setOriginStationId('');
|
|
setOriginCheckinMinutes(undefined);
|
|
setOriginDepartureTime('');
|
|
setDestinationStationId('');
|
|
setDestinationCheckinMinutes(undefined);
|
|
setDestinationArrivalTime('');
|
|
setDestinationDistance(undefined);
|
|
setStops([]);
|
|
setShowModal(true);
|
|
}}
|
|
>
|
|
Add Route
|
|
</ActionButton>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-1 border-b">
|
|
{(['routes', 'coaches'] as Tab[]).map((tab) => (
|
|
<button
|
|
key={tab}
|
|
onClick={() => setActiveTab(tab)}
|
|
className={`px-4 py-2 text-sm font-medium capitalize transition-colors border-b-2 -mb-px ${
|
|
activeTab === tab
|
|
? 'border-primary text-primary'
|
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
>
|
|
{tab === 'coaches' ? 'Coach Templates' : 'Routes'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{activeTab === 'routes' && (
|
|
<>
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search by code, name, or description..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="input pl-10 w-full"
|
|
/>
|
|
</div>
|
|
|
|
<DataTable
|
|
data={displayedRoutes}
|
|
columns={routeColumns}
|
|
actions={routeActions}
|
|
loading={routesLoading || editLoading}
|
|
emptyMessage={search ? 'No routes match your search' : 'No routes found'}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{activeTab === 'coaches' && (
|
|
<RouteCoachesTab routes={filteredRoutes} />
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
isOpen={deleteConfirm.isOpen}
|
|
onClose={() => setDeleteConfirm({ isOpen: false, route: null })}
|
|
onConfirm={confirmDelete}
|
|
title="Delete Route"
|
|
message={`Are you sure you want to delete ${deleteConfirm.route?.name}?`}
|
|
confirmText="Delete"
|
|
isDanger={true}
|
|
isLoading={deleteMutation.isPending}
|
|
error={deleteConfirm.error}
|
|
warning={!deleteConfirm.cascade ? "This route may be referenced by schedules and bookings. Deleting it may impact these systems." : undefined}
|
|
cascadeWarning={deleteConfirm.cascade ? "This route has related schedules that will also be permanently deleted." : undefined}
|
|
cascadeChecked={deleteConfirm.cascadeChecked}
|
|
onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))}
|
|
/>
|
|
|
|
<Modal
|
|
isOpen={showModal}
|
|
onClose={() => {
|
|
setShowModal(false);
|
|
setEditingRoute(null);
|
|
setOriginStationId('');
|
|
setOriginCheckinMinutes(undefined);
|
|
setDestinationStationId('');
|
|
setDestinationCheckinMinutes(undefined);
|
|
setDestinationDistance(undefined);
|
|
setStops([]);
|
|
setSearch('');
|
|
}}
|
|
title={`${editingRoute ? 'Edit' : 'Add'} Route`}
|
|
size="xl"
|
|
>
|
|
<form onSubmit={handleSubmit} className="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
|
|
{editingRoute && (
|
|
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 p-3 text-sm text-yellow-800 dark:text-yellow-200">
|
|
<p className="font-semibold">⚠ Warning</p>
|
|
<p className="mt-1">Editing this route may impact schedules, trips, and bookings that reference it. Proceed with caution.</p>
|
|
</div>
|
|
)}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="label">Origin Station *</label>
|
|
<select
|
|
className="input"
|
|
value={originStationId}
|
|
onChange={(e) => setOriginStationId(e.target.value)}
|
|
required
|
|
disabled={!!editingRoute}
|
|
>
|
|
<option value="">Select Origin</option>
|
|
{stations?.items?.map((station: any) => (
|
|
<option key={station.id} value={station.id}>
|
|
{station.name} ({station.code})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="label">Destination Station *</label>
|
|
<select
|
|
className="input"
|
|
value={destinationStationId}
|
|
onChange={(e) => setDestinationStationId(e.target.value)}
|
|
required
|
|
disabled={!!editingRoute}
|
|
>
|
|
<option value="">Select Destination</option>
|
|
{stations?.items?.map((station: any) => (
|
|
<option key={station.id} value={station.id}>
|
|
{station.name} ({station.code})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="label">Route Code *</label>
|
|
<input
|
|
type="text"
|
|
name="code"
|
|
className="input"
|
|
defaultValue={editingRoute ? editingRoute.code : undefined}
|
|
key={editingRoute ? `code-edit-${editingRoute.id}` : `code-new-${originStationId}-${destinationStationId}`}
|
|
placeholder={generateRouteCode(originStationId, destinationStationId) || 'e.g. ADD-DJI'}
|
|
required
|
|
/>
|
|
{!editingRoute && originStationId && destinationStationId && (
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Suggested: <button type="button" className="text-primary underline" onClick={(e) => { const inp = (e.currentTarget.closest('.grid')?.querySelector('input[name=code]') as HTMLInputElement); if (inp) inp.value = generateRouteCode(originStationId, destinationStationId); }}>{generateRouteCode(originStationId, destinationStationId)}</button>
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<label className="label">Route Name *</label>
|
|
<input
|
|
type="text"
|
|
name="name"
|
|
className="input"
|
|
defaultValue={editingRoute ? editingRoute.name : undefined}
|
|
key={editingRoute ? `name-edit-${editingRoute.id}` : `name-new-${originStationId}-${destinationStationId}`}
|
|
placeholder={generateRouteName(originStationId, destinationStationId) || 'e.g. Addis Ababa - Djibouti'}
|
|
required
|
|
/>
|
|
{!editingRoute && originStationId && destinationStationId && (
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Suggested: <button type="button" className="text-primary underline" onClick={(e) => { const inp = (e.currentTarget.closest('.grid')?.querySelector('input[name=name]') as HTMLInputElement); if (inp) inp.value = generateRouteName(originStationId, destinationStationId); }}>{generateRouteName(originStationId, destinationStationId)}</button>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">Description</label>
|
|
<textarea
|
|
name="description"
|
|
className="input"
|
|
rows={2}
|
|
defaultValue={editingRoute?.description}
|
|
placeholder="Outbound local route from [Origin] to [Destination]"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">Check-in Cutoff (minutes before departure)</label>
|
|
<input
|
|
type="number"
|
|
name="checkinMinutesBefore"
|
|
className="input"
|
|
defaultValue={editingRoute?.checkinMinutesBefore ?? 30}
|
|
min={1}
|
|
max={480}
|
|
step={1}
|
|
required
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Booking closes and check-in ends this many minutes before each stop's departure. Default: 30.
|
|
</p>
|
|
</div>
|
|
|
|
{!editingRoute && (
|
|
<div>
|
|
<label className="label">Status</label>
|
|
<select name="active" className="input" defaultValue="true">
|
|
<option value="true">Active</option>
|
|
<option value="false">Inactive</option>
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="label">Effective From *</label>
|
|
<input
|
|
type="datetime-local"
|
|
name="effectiveFrom"
|
|
className="input"
|
|
defaultValue={editingRoute?.effectiveFrom ? new Date(editingRoute.effectiveFrom).toISOString().slice(0, 16) : new Date().toISOString().slice(0, 16)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="label">Effective Until</label>
|
|
<input
|
|
type="datetime-local"
|
|
name="effectiveUntil"
|
|
className="input"
|
|
defaultValue={editingRoute?.effectiveUntil ? new Date(editingRoute.effectiveUntil).toISOString().slice(0, 16) : ''}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t pt-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<label className="label mb-0">Route Stops</label>
|
|
<span className="text-xs text-muted-foreground">Drag to rearrange · <span className="font-medium">Cutoff</span> overrides check-in · <span className="font-medium">Arr/Dep time</span> sets default times (auto-filled on schedule creation)</span>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary">
|
|
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
|
|
1
|
|
</div>
|
|
<div className="flex-1 font-medium">
|
|
{originStationId ? (
|
|
<span>
|
|
{stations?.items?.find((s: any) => s.id === originStationId)?.name || 'Unknown'}
|
|
{' '}({stations?.items?.find((s: any) => s.id === originStationId)?.code || 'N/A'})
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground">Select origin station above</span>
|
|
)}
|
|
</div>
|
|
<div className="w-24 flex-shrink-0">
|
|
<input
|
|
type="number"
|
|
className="input input-sm"
|
|
placeholder="cutoff min"
|
|
value={originCheckinMinutes ?? ''}
|
|
onChange={(e) => setOriginCheckinMinutes(e.target.value ? parseInt(e.target.value) : undefined)}
|
|
min={1}
|
|
title="Check-in cutoff override (minutes) for this stop"
|
|
/>
|
|
</div>
|
|
<div className="w-44 flex-shrink-0">
|
|
<DateTimePicker
|
|
value={originDepartureTime}
|
|
onChange={setOriginDepartureTime}
|
|
placeholder="Dep time"
|
|
label="Planned Departure"
|
|
/>
|
|
</div>
|
|
<div className="text-sm text-muted-foreground w-10 text-right flex-shrink-0">0 km</div>
|
|
</div>
|
|
|
|
{stops.map((stop, index) => (
|
|
<div
|
|
key={index}
|
|
draggable
|
|
onDragStart={(e) => handleDragStart(e, index)}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={(e) => handleDrop(e, index)}
|
|
className="flex gap-2 items-center p-3 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors"
|
|
>
|
|
<div className="flex-shrink-0 w-8 h-8 bg-secondary text-secondary-foreground rounded-full flex items-center justify-center text-sm font-medium">
|
|
{index + 2}
|
|
</div>
|
|
<div className="flex-1">
|
|
<select
|
|
className="input input-sm"
|
|
value={stop.stationId}
|
|
onChange={(e) => updateStop(index, 'stationId', e.target.value)}
|
|
required
|
|
>
|
|
<option value="">Select Station</option>
|
|
{stations?.items?.filter((s: any) =>
|
|
s.id !== originStationId &&
|
|
s.id !== destinationStationId &&
|
|
!stops.some((st, idx) => idx !== index && st.stationId === s.id)
|
|
).map((station: any) => (
|
|
<option key={station.id} value={station.id}>
|
|
{station.name} ({station.code})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="w-20">
|
|
<input
|
|
type="number"
|
|
className="input input-sm"
|
|
placeholder="km"
|
|
value={stop.distanceFromOrigin || ''}
|
|
onChange={(e) => updateStop(index, 'distanceFromOrigin', e.target.value ? parseFloat(e.target.value) : undefined)}
|
|
min="0"
|
|
step="0.1"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="w-20">
|
|
<input
|
|
type="number"
|
|
className="input input-sm"
|
|
placeholder="cutoff"
|
|
value={stop.checkinMinutesBefore ?? ''}
|
|
onChange={(e) => updateStop(index, 'checkinMinutesBefore', e.target.value ? parseInt(e.target.value) : undefined)}
|
|
min={1}
|
|
title="Check-in cutoff override (minutes) for this stop"
|
|
/>
|
|
</div>
|
|
<div className="w-44">
|
|
<DateTimePicker
|
|
value={stop.plannedDepartureTime ?? ''}
|
|
onChange={(v) => updateStop(index, 'plannedDepartureTime', v)}
|
|
placeholder="Dep time"
|
|
label="Planned Departure"
|
|
/>
|
|
</div>
|
|
<div className="w-44">
|
|
<DateTimePicker
|
|
value={stop.plannedArrivalTime ?? ''}
|
|
onChange={(v) => updateStop(index, 'plannedArrivalTime', v)}
|
|
placeholder="Arr time"
|
|
label="Planned Arrival"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeStop(index)}
|
|
className="flex-shrink-0 p-1 text-destructive hover:bg-destructive/10 rounded"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
|
|
{originStationId && destinationStationId && (
|
|
<div className="flex justify-center py-2">
|
|
<ActionButton
|
|
type="button"
|
|
variant="secondary"
|
|
size="sm"
|
|
icon={Plus}
|
|
onClick={addStop}
|
|
>
|
|
Add Intermediate Stop
|
|
</ActionButton>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary">
|
|
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
|
|
{stops.length + 2}
|
|
</div>
|
|
<div className="flex-1 font-medium">
|
|
{destinationStationId ? (
|
|
<span>
|
|
{stations?.items?.find((s: any) => s.id === destinationStationId)?.name || 'Unknown'}
|
|
{' '}({stations?.items?.find((s: any) => s.id === destinationStationId)?.code || 'N/A'})
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground">Select destination station above</span>
|
|
)}
|
|
</div>
|
|
<div className="w-24">
|
|
{destinationStationId && (
|
|
<input
|
|
type="number"
|
|
className="input input-sm"
|
|
placeholder="cutoff min"
|
|
value={destinationCheckinMinutes ?? ''}
|
|
onChange={(e) => setDestinationCheckinMinutes(e.target.value ? parseInt(e.target.value) : undefined)}
|
|
min={1}
|
|
title="Check-in cutoff override (minutes) for this stop"
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="w-44" />
|
|
<div className="w-44">
|
|
{destinationStationId && (
|
|
<DateTimePicker
|
|
value={destinationArrivalTime}
|
|
onChange={setDestinationArrivalTime}
|
|
placeholder="Arr time"
|
|
label="Planned Arrival"
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="w-24">
|
|
{destinationStationId && (
|
|
<input
|
|
type="number"
|
|
className="input input-sm"
|
|
placeholder="km"
|
|
value={destinationDistance || ''}
|
|
onChange={(e) => setDestinationDistance(e.target.value ? parseFloat(e.target.value) : undefined)}
|
|
min="0"
|
|
step="0.1"
|
|
required
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 pt-4">
|
|
<ActionButton
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
setShowModal(false);
|
|
setEditingRoute(null);
|
|
setOriginStationId('');
|
|
setOriginCheckinMinutes(undefined);
|
|
setDestinationStationId('');
|
|
setDestinationCheckinMinutes(undefined);
|
|
setDestinationDistance(undefined);
|
|
setStops([]);
|
|
setSearch('');
|
|
}}
|
|
>
|
|
Cancel
|
|
</ActionButton>
|
|
<ActionButton
|
|
type="submit"
|
|
loading={createMutation.isPending || updateMutation.isPending}
|
|
>
|
|
{editingRoute ? 'Update' : 'Create'} Route
|
|
</ActionButton>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|