Files
edr-platform/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx

573 lines
21 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, X, Search } 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 } from '@/lib/api';
interface RouteStop {
stationId: string;
sequence: number;
distanceKm?: number;
distanceFromOrigin?: number;
}
export default function RoutesPage() {
const [showModal, setShowModal] = useState(false);
const [editingRoute, setEditingRoute] = useState<any>(null);
const [stops, setStops] = useState<RouteStop[]>([]);
const [originStationId, setOriginStationId] = useState('');
const [destinationStationId, setDestinationStationId] = useState('');
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null }>({ 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();
console.log('Routes query result:', result);
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: routesApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['routes'] });
},
});
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;
// Calculate distanceKm (distance from previous stop)
const stopsArray = [
{ stationId: originStationId, sequence: 1, distanceKm: 0 },
...sortedMiddleStops.map((stop, idx) => {
const prevDistance = idx === 0 ? 0 : (sortedMiddleStops[idx - 1].distanceFromOrigin || 0);
return {
stationId: stop.stationId,
sequence: idx + 2,
distanceKm: (stop.distanceFromOrigin || 0) - prevDistance,
};
}),
{
stationId: destinationStationId,
sequence: sortedMiddleStops.length + 2,
distanceKm: (destinationDistance || 0) - (sortedMiddleStops.length > 0 ? (sortedMiddleStops[sortedMiddleStops.length - 1].distanceFromOrigin || 0) : 0),
},
];
const routeData = {
code: formData.get('code') as string,
name: formData.get('name') as string,
description: formData.get('description') as string || undefined,
effectiveFrom: formData.get('effectiveFrom') as string,
effectiveUntil: formData.get('effectiveUntil') as string || undefined,
stops: stopsArray,
};
console.log('Submitting route data:', JSON.stringify(routeData, null, 2));
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 });
};
const confirmDelete = async () => {
if (deleteConfirm.route) {
await deleteMutation.mutateAsync(deleteConfirm.route.id);
setDeleteConfirm({ isOpen: false, route: null });
}
};
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: '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 routeActions = [
{
label: 'Edit',
onClick: (route: any) => {
setEditingRoute(route);
const routeStops = route.stops || [];
if (routeStops.length >= 2) {
setOriginStationId(routeStops[0].stationId);
setDestinationStationId(routeStops[routeStops.length - 1].stationId);
// Last stop's distanceKm is already cumulative from origin
setDestinationDistance(routeStops[routeStops.length - 1].distanceKm || 0);
const middleStops = routeStops.slice(1, -1).map((stop: any) => ({
stationId: stop.stationId,
sequence: stop.sequence,
distanceKm: stop.distanceKm,
distanceFromOrigin: stop.distanceKm || 0,
}));
setStops(middleStops);
}
setShowModal(true);
},
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</p>
</div>
<ActionButton
icon={Plus}
onClick={() => {
setEditingRoute(null);
setOriginStationId('');
setDestinationStationId('');
setDestinationDistance(undefined);
setStops([]);
setSearch('');
setShowModal(true);
}}
>
Add Route
</ActionButton>
</div>
<div className="relative mb-6">
<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}
emptyMessage={search ? "No routes match your search" : "No routes found"}
/>
<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}
warning="This route may be referenced by schedules and bookings. Deleting it may impact these systems."
/>
<Modal
isOpen={showModal}
onClose={() => {
setShowModal(false);
setEditingRoute(null);
setOriginStationId('');
setDestinationStationId('');
setDestinationDistance(undefined);
setStops([]);
setSearch('');
}}
title={`${editingRoute ? 'Edit' : 'Add'} Route`}
size="lg"
>
<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"
value={generateRouteCode(originStationId, destinationStationId)}
readOnly
required
placeholder="Select stations to generate"
disabled={!!editingRoute}
/>
</div>
<div>
<label className="label">Route Name *</label>
<input
type="text"
name="name"
className="input"
value={generateRouteName(originStationId, destinationStationId)}
readOnly
required
placeholder="Select stations to generate"
/>
</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 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 intermediate stops</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="text-sm text-muted-foreground">
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-32">
<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>
<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-32">
{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('');
setDestinationStationId('');
setDestinationDistance(undefined);
setStops([]);
setSearch('');
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createMutation.isPending || updateMutation.isPending}
>
{editingRoute ? 'Update' : 'Create'} Route
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}