From be933c2c0d2115ef899bd23ad4f570dc5f1d5435 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Tue, 21 Jul 2026 00:24:32 +0300 Subject: [PATCH] Revert departure and arrival datetime of stops --- .../src/modules/schedules/routes.dto.ts | 4 - .../src/modules/schedules/routes.service.ts | 6 - .../src/modules/schedules/schedules.dto.ts | 10 +- .../modules/schedules/schedules.service.ts | 147 +- .../backoffice/src/app/routes/page.tsx | 82 +- .../backoffice/src/app/schedules/page.tsx | 1440 ++++------------- .../src/app/booking/confirmation/page.tsx | 6 +- .../portal/src/lib/generate-voucher.ts | 45 +- 8 files changed, 338 insertions(+), 1402 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts index fefa26ad1..f2db36e7c 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts @@ -7,8 +7,6 @@ export class RouteStopInputDto { @ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number; @ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number; @ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; - @ApiPropertyOptional({ example: '2026-06-15T06:30:00Z', description: 'Template planned arrival time at this stop. Only the time-of-day (EAT) is used when auto-populating new schedules. Omit for first stop.' }) @IsOptional() @IsDateString() plannedArrivalTime?: string; - @ApiPropertyOptional({ example: '2026-06-15T06:45:00Z', description: 'Template planned departure time from this stop. Only the time-of-day (EAT) is used when auto-populating new schedules. Omit for last stop.' }) @IsOptional() @IsDateString() plannedDepartureTime?: string; } export class CreateRouteDto { @@ -39,8 +37,6 @@ export class AddRouteStopDto { @ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number; @ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number; @ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; - @ApiPropertyOptional({ example: '2026-06-15T06:30:00Z', description: 'Template planned arrival time at this stop (only time-of-day is used)' }) @IsOptional() @IsDateString() plannedArrivalTime?: string; - @ApiPropertyOptional({ example: '2026-06-15T06:45:00Z', description: 'Template planned departure time from this stop (only time-of-day is used)' }) @IsOptional() @IsDateString() plannedDepartureTime?: string; } export class UpdateRouteDto { diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts index dcbdbc3b0..58e647180 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -37,8 +37,6 @@ export class RoutesService { sequence: s.sequence, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, checkinMinutesBefore: s.checkinMinutesBefore ?? null, - plannedArrivalTime: s.plannedArrivalTime ? new Date(s.plannedArrivalTime) : null, - plannedDepartureTime: s.plannedDepartureTime ? new Date(s.plannedDepartureTime) : null, })), }, }, @@ -108,8 +106,6 @@ export class RoutesService { sequence: s.sequence, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, checkinMinutesBefore: s.checkinMinutesBefore ?? null, - plannedArrivalTime: s.plannedArrivalTime ?? null, - plannedDepartureTime: s.plannedDepartureTime ?? null, })), }); } @@ -229,8 +225,6 @@ export class RoutesService { sequence: dto.sequence, distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null, checkinMinutesBefore: dto.checkinMinutesBefore ?? null, - plannedArrivalTime: dto.plannedArrivalTime ?? null, - plannedDepartureTime: dto.plannedDepartureTime ?? null, }, }); } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 12f918edd..675168cf9 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -54,20 +54,12 @@ export class CreateScheduleDto { plannedTimes?: PlannedStopTimeDto[]; } -export class CoachAssignmentDto { - @ApiProperty({ example: 'coach-uuid' }) @IsString() coachId: string; - @ApiProperty({ example: 1 }) @IsInt() @Min(1) positionNumber: number; -} - export class UpdateScheduleDto { @ApiPropertyOptional({ example: '2026-06-15T08:00:00Z', description: 'Scheduled departure from the first stop (origin)' }) @IsOptional() @IsDateString() departureAt?: string; @ApiPropertyOptional({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsOptional() @IsDateString() arrivalAt?: string; @ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus; - @ApiPropertyOptional({ type: [CoachAssignmentDto], description: 'List of coaches to assign' }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => CoachAssignmentDto) coaches?: CoachAssignmentDto[]; + @ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>; @ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean; - @ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Planned times per stop — when provided, replaces all existing stop times for the schedule' }) - @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto) - plannedTimes?: PlannedStopTimeDto[]; } export class UpdateStopTimeDto { diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index ecdf51666..7cca548aa 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -133,62 +133,26 @@ export class SchedulesService { let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { - const hasRouteTimes = route.stops.some( - s => (s as any).plannedArrivalTime != null || (s as any).plannedDepartureTime != null, - ); + const totalDuration = arr.getTime() - dep.getTime(); + const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; - if (hasRouteTimes) { - // Extract EAT time-of-day from a template DateTime and anchor to the schedule's EAT date. - const EAT_MS = 3 * 60 * 60 * 1000; - const depEATMs = dep.getTime() + EAT_MS; - const depMsIntoDay = depEATMs % (24 * 60 * 60 * 1000); - const eatMidnightUTC = dep.getTime() - depMsIntoDay; - - const templateToScheduleUTC = (templateDt: Date): Date => { - // Pull the time-of-day in EAT from the template DateTime - const templateEATMs = templateDt.getTime() + EAT_MS; - const timeOfDayMs = templateEATMs % (24 * 60 * 60 * 1000); - const candidate = new Date(eatMidnightUTC + timeOfDayMs); - // Overnight: if the stop time lands before departure, move to next day - if (candidate < dep) return new Date(candidate.getTime() + 24 * 60 * 60 * 1000); - return candidate; + plannedTimes = route.stops.map((stop, index) => { + let stopTime: Date; + if (index === 0) { + stopTime = dep; + } else if (index === route.stops.length - 1) { + stopTime = arr; + } else { + const stopDistance = stop.distanceKm || 0; + const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); + stopTime = new Date(dep.getTime() + totalDuration * progress); + } + return { + sequence: stop.sequence, + plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), + plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), }; - - plannedTimes = route.stops.map((stop, index) => { - const arrDt: Date | null = (stop as any).plannedArrivalTime ?? null; - const depDt: Date | null = (stop as any).plannedDepartureTime ?? null; - return { - sequence: stop.sequence, - plannedArrivalAt: index > 0 && arrDt != null - ? templateToScheduleUTC(arrDt).toISOString() - : undefined, - plannedDepartureAt: index < route.stops.length - 1 && depDt != null - ? templateToScheduleUTC(depDt).toISOString() - : undefined, - }; - }); - } else { - const totalDuration = arr.getTime() - dep.getTime(); - const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; - - plannedTimes = route.stops.map((stop, index) => { - let stopTime: Date; - if (index === 0) { - stopTime = dep; - } else if (index === route.stops.length - 1) { - stopTime = arr; - } else { - const stopDistance = stop.distanceKm || 0; - const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); - stopTime = new Date(dep.getTime() + totalDuration * progress); - } - return { - sequence: stop.sequence, - plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), - plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), - }; - }); - } + }); } const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence)); @@ -340,59 +304,26 @@ export class SchedulesService { let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { - const hasRouteTimes = route.stops.some( - s => (s as any).plannedArrivalTime != null || (s as any).plannedDepartureTime != null, - ); + const totalDuration = arr.getTime() - dep.getTime(); + const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; - if (hasRouteTimes) { - const EAT_MS = 3 * 60 * 60 * 1000; - const depEATMs = dep.getTime() + EAT_MS; - const depMsIntoDay = depEATMs % (24 * 60 * 60 * 1000); - const eatMidnightUTC = dep.getTime() - depMsIntoDay; - - const templateToScheduleUTC = (templateDt: Date): Date => { - const templateEATMs = templateDt.getTime() + EAT_MS; - const timeOfDayMs = templateEATMs % (24 * 60 * 60 * 1000); - const candidate = new Date(eatMidnightUTC + timeOfDayMs); - if (candidate < dep) return new Date(candidate.getTime() + 24 * 60 * 60 * 1000); - return candidate; + plannedTimes = route.stops.map((stop, index) => { + let stopTime: Date; + if (index === 0) { + stopTime = dep; + } else if (index === route.stops.length - 1) { + stopTime = arr; + } else { + const stopDistance = stop.distanceKm || 0; + const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); + stopTime = new Date(dep.getTime() + totalDuration * progress); + } + return { + sequence: stop.sequence, + plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), + plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), }; - - plannedTimes = route.stops.map((stop, index) => { - const arrDt: Date | null = (stop as any).plannedArrivalTime ?? null; - const depDt: Date | null = (stop as any).plannedDepartureTime ?? null; - return { - sequence: stop.sequence, - plannedArrivalAt: index > 0 && arrDt != null - ? templateToScheduleUTC(arrDt).toISOString() - : undefined, - plannedDepartureAt: index < route.stops.length - 1 && depDt != null - ? templateToScheduleUTC(depDt).toISOString() - : undefined, - }; - }); - } else { - const totalDuration = arr.getTime() - dep.getTime(); - const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; - - plannedTimes = route.stops.map((stop, index) => { - let stopTime: Date; - if (index === 0) { - stopTime = dep; - } else if (index === route.stops.length - 1) { - stopTime = arr; - } else { - const stopDistance = stop.distanceKm || 0; - const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); - stopTime = new Date(dep.getTime() + totalDuration * progress); - } - return { - sequence: stop.sequence, - plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), - plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), - }; - }); - } + }); } const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); @@ -747,12 +678,6 @@ export class SchedulesService { } } - if (dto.plannedTimes && dto.plannedTimes.length > 0 && schedule.routeId) { - await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } }); - const plannedTimesMap = Object.fromEntries(dto.plannedTimes.map(t => [t.sequence, t])); - await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap); - } - return this.getSchedule(id); } diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index 52b875433..feca7e3b5 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -10,14 +10,6 @@ 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; @@ -25,8 +17,6 @@ interface RouteStop { distanceKm?: number; distanceFromOrigin?: number; checkinMinutesBefore?: number; - plannedArrivalTime?: string; - plannedDepartureTime?: string; } type Tab = 'routes' | 'coaches'; @@ -185,8 +175,6 @@ export default function RoutesPage() { const [destinationDistance, setDestinationDistance] = useState(undefined); const [originCheckinMinutes, setOriginCheckinMinutes] = useState(undefined); const [destinationCheckinMinutes, setDestinationCheckinMinutes] = useState(undefined); - const [originDepartureTime, setOriginDepartureTime] = useState(''); - const [destinationArrivalTime, setDestinationArrivalTime] = useState(''); 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(); @@ -257,27 +245,18 @@ export default function RoutesPage() { // 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, - }, + { stationId: originStationId, sequence: 1, distanceKm: 0, checkinMinutesBefore: originCheckinMinutes ?? 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, }, ]; @@ -408,10 +387,8 @@ export default function RoutesPage() { 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, @@ -419,8 +396,6 @@ export default function RoutesPage() { 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); @@ -458,10 +433,8 @@ export default function RoutesPage() { setEditingRoute(null); setOriginStationId(''); setOriginCheckinMinutes(undefined); - setOriginDepartureTime(''); setDestinationStationId(''); setDestinationCheckinMinutes(undefined); - setDestinationArrivalTime(''); setDestinationDistance(undefined); setStops([]); setShowModal(true); @@ -546,7 +519,7 @@ export default function RoutesPage() { setSearch(''); }} title={`${editingRoute ? 'Edit' : 'Add'} Route`} - size="xl" + size="lg" >
{editingRoute && ( @@ -692,7 +665,7 @@ export default function RoutesPage() {
- Drag to rearrange · Cutoff overrides check-in · Arr/Dep time sets default times (auto-filled on schedule creation) + Drag to rearrange · Cutoff min overrides route check-in window per stop (leave blank to inherit)
@@ -710,7 +683,7 @@ export default function RoutesPage() { Select origin station above )}
-
+
-
- -
-
0 km
+
0 km
{stops.map((stop, index) => ( @@ -764,7 +729,7 @@ export default function RoutesPage() { ))}
-
+
-
+
updateStop(index, 'checkinMinutesBefore', e.target.value ? parseInt(e.target.value) : undefined)} min={1} title="Check-in cutoff override (minutes) for this stop" />
-
- updateStop(index, 'plannedArrivalTime', v)} - placeholder="Arr time" - label="Planned Arrival" - /> -
-
- updateStop(index, 'plannedDepartureTime', v)} - placeholder="Dep time" - label="Planned Departure" - /> -
-
+
{destinationStationId && ( )}
-
- {destinationStationId && ( - - )} -
-
-
+
{destinationStationId && ( void; - required?: boolean; - placeholder?: string; -} - -/** value / onChange use "YYYY-MM-DDTHH:mm" (24-hr, local) — same as datetime-local */ -function DateTimePicker({ label, value, onChange, required }: DTPProps) { - const datePart = value.slice(0, 10); - const timePart = value.slice(11, 16); // HH:mm 24-hr - - const hour24 = timePart ? parseInt(timePart.slice(0, 2), 10) : 12; - const minute = timePart ? timePart.slice(3, 5) : "00"; - const period = hour24 >= 12 ? "PM" : "AM"; - const hour12 = hour24 % 12 === 0 ? 12 : hour24 % 12; - - const emit = (d: string, h12: number, m: string, p: string) => { - if (!d) return; - const h24 = - p === "AM" ? (h12 === 12 ? 0 : h12) : h12 === 12 ? 12 : h12 + 12; - onChange(`${d}T${String(h24).padStart(2, "0")}:${m}`); - }; - - return ( -
- -
- emit(e.target.value, hour12, minute, period)} - /> - - - -
-
- ); -} -// ──────────────────────────────────────────────────────────────────────────── -import DataTable from "@/components/ui/DataTable"; -import ActionButton from "@/components/ui/ActionButton"; -import Modal from "@/components/ui/Modal"; -import ConfirmDialog from "@/components/ui/ConfirmDialog"; -import { apiClient } from "@/lib/api-client"; -import { routeCoachTemplatesApi } from "@/lib/api"; -import { formatDateTime } from "@/lib/utils"; - -// EAT is UTC+3. Convert without depending on the browser's own timezone. -const EAT_MS = 3 * 60 * 60 * 1000; -// UTC ISO string → EAT "YYYY-MM-DDTHH:mm" for DateTimePicker display -const isoToEAT = (iso: string): string => - new Date(new Date(iso).getTime() + EAT_MS).toISOString().slice(0, 16); -// EAT "YYYY-MM-DDTHH:mm" → UTC ISO string for API submission -const eatToISO = (local: string): string => - new Date(new Date(local + ":00Z").getTime() - EAT_MS).toISOString(); -// Extract HH:mm in EAT from a UTC ISO datetime (e.g. route stop planned time) -const isoToEATTimePart = (iso: string): string | null => { - if (!iso) return null; - const eatMs = new Date(iso).getTime() + EAT_MS; - const msIntoDay = eatMs % (24 * 60 * 60 * 1000); - const h = Math.floor(msIntoDay / 3600000); - const m = Math.floor((msIntoDay % 3600000) / 60000); - return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; -}; +import { useState, useEffect } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { apiClient } from '@/lib/api-client'; +import { routeCoachTemplatesApi } from '@/lib/api'; +import { formatDateTime } from '@/lib/utils'; interface Schedule { id: string; @@ -140,19 +22,8 @@ interface Schedule { train?: { id: string; name: string; number: string }; originStation?: { id: string; name: string }; destinationStation?: { id: string; name: string }; - coachAssignments?: Array<{ - coachId: string; - positionNumber: number; - coach?: { id: string; number: string }; - }>; + coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>; isPackageOnly?: boolean; - stopTimes?: Array<{ - sequence: number; - stationId: string; - plannedDepartureAt: string | null; - plannedArrivalAt: string | null; - station?: { name: string }; - }>; } interface Train { @@ -181,263 +52,133 @@ export default function SchedulesPage() { const [showAddModal, setShowAddModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [editingSchedule, setEditingSchedule] = useState(null); - const [selectedSchedules, setSelectedSchedules] = useState>( - new Set(), + const [selectedSchedules, setSelectedSchedules] = useState>(new Set()); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean; error?: string; cascade?: boolean; cascadeChecked?: boolean }>( + { isOpen: false, item: null } ); - const [deleteConfirm, setDeleteConfirm] = useState<{ - isOpen: boolean; - item: any | null; - isBulk?: boolean; - error?: string; - cascade?: boolean; - cascadeChecked?: boolean; - }>({ isOpen: false, item: null }); const [error, setError] = useState(null); const queryClient = useQueryClient(); const [bulkForm, setBulkForm] = useState({ - trainId: "", - routeId: "", - startDateTime: "", - durationHours: "10", - repeatEveryDays: "2", - forNextDays: "15", + trainId: '', + routeId: '', + startDateTime: '', + durationHours: '10', + repeatEveryDays: '2', + forNextDays: '15', }); - const [bulkCoachRows, setBulkCoachRows] = useState< - { coachId: string; positionNumber: number }[] - >([]); + const [bulkCoachRows, setBulkCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]); - const [addForm, setAddForm] = useState({ - trainId: "", - routeId: "", - departureAt: "", - arrivalAt: "", - }); - const [addCoachRows, setAddCoachRows] = useState< - { coachId: string; positionNumber: number }[] - >([]); - const [addStopTimes, setAddStopTimes] = useState< - { - sequence: number; - stationName: string; - plannedArrivalAt: string; - plannedDepartureAt: string; - }[] - >([]); - const [editStopTimes, setEditStopTimes] = useState< - { - sequence: number; - stationName: string; - plannedArrivalAt: string; - plannedDepartureAt: string; - }[] - >([]); + const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); + const [addCoachRows, setAddCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]); - const { data: singleRouteTemplate, isLoading: singleTemplateLoading } = - useQuery({ - queryKey: ["route-coaches", addForm.routeId], - queryFn: () => routeCoachTemplatesApi.get(addForm.routeId), - enabled: !!addForm.routeId, - }); - - const { data: addRouteDetail } = useQuery({ - queryKey: ["route-detail", addForm.routeId], - queryFn: () => apiClient.get(`/routes/${addForm.routeId}`), + const { data: singleRouteTemplate, isLoading: singleTemplateLoading } = useQuery({ + queryKey: ['route-coaches', addForm.routeId], + queryFn: () => routeCoachTemplatesApi.get(addForm.routeId), enabled: !!addForm.routeId, }); - const { data: editRouteDetail } = useQuery({ - queryKey: ["route-detail", editingSchedule?.routeId], - queryFn: () => apiClient.get(`/routes/${editingSchedule!.routeId}`), - enabled: !!editingSchedule?.routeId, - }); - useEffect(() => { - if (!addForm.routeId) { - setAddCoachRows([]); - return; - } - const rows: any[] = Array.isArray(singleRouteTemplate) - ? singleRouteTemplate - : ((singleRouteTemplate as any)?.coaches ?? []); - setAddCoachRows( - rows.length - ? rows.map((r: any) => ({ - coachId: r.coachId ?? r.coach?.id, - positionNumber: r.positionNumber, - })) - : [], - ); + if (!addForm.routeId) { setAddCoachRows([]); return; } + const rows: any[] = Array.isArray(singleRouteTemplate) ? singleRouteTemplate : (singleRouteTemplate as any)?.coaches ?? []; + setAddCoachRows(rows.length ? rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber })) : []); }, [singleRouteTemplate, addForm.routeId]); - useEffect(() => { - const stops: any[] = (addRouteDetail as any)?.stops ?? []; - if (!stops.length) { - setAddStopTimes([]); - return; - } - - const eatDateStr = addForm.departureAt - ? addForm.departureAt.slice(0, 10) - : null; - - setAddStopTimes( - stops.map((s: any) => { - const arrTimePart = s.plannedArrivalTime - ? isoToEATTimePart(s.plannedArrivalTime) - : null; - const depTimePart = s.plannedDepartureTime - ? isoToEATTimePart(s.plannedDepartureTime) - : null; - return { - sequence: s.sequence, - stationName: s.station?.name ?? `Stop ${s.sequence}`, - plannedArrivalAt: - eatDateStr && arrTimePart ? `${eatDateStr}T${arrTimePart}` : "", - plannedDepartureAt: - eatDateStr && depTimePart ? `${eatDateStr}T${depTimePart}` : "", - }; - }), - ); - }, [addRouteDetail, addForm.departureAt]); - // Fetch route coach template when route changes const { data: routeTemplate, isLoading: templateLoading } = useQuery({ - queryKey: ["route-coaches", bulkForm.routeId], + queryKey: ['route-coaches', bulkForm.routeId], queryFn: () => routeCoachTemplatesApi.get(bulkForm.routeId), enabled: !!bulkForm.routeId, }); useEffect(() => { - if (!bulkForm.routeId) { - setBulkCoachRows([]); - return; - } - const rows: any[] = Array.isArray(routeTemplate) - ? routeTemplate - : ((routeTemplate as any)?.coaches ?? []); + if (!bulkForm.routeId) { setBulkCoachRows([]); return; } + const rows: any[] = Array.isArray(routeTemplate) ? routeTemplate : (routeTemplate as any)?.coaches ?? []; setBulkCoachRows( rows.length - ? rows.map((r: any) => ({ - coachId: r.coachId ?? r.coach?.id, - positionNumber: r.positionNumber, - })) - : [], + ? rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber })) + : [] ); }, [routeTemplate, bulkForm.routeId]); const [editForm, setEditForm] = useState({ - departureAt: "", - arrivalAt: "", - status: "SCHEDULED", + departureAt: '', + arrivalAt: '', + status: 'SCHEDULED', coachIds: [] as string[], isPackageOnly: false, }); - useEffect(() => { - const stops: any[] = (editRouteDetail as any)?.stops ?? []; - if (!stops.length || !editingSchedule) return; - const hasRouteTimes = stops.some( - (s: any) => s.plannedArrivalTime || s.plannedDepartureTime, - ); - if (!hasRouteTimes) return; - // If the schedule already has saved stop times, keep them — don't overwrite - // with route template times. The user can use "Auto-fill" if they want to reset. - if (editingSchedule.stopTimes && editingSchedule.stopTimes.length > 0) return; - const eatDateStr = editForm.departureAt - ? editForm.departureAt.slice(0, 10) - : null; - setEditStopTimes( - stops.map((s: any) => { - const arrTimePart = s.plannedArrivalTime - ? isoToEATTimePart(s.plannedArrivalTime) - : null; - const depTimePart = s.plannedDepartureTime - ? isoToEATTimePart(s.plannedDepartureTime) - : null; - return { - sequence: s.sequence, - stationName: s.station?.name ?? `Stop ${s.sequence}`, - plannedArrivalAt: - eatDateStr && arrTimePart ? `${eatDateStr}T${arrTimePart}` : "", - plannedDepartureAt: - eatDateStr && depTimePart ? `${eatDateStr}T${depTimePart}` : "", - }; - }), - ); - }, [editRouteDetail, editingSchedule?.id, editForm.departureAt]); // eslint-disable-line react-hooks/exhaustive-deps - const [filters, setFilters] = useState({ - search: "", - trainId: "", - routeId: "", - date: "", + search: '', + trainId: '', + routeId: '', + date: '', }); const { data: schedulesData, isLoading: schedulesLoading } = useQuery({ - queryKey: ["schedules", filters], + queryKey: ['schedules', filters], queryFn: () => { const params = new URLSearchParams(); - if (filters.trainId) params.append("trainId", filters.trainId); - if (filters.routeId) params.append("routeId", filters.routeId); - if (filters.date) params.append("date", filters.date); + if (filters.trainId) params.append('trainId', filters.trainId); + if (filters.routeId) params.append('routeId', filters.routeId); + if (filters.date) params.append('date', filters.date); return apiClient.get(`/schedules?${params.toString()}`); }, retry: 1, }); const { data: trainsData } = useQuery({ - queryKey: ["trains"], - queryFn: () => apiClient.get("/fleet/trains"), + queryKey: ['trains'], + queryFn: () => apiClient.get('/fleet/trains'), retry: 1, }); const { data: routesData } = useQuery({ - queryKey: ["routes"], - queryFn: () => apiClient.get("/routes"), + queryKey: ['routes'], + queryFn: () => apiClient.get('/routes'), retry: 1, }); const { data: coachesData } = useQuery({ - queryKey: ["coaches"], - queryFn: () => apiClient.get("/fleet/coaches"), + queryKey: ['coaches'], + queryFn: () => apiClient.get('/fleet/coaches'), retry: 1, }); const bulkGenerateMutation = useMutation({ - mutationFn: (data: any) => apiClient.post("/schedules/bulk-generate", data), + mutationFn: (data: any) => apiClient.post('/schedules/bulk-generate', data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["schedules"] }); + queryClient.invalidateQueries({ queryKey: ['schedules'] }); setShowModal(false); setBulkForm({ - trainId: "", - routeId: "", - startDateTime: "", - durationHours: "12", - repeatEveryDays: "1", - forNextDays: "30", + trainId: '', + routeId: '', + startDateTime: '', + durationHours: '12', + repeatEveryDays: '1', + forNextDays: '30', }); setBulkCoachRows([]); setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || "Failed to generate schedules"); + setError(err.response?.data?.message || 'Failed to generate schedules'); }, }); const createScheduleMutation = useMutation({ - mutationFn: (data: any) => apiClient.post("/schedules", data), + mutationFn: (data: any) => apiClient.post('/schedules', data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["schedules"] }); + queryClient.invalidateQueries({ queryKey: ['schedules'] }); setShowAddModal(false); - setAddForm({ trainId: "", routeId: "", departureAt: "", arrivalAt: "" }); + setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); - setAddStopTimes([]); setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || "Failed to create schedule"); + setError(err.response?.data?.message || 'Failed to create schedule'); }, }); @@ -445,63 +186,43 @@ export default function SchedulesPage() { mutationFn: (data: { id: string; payload: any }) => apiClient.patch(`/schedules/${data.id}`, data.payload), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["schedules"] }); + queryClient.invalidateQueries({ queryKey: ['schedules'] }); setShowEditModal(false); setEditingSchedule(null); - setEditStopTimes([]); setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || "Failed to update schedule"); + setError(err.response?.data?.message || 'Failed to update schedule'); }, }); const deleteScheduleMutation = useMutation({ - mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => - apiClient.delete(`/schedules/${id}${cascade ? "?cascade=true" : ""}`), + mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => apiClient.delete(`/schedules/${id}${cascade ? '?cascade=true' : ''}`), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["schedules"] }); + queryClient.invalidateQueries({ queryKey: ['schedules'] }); }, onError: (err: any) => { - const msg = - err?.response?.data?.message || - err?.message || - "Failed to delete schedule"; - const isFkError = - msg?.includes("Cannot delete") || err?.response?.status === 400; + const msg = err?.response?.data?.message || err?.message || 'Failed to delete schedule'; + const isFkError = msg?.includes('Cannot delete') || err?.response?.status === 400; if (isFkError && !deleteConfirm.cascade) { - setDeleteConfirm((prev) => ({ - ...prev, - cascade: true, - cascadeChecked: false, - error: Array.isArray(msg) ? msg.join(" ") : msg, - })); + 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, - })); + setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); } }, }); const bulkDeleteMutation = useMutation({ mutationFn: async (ids: string[]) => { - await Promise.all(ids.map((id) => apiClient.delete(`/schedules/${id}`))); + await Promise.all(ids.map(id => apiClient.delete(`/schedules/${id}`))); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["schedules"] }); + queryClient.invalidateQueries({ queryKey: ['schedules'] }); setSelectedSchedules(new Set()); }, onError: (err: any) => { - const msg = - err?.response?.data?.message || - err?.message || - "Failed to delete schedules"; - setDeleteConfirm((prev) => ({ - ...prev, - error: Array.isArray(msg) ? msg.join(" ") : msg, - })); + const msg = err?.response?.data?.message || err?.message || 'Failed to delete schedules'; + setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); }, }); @@ -510,14 +231,14 @@ export default function SchedulesPage() { setError(null); if (!bulkForm.trainId || !bulkForm.routeId || !bulkForm.startDateTime) { - setError("Train, route, and start date/time are required"); + setError('Train, route, and start date/time are required'); return; } const payload: any = { trainId: bulkForm.trainId, routeId: bulkForm.routeId, - startDateTime: new Date(bulkForm.startDateTime).toISOString(), + startDateTime: bulkForm.startDateTime, durationHours: parseInt(bulkForm.durationHours), repeatEveryDays: parseInt(bulkForm.repeatEveryDays), forNextDays: parseInt(bulkForm.forNextDays), @@ -534,44 +255,16 @@ export default function SchedulesPage() { const handleAddSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); - if (!addForm.departureAt || !addForm.arrivalAt) { - setError("Please select departure and arrival date & time"); - return; - } - if ( - new Date(addForm.arrivalAt + ":00Z") <= - new Date(addForm.departureAt + ":00Z") - ) { - setError("Arrival must be after departure"); - return; - } - - const filledStops = addStopTimes.filter( - (s) => s.plannedDepartureAt || s.plannedArrivalAt, - ); - const plannedTimes = - filledStops.length === addStopTimes.length && addStopTimes.length > 0 - ? addStopTimes.map((s) => ({ - sequence: s.sequence, - ...(s.plannedArrivalAt - ? { plannedArrivalAt: eatToISO(s.plannedArrivalAt) } - : {}), - ...(s.plannedDepartureAt - ? { plannedDepartureAt: eatToISO(s.plannedDepartureAt) } - : {}), - })) - : undefined; - + const dep = new Date(addForm.departureAt); + const arr = new Date(addForm.arrivalAt); + if (arr <= dep) { setError('Arrival must be after departure'); return; } const validCoaches = addCoachRows.filter((r) => r.coachId); await createScheduleMutation.mutateAsync({ trainId: addForm.trainId, routeId: addForm.routeId, - departureAt: eatToISO(addForm.departureAt), - arrivalAt: eatToISO(addForm.arrivalAt), - ...(plannedTimes ? { plannedTimes } : {}), - ...(validCoaches.length > 0 && { - coachIds: validCoaches.map((r) => r.coachId), - }), + departureAt: dep.toISOString(), + arrivalAt: arr.toISOString(), + ...(validCoaches.length > 0 && { coachIds: validCoaches.map((r) => r.coachId) }), }); }; @@ -581,46 +274,24 @@ export default function SchedulesPage() { if (!editingSchedule) return; - if (!editForm.departureAt || !editForm.arrivalAt) { - setError("Please select departure and arrival date & time"); + // Convert local datetime-local values to UTC for API + const depLocal = new Date(editForm.departureAt); + const arrLocal = new Date(editForm.arrivalAt); + + if (arrLocal <= depLocal) { + setError('Arrival time must be after departure time'); return; } - if ( - new Date(editForm.arrivalAt + ":00Z") <= - new Date(editForm.departureAt + ":00Z") - ) { - setError("Arrival time must be after departure time"); - return; - } - - const filledEditStops = editStopTimes.filter( - (s) => s.plannedDepartureAt || s.plannedArrivalAt, - ); - const editPlannedTimes = - filledEditStops.length === editStopTimes.length && - editStopTimes.length > 0 - ? editStopTimes.map((s) => ({ - sequence: s.sequence, - ...(s.plannedArrivalAt - ? { plannedArrivalAt: eatToISO(s.plannedArrivalAt) } - : {}), - ...(s.plannedDepartureAt - ? { plannedDepartureAt: eatToISO(s.plannedDepartureAt) } - : {}), - })) - : undefined; - const payload: any = { - departureAt: eatToISO(editForm.departureAt), - arrivalAt: eatToISO(editForm.arrivalAt), + departureAt: depLocal.toISOString(), + arrivalAt: arrLocal.toISOString(), status: editForm.status, isPackageOnly: editForm.isPackageOnly, coaches: editForm.coachIds.map((coachId: string, idx: number) => ({ coachId, positionNumber: idx + 1, })), - ...(editPlannedTimes ? { plannedTimes: editPlannedTimes } : {}), }; await updateScheduleMutation.mutateAsync({ @@ -635,24 +306,17 @@ export default function SchedulesPage() { const handleBulkDelete = () => { if (selectedSchedules.size === 0) return; - setDeleteConfirm({ - isOpen: true, - item: Array.from(selectedSchedules), - isBulk: true, - }); + setDeleteConfirm({ isOpen: true, item: Array.from(selectedSchedules), isBulk: true }); }; const confirmDelete = async () => { - setDeleteConfirm((prev) => ({ ...prev, error: undefined })); + setDeleteConfirm(prev => ({ ...prev, error: undefined })); try { if (deleteConfirm.isBulk) { const ids = deleteConfirm.item as string[]; await bulkDeleteMutation.mutateAsync(ids); } else if (deleteConfirm.item) { - await deleteScheduleMutation.mutateAsync({ - id: deleteConfirm.item.id, - cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked, - }); + await deleteScheduleMutation.mutateAsync({ id: deleteConfirm.item.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked }); } setDeleteConfirm({ isOpen: false, item: null }); } catch { @@ -663,45 +327,34 @@ export default function SchedulesPage() { const handleEditClick = (schedule: Schedule) => { setEditingSchedule(schedule); + // Convert UTC dates to local time for datetime-local input + // datetime-local expects local time (no timezone info) + const dep = new Date(schedule.departureAt); + const arr = new Date(schedule.arrivalAt); + + // Convert to local time by adding the timezone offset + const depLocal = new Date(dep.getTime() + dep.getTimezoneOffset() * 60000); + const arrLocal = new Date(arr.getTime() + arr.getTimezoneOffset() * 60000); + + // Format for datetime-local input (YYYY-MM-DDTHH:mm) + const depStr = depLocal.toISOString().slice(0, 16); + const arrStr = arrLocal.toISOString().slice(0, 16); + setEditForm({ - departureAt: isoToEAT(schedule.departureAt), - arrivalAt: isoToEAT(schedule.arrivalAt), + departureAt: depStr, + arrivalAt: arrStr, status: schedule.status, coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [], isPackageOnly: schedule.isPackageOnly ?? false, }); - - if (schedule.stopTimes && schedule.stopTimes.length > 0) { - const toDatetimeLocal = (iso: string | null) => - iso ? isoToEAT(iso) : ""; - setEditStopTimes( - schedule.stopTimes.map((st) => ({ - sequence: st.sequence, - stationName: st.station?.name ?? `Stop ${st.sequence}`, - plannedArrivalAt: toDatetimeLocal(st.plannedArrivalAt), - plannedDepartureAt: toDatetimeLocal(st.plannedDepartureAt), - })), - ); - } else { - setEditStopTimes([]); - } - setError(null); setShowEditModal(true); }; - const schedules = Array.isArray(schedulesData) - ? schedulesData - : (schedulesData as any)?.items || []; - const trains = Array.isArray(trainsData) - ? trainsData - : (trainsData as any)?.items || []; - const routes = Array.isArray(routesData) - ? routesData - : (routesData as any)?.items || []; - const coaches = Array.isArray(coachesData) - ? coachesData - : (coachesData as any)?.items || []; + const schedules = Array.isArray(schedulesData) ? schedulesData : (schedulesData as any)?.items || []; + const trains = Array.isArray(trainsData) ? trainsData : (trainsData as any)?.items || []; + const routes = Array.isArray(routesData) ? routesData : (routesData as any)?.items || []; + const coaches = Array.isArray(coachesData) ? coachesData : (coachesData as any)?.items || []; const filteredSchedules = schedules.filter((schedule: Schedule) => { if (!filters.search) return true; @@ -716,28 +369,23 @@ export default function SchedulesPage() { }); const statusMap: Record = { - SCHEDULED: "edr-badge-info", - BOARDING: "edr-badge-warning", - EN_ROUTE: "edr-badge-success", - ARRIVED: "edr-badge-secondary", - CANCELLED: "edr-badge-danger", + SCHEDULED: 'edr-badge-info', + BOARDING: 'edr-badge-warning', + EN_ROUTE: 'edr-badge-success', + ARRIVED: 'edr-badge-secondary', + CANCELLED: 'edr-badge-danger', }; const scheduleColumns = [ { - key: "checkbox", + key: 'checkbox', label: ( 0 - } + checked={selectedSchedules.size === filteredSchedules.length && filteredSchedules.length > 0} onChange={(e) => { if (e.target.checked) { - setSelectedSchedules( - new Set(filteredSchedules.map((s: Schedule) => s.id)), - ); + setSelectedSchedules(new Set(filteredSchedules.map((s: Schedule) => s.id))); } else { setSelectedSchedules(new Set()); } @@ -763,52 +411,50 @@ export default function SchedulesPage() { ), }, { - key: "train.name", - label: "Train", + key: 'train.name', + label: 'Train', sortable: true, render: (schedule: Schedule) => ( -
{schedule.train?.number}
+
+ {schedule.train?.number} +
), }, { - key: "route", - label: "Route", + key: 'route', + label: 'Route', sortable: true, render: (schedule: Schedule) => (
- {schedule.originStation?.name || "Unknown"} + {schedule.originStation?.name || 'Unknown'} - {schedule.destinationStation?.name || "Unknown"} + {schedule.destinationStation?.name || 'Unknown'}
), }, { - key: "departureAt", - label: "Departure", + key: 'departureAt', + label: 'Departure', sortable: true, render: (schedule: Schedule) => ( - - {formatDateTime(schedule.departureAt)} - + {formatDateTime(schedule.departureAt)} ), }, { - key: "arrivalAt", - label: "Arrival", + key: 'arrivalAt', + label: 'Arrival', sortable: true, render: (schedule: Schedule) => ( - - {formatDateTime(schedule.arrivalAt)} - + {formatDateTime(schedule.arrivalAt)} ), }, { - key: "coachAssignments", - label: "Coaches", + key: 'coachAssignments', + label: 'Coaches', render: (schedule: Schedule) => ( {schedule.coachAssignments?.length || 0} @@ -816,13 +462,11 @@ export default function SchedulesPage() { ), }, { - key: "status", - label: "Status", + key: 'status', + label: 'Status', render: (schedule: Schedule) => (
- + {schedule.status} {schedule.isPackageOnly && ( @@ -834,35 +478,30 @@ export default function SchedulesPage() { ] as any; const cancelScheduleMutation = useMutation({ - mutationFn: (id: string) => - apiClient.patch(`/schedules/${id}/status`, { status: "CANCELLED" }), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["schedules"] }), + mutationFn: (id: string) => apiClient.patch(`/schedules/${id}/status`, { status: 'CANCELLED' }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules'] }), }); - const [cancelConfirm, setCancelConfirm] = useState<{ - isOpen: boolean; - item: Schedule | null; - }>({ isOpen: false, item: null }); + const [cancelConfirm, setCancelConfirm] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null }); const scheduleActions = [ { - label: "Edit", + label: 'Edit', onClick: handleEditClick, - variant: "secondary" as const, + variant: 'secondary' as const, icon: Edit, }, { - label: "Cancel", - onClick: (schedule: Schedule) => - setCancelConfirm({ isOpen: true, item: schedule }), - variant: "danger" as const, + label: 'Cancel', + onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }), + variant: 'danger' as const, icon: X, - hidden: (schedule: Schedule) => schedule.status === "CANCELLED", + hidden: (schedule: Schedule) => schedule.status === 'CANCELLED', }, { - label: "Delete", + label: 'Delete', onClick: handleDelete, - variant: "danger" as const, + variant: 'danger' as const, icon: Trash2, }, ]; @@ -871,12 +510,8 @@ export default function SchedulesPage() {
-

- Schedule Management -

-

- Create and manage train schedules -

+

Schedule Management

+

Create and manage train schedules

{selectedSchedules.size > 0 && ( @@ -885,17 +520,13 @@ export default function SchedulesPage() { variant="danger" loading={bulkDeleteMutation.isPending} > - Delete {selectedSchedules.size} Schedule - {selectedSchedules.size !== 1 ? "s" : ""} + Delete {selectedSchedules.size} Schedule{selectedSchedules.size !== 1 ? 's' : ''} )} { - setError(null); - setShowAddModal(true); - }} + onClick={() => { setError(null); setShowAddModal(true); }} > Add Schedule @@ -920,9 +551,7 @@ export default function SchedulesPage() { type="text" placeholder="Search by train name, number, station, or status..." value={filters.search} - onChange={(e) => - setFilters({ ...filters, search: e.target.value }) - } + onChange={(e) => setFilters({ ...filters, search: e.target.value })} className="input pl-10 w-full" />
@@ -932,9 +561,7 @@ export default function SchedulesPage() { - setFilters({ ...filters, routeId: e.target.value }) - } + onChange={(e) => setFilters({ ...filters, routeId: e.target.value })} className="input" > @@ -969,9 +594,7 @@ export default function SchedulesPage() { - setFilters({ ...filters, date: e.target.value }) - } + onChange={(e) => setFilters({ ...filters, date: e.target.value })} className="input" />
@@ -979,14 +602,7 @@ export default function SchedulesPage() {
- setFilters({ - search: "", - trainId: "", - routeId: "", - date: "", - }) - } + onClick={() => setFilters({ search: '', trainId: '', routeId: '', date: '' })} > Clear Filters @@ -1002,8 +618,7 @@ export default function SchedulesPage() {
) : filteredSchedules.length === 0 ? (
- No schedules found.{" "} - {filters.search && "Try adjusting your search."} + No schedules found. {filters.search && 'Try adjusting your search.'}
) : ( setDeleteConfirm({ isOpen: false, item: null })} onConfirm={confirmDelete} - title={ - deleteConfirm.isBulk ? "Delete Multiple Schedules" : "Delete Schedule" - } + title={deleteConfirm.isBulk ? 'Delete Multiple Schedules' : 'Delete Schedule'} message={ deleteConfirm.isBulk ? `Are you sure you want to delete ${Array.isArray(deleteConfirm.item) ? deleteConfirm.item.length : 0} schedule(s)? This action cannot be undone.` : `Are you sure you want to delete this schedule departing on ${ - deleteConfirm.item - ? formatDateTime(deleteConfirm.item.departureAt) - : "" + deleteConfirm.item ? formatDateTime(deleteConfirm.item.departureAt) : '' }?` } confirmText="Delete" isDanger={true} - isLoading={ - deleteScheduleMutation.isPending || bulkDeleteMutation.isPending - } + isLoading={deleteScheduleMutation.isPending || bulkDeleteMutation.isPending} error={deleteConfirm.error} - warning={ - !deleteConfirm.cascade - ? "Schedules with existing bookings cannot be deleted." - : undefined - } - cascadeWarning={ - deleteConfirm.cascade - ? "This schedule has related bookings or tickets that will also be permanently deleted." - : undefined - } + warning={!deleteConfirm.cascade ? "Schedules with existing bookings cannot be deleted." : undefined} + cascadeWarning={deleteConfirm.cascade ? "This schedule has related bookings or tickets that will also be permanently deleted." : undefined} cascadeChecked={deleteConfirm.cascadeChecked} - onCascadeChange={(checked) => - setDeleteConfirm((prev) => ({ ...prev, cascadeChecked: checked })) - } + onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))} /> { - setShowAddModal(false); - setAddForm({ - trainId: "", - routeId: "", - departureAt: "", - arrivalAt: "", - }); - setAddCoachRows([]); - setAddStopTimes([]); - setError(null); - }} + onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }} title="Add Schedule" size="lg" > - {error && ( -
- {error} -
- )} + {error &&
{error}
}
- setAddForm({ ...addForm, trainId: e.target.value })} required> - {trains.map((t: Train) => ( - - ))} + {trains.map((t: Train) => )}
- setAddForm({ ...addForm, routeId: e.target.value })} required> - {routes.map((r: Route) => ( - - ))} + {routes.map((r: Route) => )}
@@ -1137,265 +699,62 @@ export default function SchedulesPage() {
- setAddForm({ ...addForm, departureAt: v })} - placeholder="Select departure" - /> + setAddForm({ ...addForm, departureAt: e.target.value })} required />
- setAddForm({ ...addForm, arrivalAt: v })} - placeholder="Select arrival" - /> + setAddForm({ ...addForm, arrivalAt: e.target.value })} required />
- {addStopTimes.length > 0 && ( -
-
-
- -

- Set planned times for each stop. Leave all blank to - auto-generate from distance. -

-
- -
-
- - - - - - - - - - - {addStopTimes.map((stop, i) => { - const isFirst = i === 0; - const isLast = i === addStopTimes.length - 1; - return ( - - - - - - - ); - })} - -
#StationPlanned ArrivalPlanned Departure
- {stop.sequence} - - {stop.stationName} - - {isFirst ? ( - - — - - ) : ( - { - const updated = [...addStopTimes]; - updated[i] = { - ...updated[i], - plannedArrivalAt: v, - }; - setAddStopTimes(updated); - }} - placeholder="Pick arrival" - /> - )} - - {isLast ? ( - - — - - ) : ( - { - const updated = [...addStopTimes]; - updated[i] = { - ...updated[i], - plannedDepartureAt: v, - }; - setAddStopTimes(updated); - }} - placeholder="Pick departure" - /> - )} -
-
-
- )} -
{singleTemplateLoading && addForm.routeId && ( - - Loading - template… - + Loading template… )} - {!addForm.routeId && ( - - Select a route to load its coach template - - )} - r.coachId).length >= - coaches.length - } - onClick={() => - setAddCoachRows([ - ...addCoachRows, - { coachId: "", positionNumber: addCoachRows.length + 1 }, - ]) - } - > + {!addForm.routeId && Select a route to load its coach template} + r.coachId).length >= coaches.length} + onClick={() => setAddCoachRows([...addCoachRows, { coachId: '', positionNumber: addCoachRows.length + 1 }])}> Add Coach
{addCoachRows.length === 0 ? ( -

- No coaches assigned. -

+

No coaches assigned.

) : (
- {addCoachRows.length > 1 && ( -

- Drag to reorder -

- )} + {addCoachRows.length > 1 &&

Drag to reorder

} {addCoachRows.map((row, i) => { - const selectedIds = new Set( - addCoachRows.map((r) => r.coachId).filter(Boolean), - ); + const selectedIds = new Set(addCoachRows.map((r) => r.coachId).filter(Boolean)); return ( -
- e.dataTransfer.setData("add-coach-idx", i.toString()) - } - onDragOver={(e) => { - e.preventDefault(); - (e.currentTarget as HTMLElement).style.opacity = "0.5"; - }} - onDragLeave={(e) => { - (e.currentTarget as HTMLElement).style.opacity = "1"; - }} +
e.dataTransfer.setData('add-coach-idx', i.toString())} + onDragOver={(e) => { e.preventDefault(); (e.currentTarget as HTMLElement).style.opacity = '0.5'; }} + onDragLeave={(e) => { (e.currentTarget as HTMLElement).style.opacity = '1'; }} onDrop={(e) => { - e.preventDefault(); - (e.currentTarget as HTMLElement).style.opacity = "1"; - const src = parseInt( - e.dataTransfer.getData("add-coach-idx"), - ); + e.preventDefault(); (e.currentTarget as HTMLElement).style.opacity = '1'; + const src = parseInt(e.dataTransfer.getData('add-coach-idx')); if (src === i) return; const reordered = [...addCoachRows]; const [moved] = reordered.splice(src, 1); reordered.splice(i, 0, moved); - setAddCoachRows( - reordered.map((r, idx) => ({ - ...r, - positionNumber: idx + 1, - })), - ); + setAddCoachRows(reordered.map((r, idx) => ({ ...r, positionNumber: idx + 1 }))); }} className="flex gap-2 items-center p-2 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors" > - - {row.positionNumber} - - { const u = [...addCoachRows]; u[i] = { ...u[i], coachId: e.target.value }; setAddCoachRows(u); }}> - {coaches - .filter( - (c: Coach) => - !selectedIds.has(c.id) || c.id === row.coachId, - ) - .map((c: Coach) => ( - - ))} + {coaches.filter((c: Coach) => !selectedIds.has(c.id) || c.id === row.coachId).map((c: Coach) => ( + + ))} -
@@ -1406,29 +765,8 @@ export default function SchedulesPage() {
- { - setShowAddModal(false); - setAddForm({ - trainId: "", - routeId: "", - departureAt: "", - arrivalAt: "", - }); - setAddCoachRows([]); - setError(null); - }} - > - Cancel - - - Create Schedule - + { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}>Cancel + Create Schedule
@@ -1439,12 +777,12 @@ export default function SchedulesPage() { setShowModal(false); setError(null); setBulkForm({ - trainId: "", - routeId: "", - startDateTime: "", - durationHours: "12", - repeatEveryDays: "1", - forNextDays: "30", + trainId: '', + routeId: '', + startDateTime: '', + durationHours: '12', + repeatEveryDays: '1', + forNextDays: '30', }); setBulkCoachRows([]); }} @@ -1463,9 +801,7 @@ export default function SchedulesPage() { - setBulkForm({ ...bulkForm, routeId: e.target.value }) - } + onChange={(e) => setBulkForm({ ...bulkForm, routeId: e.target.value })} className="input" required > @@ -1498,12 +832,16 @@ export default function SchedulesPage() {
- setBulkForm({ ...bulkForm, startDateTime: v })} - required - /> +
+ + setBulkForm({ ...bulkForm, startDateTime: e.target.value })} + className="input" + required + /> +
@@ -1512,9 +850,7 @@ export default function SchedulesPage() { type="number" min="1" placeholder={bulkForm.durationHours} - onChange={(e) => - setBulkForm({ ...bulkForm, durationHours: e.target.value }) - } + onChange={(e) => setBulkForm({ ...bulkForm, durationHours: e.target.value })} className="input" />
@@ -1525,9 +861,7 @@ export default function SchedulesPage() { type="number" min="1" placeholder={bulkForm.repeatEveryDays} - onChange={(e) => - setBulkForm({ ...bulkForm, repeatEveryDays: e.target.value }) - } + onChange={(e) => setBulkForm({ ...bulkForm, repeatEveryDays: e.target.value })} className="input" />
@@ -1538,9 +872,7 @@ export default function SchedulesPage() { type="number" min="1" placeholder={bulkForm.forNextDays} - onChange={(e) => - setBulkForm({ ...bulkForm, forNextDays: e.target.value }) - } + onChange={(e) => setBulkForm({ ...bulkForm, forNextDays: e.target.value })} className="input" />
@@ -1551,127 +883,70 @@ export default function SchedulesPage() {
{templateLoading && bulkForm.routeId && ( - - Loading - template… - + Loading template… )} {!bulkForm.routeId && ( - - Select a route to load its coach template - + Select a route to load its coach template )} - r.coachId).length >= - coaches.length - } - onClick={() => - setBulkCoachRows([ - ...bulkCoachRows, - { coachId: "", positionNumber: bulkCoachRows.length + 1 }, - ]) - } - > + r.coachId).length >= coaches.length} + onClick={() => setBulkCoachRows([...bulkCoachRows, { coachId: '', positionNumber: bulkCoachRows.length + 1 }])}> Add Coach
{bulkCoachRows.length === 0 ? ( -

- No coaches assigned — schedules will be created without coach - assignments. -

+

No coaches assigned — schedules will be created without coach assignments.

) : (
{bulkCoachRows.length > 1 && ( -

- Drag to reorder -

+

Drag to reorder

)} {bulkCoachRows.map((row, i) => { - const selectedIds = new Set( - bulkCoachRows.map((r) => r.coachId).filter(Boolean), - ); + const selectedIds = new Set(bulkCoachRows.map((r) => r.coachId).filter(Boolean)); return (
- e.dataTransfer.setData("bulk-coach-idx", i.toString()) - } - onDragOver={(e) => { - e.preventDefault(); - (e.currentTarget as HTMLElement).style.opacity = "0.5"; - }} - onDragLeave={(e) => { - (e.currentTarget as HTMLElement).style.opacity = "1"; - }} + onDragStart={(e) => e.dataTransfer.setData('bulk-coach-idx', i.toString())} + onDragOver={(e) => { e.preventDefault(); (e.currentTarget as HTMLElement).style.opacity = '0.5'; }} + onDragLeave={(e) => { (e.currentTarget as HTMLElement).style.opacity = '1'; }} onDrop={(e) => { e.preventDefault(); - (e.currentTarget as HTMLElement).style.opacity = "1"; - const src = parseInt( - e.dataTransfer.getData("bulk-coach-idx"), - ); + (e.currentTarget as HTMLElement).style.opacity = '1'; + const src = parseInt(e.dataTransfer.getData('bulk-coach-idx')); if (src === i) return; const reordered = [...bulkCoachRows]; const [moved] = reordered.splice(src, 1); reordered.splice(i, 0, moved); - setBulkCoachRows( - reordered.map((r, idx) => ({ - ...r, - positionNumber: idx + 1, - })), - ); + setBulkCoachRows(reordered.map((r, idx) => ({ ...r, positionNumber: idx + 1 }))); }} className="flex gap-2 items-center p-2 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors" > - - {row.positionNumber} - + {row.positionNumber}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 6ab86fa95..1039b1465 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -249,10 +249,6 @@ export default function ConfirmationPage() { } : undefined; - // For settled amounts, free children (getEtbFare returns 0) should show 0 — - // split the total only among passengers who actually paid. - const paidPassengerCount = passengers.filter((_, j) => getEtbFare(j) > 0).length || passengers.length; - // Separate file per passenger, saved back-to-back with no macrotask (setTimeout) // between them — a setTimeout delay would push later saves outside the click's // synchronous user-activation window and risk iOS Safari silently blocking them. @@ -282,7 +278,7 @@ export default function ConfirmationPage() { outboundSchedule: outbound, inboundSchedule: inbound, isRoundTrip, - fareMinor: hasSettledAmount ? (getEtbFare(i) === 0 ? 0 : Math.round(settledAmountMinor! / paidPassengerCount)) : getEtbFare(i), + fareMinor: hasSettledAmount ? settledAmountMinor! : getEtbFare(i), currency: voucherCurrency, fareIsMajorUnits: hasSettledAmount, createdAt, diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts index baf11069a..439c26c29 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -355,7 +355,7 @@ function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: num doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'F'); const padX = 7; - label(doc, 'Fare paid', margin + padX, y + 8, { color: BODY }); + label(doc, 'Total fare paid', margin + padX, y + 8, { color: BODY }); doc.setFontSize(7.5); doc.setFont('helvetica', 'bold'); doc.setTextColor(...SUCCESS); doc.text('✓ PAID', margin + padX, y + 15); @@ -456,7 +456,7 @@ interface VoucherData { // outbound, leg 2 = return), each with that leg's own seat — see bookings.service.ts's // getByRef(). dateOfBirth is included purely to disambiguate same-name passengers when // grouping leg rows back into one passenger below. - passengers: Array<{ fullName: string; dateOfBirth?: string; category: string; leg?: number; fareMinor?: number; seat?: { number: string; coach: string; seatClass: string } }>; + passengers: Array<{ fullName: string; dateOfBirth?: string; category: string; leg?: number; seat?: { number: string; coach: string; seatClass: string } }>; schedule: VoucherSchedule; returnSchedule?: VoucherSchedule | null; totalMinor: number; @@ -475,44 +475,37 @@ interface VoucherData { } export const generateVoucherPDF = async (booking: VoucherData): Promise => { + // The amount shown is always a single raw field straight from the API — the settled + // payment amount when available, otherwise the booking total — never a derived value + // (previously this fell back to Math.round(totalMinor / passengers.length), which + // doesn't correspond to any real field and could disagree with what was actually + // charged). Same value on every passenger's voucher; no /100, no per-passenger split. const settledAmountMinor = booking.payment?.amountMinor; const settledCurrency = booking.payment?.currency; const useSettledAmount = settledAmountMinor != null && !!settledCurrency; // Prefer displayCurrency (passenger's home currency) over the internal ETB currency field. const voucherCurrency = useSettledAmount ? settledCurrency! : (booking.displayCurrency || booking.currency || 'ETB'); - // Display total in the booking's display currency (minor units for ETB, major for settled). - const totalFareMinor = useSettledAmount ? settledAmountMinor! : (booking.displayTotalMinor ?? booking.totalMinor); + // Use displayTotalMinor when available so the voucher shows the passenger's currency amount. + const voucherFareMinor = useSettledAmount ? settledAmountMinor! : (booking.displayTotalMinor ?? booking.totalMinor); const isRoundTrip = booking.bookingType === 'ROUND_TRIP' && !!booking.returnSchedule; // Group leg rows back into one entry per real passenger — without this, a round trip // produced two half-passenger vouchers (one per leg, each showing only its own leg's // seat) instead of one voucher per passenger covering both legs. - // Also accumulate the per-leg ETB fareMinor from the API so we can split the display - // total proportionally (adults vs children pay different rates). type SeatInfo = VoucherData['passengers'][number]['seat']; const grouped = new Map< string, - { fullName: string; category: string; outboundSeat?: SeatInfo; returnSeat?: SeatInfo; etbFareMinor: number } + { fullName: string; category: string; outboundSeat?: SeatInfo; returnSeat?: SeatInfo } >(); booking.passengers.forEach((p) => { const key = `${p.fullName}|${p.dateOfBirth}|${p.category}`; - const entry = grouped.get(key) || { fullName: p.fullName, category: p.category, outboundSeat: undefined, returnSeat: undefined, etbFareMinor: 0 }; + const entry = grouped.get(key) || { fullName: p.fullName, category: p.category, outboundSeat: undefined, returnSeat: undefined }; if (p.leg === 2) entry.returnSeat = p.seat; else entry.outboundSeat = p.seat; - entry.etbFareMinor += p.fareMinor ?? 0; grouped.set(key, entry); }); - const passengerCount = grouped.size || 1; - // Sum of all per-seat ETB fares — used as denominator for proportional splitting. - const totalEtbFareMinor = [...grouped.values()].reduce((sum, p) => sum + p.etbFareMinor, 0); - // When ETB fare data is present, free children have etbFareMinor === 0 — exclude them - // from the denominator so the settled amount is split only among paying passengers. - const paidPassengerCount = totalEtbFareMinor > 0 - ? ([...grouped.values()].filter(p => p.etbFareMinor > 0).length || passengerCount) - : passengerCount; - // Separate file per passenger, saved back-to-back with no macrotask (setTimeout) between // them — a setTimeout delay here would push later saves outside the click's synchronous // user-activation window and risk iOS Safari silently blocking them. The awaited work @@ -529,20 +522,6 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise => // if it doesn't match what's actually on file. const ticketNumber = matchedTicket?.barcodePayload || 'Not yet issued'; - // Per-passenger fare: - // • Free children (etbFareMinor === 0 when ETB data exists) always show 0. - // • Settled amounts: split evenly among PAID passengers (no per-seat currency breakdown). - // • Booking totals: proportional ETB share; falls back to even split only when no - // seat fare data is available at all (older bookings before fareMinor was stored). - const isFreeChild = totalEtbFareMinor > 0 && p.etbFareMinor === 0; - const perPassengerFare = isFreeChild - ? 0 - : useSettledAmount - ? Math.round(totalFareMinor / paidPassengerCount) - : totalEtbFareMinor > 0 - ? Math.round(totalFareMinor * p.etbFareMinor / totalEtbFareMinor) - : Math.round(totalFareMinor / passengerCount); - await generatePassengerVoucherPDF({ bookingRef: booking.bookingRef, ticketNumber, @@ -557,7 +536,7 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise => outboundCoachNumber: isRoundTrip ? p.outboundSeat?.coach : undefined, inboundSeatNumber: isRoundTrip ? p.returnSeat?.number : undefined, inboundCoachNumber: isRoundTrip ? p.returnSeat?.coach : undefined, - fareMinor: perPassengerFare, + fareMinor: voucherFareMinor, currency: voucherCurrency, fareIsMajorUnits: useSettledAmount, createdAt: booking.createdAt,