Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Stephanos A
2026-07-24 16:49:50 +03:00
442 changed files with 33423 additions and 7485 deletions

View File

@@ -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

View File

@@ -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>