mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Revert departure and arrival datetime of stops
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<number | undefined>(undefined);
|
||||
const [originCheckinMinutes, setOriginCheckinMinutes] = useState<number | undefined>(undefined);
|
||||
const [destinationCheckinMinutes, setDestinationCheckinMinutes] = useState<number | undefined>(undefined);
|
||||
const [originDepartureTime, setOriginDepartureTime] = useState<string>('');
|
||||
const [destinationArrivalTime, setDestinationArrivalTime] = useState<string>('');
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null });
|
||||
const [search, setSearch] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
@@ -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"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
|
||||
{editingRoute && (
|
||||
@@ -692,7 +665,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</span> overrides check-in · <span className="font-medium">Arr/Dep time</span> sets default times (auto-filled on schedule creation)</span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -710,7 +683,7 @@ export default function RoutesPage() {
|
||||
<span className="text-muted-foreground">Select origin station above</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-24 flex-shrink-0">
|
||||
<div className="w-28 flex-shrink-0">
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm"
|
||||
@@ -721,15 +694,7 @@ export default function RoutesPage() {
|
||||
title="Check-in cutoff override (minutes) for this stop"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-44 flex-shrink-0">
|
||||
<DateTimePicker
|
||||
value={originDepartureTime}
|
||||
onChange={setOriginDepartureTime}
|
||||
placeholder="Dep time"
|
||||
label="Planned Departure"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground w-10 text-right flex-shrink-0">0 km</div>
|
||||
<div className="text-sm text-muted-foreground w-12 text-right flex-shrink-0">0 km</div>
|
||||
</div>
|
||||
|
||||
{stops.map((stop, index) => (
|
||||
@@ -764,7 +729,7 @@ export default function RoutesPage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-20">
|
||||
<div className="w-28">
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm"
|
||||
@@ -776,33 +741,17 @@ export default function RoutesPage() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="w-20">
|
||||
<div className="w-28">
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm"
|
||||
placeholder="cutoff"
|
||||
placeholder="cutoff min"
|
||||
value={stop.checkinMinutesBefore ?? ''}
|
||||
onChange={(e) => updateStop(index, 'checkinMinutesBefore', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
min={1}
|
||||
title="Check-in cutoff override (minutes) for this stop"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-44">
|
||||
<DateTimePicker
|
||||
value={stop.plannedArrivalTime ?? ''}
|
||||
onChange={(v) => updateStop(index, 'plannedArrivalTime', v)}
|
||||
placeholder="Arr time"
|
||||
label="Planned Arrival"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-44">
|
||||
<DateTimePicker
|
||||
value={stop.plannedDepartureTime ?? ''}
|
||||
onChange={(v) => updateStop(index, 'plannedDepartureTime', v)}
|
||||
placeholder="Dep time"
|
||||
label="Planned Departure"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeStop(index)}
|
||||
@@ -841,7 +790,7 @@ export default function RoutesPage() {
|
||||
<span className="text-muted-foreground">Select destination station above</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<div className="w-28">
|
||||
{destinationStationId && (
|
||||
<input
|
||||
type="number"
|
||||
@@ -854,18 +803,7 @@ export default function RoutesPage() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-44">
|
||||
{destinationStationId && (
|
||||
<DateTimePicker
|
||||
value={destinationArrivalTime}
|
||||
onChange={setDestinationArrivalTime}
|
||||
placeholder="Arr time"
|
||||
label="Planned Arrival"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-44" />
|
||||
<div className="w-24">
|
||||
<div className="w-28">
|
||||
{destinationStationId && (
|
||||
<input
|
||||
type="number"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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<void> => {
|
||||
// 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<void> =>
|
||||
// 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<void> =>
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user