mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } 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';
|
||||
@@ -10,6 +10,7 @@ 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 { eatLocalToISO, isoToEATLocal } from '@/lib/timezone';
|
||||
|
||||
interface RouteStop {
|
||||
stationId: string;
|
||||
@@ -17,6 +18,7 @@ interface RouteStop {
|
||||
distanceKm?: number;
|
||||
distanceFromOrigin?: number;
|
||||
checkinMinutesBefore?: number;
|
||||
travelMinutesToStop?: number;
|
||||
}
|
||||
|
||||
type Tab = 'routes' | 'coaches';
|
||||
@@ -175,8 +177,18 @@ export default function RoutesPage() {
|
||||
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
|
||||
const [originCheckinMinutes, setOriginCheckinMinutes] = useState<number | undefined>(undefined);
|
||||
const [destinationCheckinMinutes, setDestinationCheckinMinutes] = useState<number | undefined>(undefined);
|
||||
const [destinationTravelMinutes, setDestinationTravelMinutes] = useState<number | undefined>(undefined);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null });
|
||||
const [search, setSearch] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const errorBannerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The form scrolls internally (long stop lists push the error banner above the fold), so a
|
||||
// submit failure can land silently off-screen with no visible indication anything went wrong.
|
||||
// Scroll the banner into view whenever a new error appears.
|
||||
useEffect(() => {
|
||||
if (error) errorBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, [error]);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: routes, isLoading: routesLoading } = useQuery({
|
||||
@@ -198,6 +210,11 @@ export default function RoutesPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ['routes'] });
|
||||
setShowModal(false);
|
||||
setEditingRoute(null);
|
||||
setError(null);
|
||||
},
|
||||
onError: (e: any) => {
|
||||
const msg = e?.response?.data?.message || e?.message || 'Failed to create route';
|
||||
setError(Array.isArray(msg) ? msg.join(' ') : msg);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -207,6 +224,11 @@ export default function RoutesPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ['routes'] });
|
||||
setShowModal(false);
|
||||
setEditingRoute(null);
|
||||
setError(null);
|
||||
},
|
||||
onError: (e: any) => {
|
||||
const msg = e?.response?.data?.message || e?.message || 'Failed to update route';
|
||||
setError(Array.isArray(msg) ? msg.join(' ') : msg);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -244,6 +266,8 @@ export default function RoutesPage() {
|
||||
const sortedMiddleStops = stops;
|
||||
|
||||
// distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm)
|
||||
// travelMinutesToStop = minutes of travel from the PREVIOUS stop, used to estimate this
|
||||
// stop's arrival time. The origin (sequence 1) has no predecessor, so it gets none.
|
||||
const stopsArray = [
|
||||
{ stationId: originStationId, sequence: 1, distanceKm: 0, checkinMinutesBefore: originCheckinMinutes ?? undefined },
|
||||
...sortedMiddleStops.map((stop, idx) => ({
|
||||
@@ -251,12 +275,14 @@ export default function RoutesPage() {
|
||||
sequence: idx + 2,
|
||||
distanceKm: stop.distanceFromOrigin || 0,
|
||||
checkinMinutesBefore: stop.checkinMinutesBefore ?? undefined,
|
||||
travelMinutesToStop: stop.travelMinutesToStop ?? undefined,
|
||||
})),
|
||||
{
|
||||
stationId: destinationStationId,
|
||||
sequence: sortedMiddleStops.length + 2,
|
||||
distanceKm: destinationDistance || 0,
|
||||
checkinMinutesBefore: destinationCheckinMinutes ?? undefined,
|
||||
travelMinutesToStop: destinationTravelMinutes ?? undefined,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -266,8 +292,10 @@ export default function RoutesPage() {
|
||||
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,
|
||||
effectiveFrom: eatLocalToISO(formData.get('effectiveFrom') as string),
|
||||
// null (not undefined) so clearing the field on an edit explicitly clears effectiveUntil
|
||||
// server-side, instead of being silently dropped as "no change".
|
||||
effectiveUntil: formData.get('effectiveUntil') ? eatLocalToISO(formData.get('effectiveUntil') as string) : null,
|
||||
checkinMinutesBefore: checkinRaw ? parseInt(checkinRaw) : undefined,
|
||||
stops: stopsArray,
|
||||
};
|
||||
@@ -280,7 +308,12 @@ export default function RoutesPage() {
|
||||
};
|
||||
|
||||
const addStop = () => {
|
||||
setStops([...stops, { stationId: '', sequence: stops.length + 1, distanceFromOrigin: 0 }]);
|
||||
// distanceKm must be cumulative and strictly increasing (enforced server-side) — defaulting
|
||||
// every new stop to 0 made each one collide with the previous, so simply clicking "Add
|
||||
// Intermediate Stop" a few times and saving without hand-editing every distance always failed
|
||||
// validation. Default each new stop's distance a step past whatever precedes it instead.
|
||||
const lastDistance = stops.length > 0 ? (stops[stops.length - 1].distanceFromOrigin ?? 0) : 0;
|
||||
setStops([...stops, { stationId: '', sequence: stops.length + 1, distanceFromOrigin: lastDistance + 10 }]);
|
||||
};
|
||||
|
||||
const removeStop = (index: number) => {
|
||||
@@ -390,14 +423,17 @@ export default function RoutesPage() {
|
||||
setDestinationStationId(destStop.stationId);
|
||||
setDestinationCheckinMinutes(destStop.checkinMinutesBefore ?? undefined);
|
||||
setDestinationDistance(destStop.distanceKm || 0);
|
||||
setDestinationTravelMinutes(destStop.travelMinutesToStop ?? undefined);
|
||||
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,
|
||||
travelMinutesToStop: s.travelMinutesToStop ?? undefined,
|
||||
})));
|
||||
}
|
||||
setError(null);
|
||||
setShowModal(true);
|
||||
} finally {
|
||||
setEditLoading(false);
|
||||
@@ -436,7 +472,9 @@ export default function RoutesPage() {
|
||||
setDestinationStationId('');
|
||||
setDestinationCheckinMinutes(undefined);
|
||||
setDestinationDistance(undefined);
|
||||
setDestinationTravelMinutes(undefined);
|
||||
setStops([]);
|
||||
setError(null);
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
@@ -515,13 +553,20 @@ export default function RoutesPage() {
|
||||
setDestinationStationId('');
|
||||
setDestinationCheckinMinutes(undefined);
|
||||
setDestinationDistance(undefined);
|
||||
setDestinationTravelMinutes(undefined);
|
||||
setStops([]);
|
||||
setSearch('');
|
||||
setError(null);
|
||||
}}
|
||||
title={`${editingRoute ? 'Edit' : 'Add'} Route`}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
|
||||
{error && (
|
||||
<div ref={errorBannerRef} className="bg-red-50 p-3 rounded-lg text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
@@ -647,7 +692,7 @@ export default function RoutesPage() {
|
||||
type="datetime-local"
|
||||
name="effectiveFrom"
|
||||
className="input"
|
||||
defaultValue={editingRoute?.effectiveFrom ? new Date(editingRoute.effectiveFrom).toISOString().slice(0, 16) : new Date().toISOString().slice(0, 16)}
|
||||
defaultValue={editingRoute?.effectiveFrom ? isoToEATLocal(editingRoute.effectiveFrom) : isoToEATLocal(new Date())}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -657,7 +702,7 @@ export default function RoutesPage() {
|
||||
type="datetime-local"
|
||||
name="effectiveUntil"
|
||||
className="input"
|
||||
defaultValue={editingRoute?.effectiveUntil ? new Date(editingRoute.effectiveUntil).toISOString().slice(0, 16) : ''}
|
||||
defaultValue={editingRoute?.effectiveUntil ? isoToEATLocal(editingRoute.effectiveUntil) : ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -665,7 +710,7 @@ export default function RoutesPage() {
|
||||
<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 min</span> overrides route check-in window per stop (leave blank to inherit)</span>
|
||||
<span className="text-xs text-muted-foreground">Drag to rearrange · <span className="font-medium">Travel min</span> estimates arrival from the previous stop (falls back to distance if blank) · <span className="font-medium">Cutoff min</span> overrides route check-in window per stop (leave blank to inherit)</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -741,6 +786,17 @@ export default function RoutesPage() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="w-28">
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm"
|
||||
placeholder="travel min"
|
||||
value={stop.travelMinutesToStop ?? ''}
|
||||
onChange={(e) => updateStop(index, 'travelMinutesToStop', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
min={1}
|
||||
title="Travel time in minutes from the previous stop, used to estimate this stop's arrival time"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-28">
|
||||
<input
|
||||
type="number"
|
||||
@@ -817,6 +873,19 @@ export default function RoutesPage() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-28">
|
||||
{destinationStationId && (
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm"
|
||||
placeholder="travel min"
|
||||
value={destinationTravelMinutes ?? ''}
|
||||
onChange={(e) => setDestinationTravelMinutes(e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
min={1}
|
||||
title="Travel time in minutes from the previous stop, used to estimate this stop's arrival time"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -833,8 +902,10 @@ export default function RoutesPage() {
|
||||
setDestinationStationId('');
|
||||
setDestinationCheckinMinutes(undefined);
|
||||
setDestinationDistance(undefined);
|
||||
setDestinationTravelMinutes(undefined);
|
||||
setStops([]);
|
||||
setSearch('');
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical } from 'lucide-react';
|
||||
import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical, RefreshCw } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
@@ -12,6 +12,7 @@ import { routeCoachTemplatesApi } from '@/lib/api';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import { usePagination } from '@/lib/use-pagination';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone';
|
||||
import DateTimePicker from '@/components/ui/DateTimePicker';
|
||||
|
||||
interface Schedule {
|
||||
@@ -60,8 +61,15 @@ export default function SchedulesPage() {
|
||||
{ isOpen: false, item: null }
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const errorBannerRef = useRef<HTMLDivElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// These modals can scroll internally — a submit failure can land silently off-screen with no
|
||||
// visible indication anything went wrong. Scroll the banner into view when a new error appears.
|
||||
useEffect(() => {
|
||||
if (error) errorBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, [error]);
|
||||
|
||||
const [bulkForm, setBulkForm] = useState({
|
||||
trainId: '',
|
||||
routeId: '',
|
||||
@@ -167,7 +175,8 @@ export default function SchedulesPage() {
|
||||
setError(null);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.message || 'Failed to generate schedules');
|
||||
const msg = err.response?.data?.message || 'Failed to generate schedules';
|
||||
setError(Array.isArray(msg) ? msg.join(' ') : msg);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -181,7 +190,8 @@ export default function SchedulesPage() {
|
||||
setError(null);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.message || 'Failed to create schedule');
|
||||
const msg = err.response?.data?.message || 'Failed to create schedule';
|
||||
setError(Array.isArray(msg) ? msg.join(' ') : msg);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -195,7 +205,15 @@ export default function SchedulesPage() {
|
||||
setError(null);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.message || 'Failed to update schedule');
|
||||
const msg = err.response?.data?.message || 'Failed to update schedule';
|
||||
setError(Array.isArray(msg) ? msg.join(' ') : msg);
|
||||
},
|
||||
});
|
||||
|
||||
const recalculateStopsMutation = useMutation({
|
||||
mutationFn: (id: string) => apiClient.post(`/schedules/${id}/recalculate-stops`, {}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -229,20 +247,6 @@ export default function SchedulesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
/** Parse a datetime-local string ("YYYY-MM-DDTHH:mm") as EAT (UTC+3) and return an ISO string. */
|
||||
const eatLocalToISO = (local: string): string => {
|
||||
if (!local) return '';
|
||||
return new Date(local + ':00+03:00').toISOString();
|
||||
};
|
||||
|
||||
/** Convert a UTC ISO string to a datetime-local value in EAT (UTC+3). */
|
||||
const isoToEATLocal = (iso: string): string => {
|
||||
if (!iso) return '';
|
||||
const utcMs = new Date(iso).getTime();
|
||||
const eatMs = utcMs + 3 * 60 * 60 * 1000;
|
||||
return new Date(eatMs).toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
const handleBulkSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
@@ -685,7 +689,7 @@ export default function SchedulesPage() {
|
||||
size="xl"
|
||||
>
|
||||
<form onSubmit={handleAddSubmit} className="space-y-4">
|
||||
{error && <div className="bg-red-50 p-3 rounded-lg text-sm text-red-800">{error}</div>}
|
||||
{error && <div ref={errorBannerRef} className="bg-red-50 p-3 rounded-lg text-sm text-red-800">{error}</div>}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
@@ -803,7 +807,7 @@ export default function SchedulesPage() {
|
||||
>
|
||||
<form onSubmit={handleBulkSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 p-3 rounded-lg text-sm text-red-800">
|
||||
<div ref={errorBannerRef} className="bg-red-50 p-3 rounded-lg text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -1022,7 +1026,7 @@ export default function SchedulesPage() {
|
||||
{editingSchedule && (
|
||||
<form onSubmit={handleEditSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 p-3 rounded-lg text-sm text-red-800">
|
||||
<div ref={errorBannerRef} className="bg-red-50 p-3 rounded-lg text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -1133,6 +1137,15 @@ export default function SchedulesPage() {
|
||||
>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
icon={RefreshCw}
|
||||
loading={recalculateStopsMutation.isPending}
|
||||
onClick={() => editingSchedule && recalculateStopsMutation.mutate(editingSchedule.id)}
|
||||
>
|
||||
Recalculate Stop Times
|
||||
</ActionButton>
|
||||
<ActionButton type="submit" loading={updateScheduleMutation.isPending}>
|
||||
Update Schedule
|
||||
</ActionButton>
|
||||
|
||||
Reference in New Issue
Block a user