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;
|
@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: 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: 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 {
|
export class CreateRouteDto {
|
||||||
@@ -39,8 +37,6 @@ export class AddRouteStopDto {
|
|||||||
@ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number;
|
@ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number;
|
||||||
@ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: 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: 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 {
|
export class UpdateRouteDto {
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ export class RoutesService {
|
|||||||
sequence: s.sequence,
|
sequence: s.sequence,
|
||||||
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
|
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
|
||||||
checkinMinutesBefore: s.checkinMinutesBefore ?? 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,
|
sequence: s.sequence,
|
||||||
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
|
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
|
||||||
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
|
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
|
||||||
plannedArrivalTime: s.plannedArrivalTime ?? null,
|
|
||||||
plannedDepartureTime: s.plannedDepartureTime ?? null,
|
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -229,8 +225,6 @@ export class RoutesService {
|
|||||||
sequence: dto.sequence,
|
sequence: dto.sequence,
|
||||||
distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null,
|
distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null,
|
||||||
checkinMinutesBefore: dto.checkinMinutesBefore ?? null,
|
checkinMinutesBefore: dto.checkinMinutesBefore ?? null,
|
||||||
plannedArrivalTime: dto.plannedArrivalTime ?? null,
|
|
||||||
plannedDepartureTime: dto.plannedDepartureTime ?? null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,20 +54,12 @@ export class CreateScheduleDto {
|
|||||||
plannedTimes?: PlannedStopTimeDto[];
|
plannedTimes?: PlannedStopTimeDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CoachAssignmentDto {
|
|
||||||
@ApiProperty({ example: 'coach-uuid' }) @IsString() coachId: string;
|
|
||||||
@ApiProperty({ example: 1 }) @IsInt() @Min(1) positionNumber: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UpdateScheduleDto {
|
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-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({ 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({ 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({ 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 {
|
export class UpdateStopTimeDto {
|
||||||
|
|||||||
@@ -133,62 +133,26 @@ export class SchedulesService {
|
|||||||
|
|
||||||
let plannedTimes = dto.plannedTimes;
|
let plannedTimes = dto.plannedTimes;
|
||||||
if (!plannedTimes || plannedTimes.length === 0) {
|
if (!plannedTimes || plannedTimes.length === 0) {
|
||||||
const hasRouteTimes = route.stops.some(
|
const totalDuration = arr.getTime() - dep.getTime();
|
||||||
s => (s as any).plannedArrivalTime != null || (s as any).plannedDepartureTime != null,
|
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||||
);
|
|
||||||
|
|
||||||
if (hasRouteTimes) {
|
plannedTimes = route.stops.map((stop, index) => {
|
||||||
// Extract EAT time-of-day from a template DateTime and anchor to the schedule's EAT date.
|
let stopTime: Date;
|
||||||
const EAT_MS = 3 * 60 * 60 * 1000;
|
if (index === 0) {
|
||||||
const depEATMs = dep.getTime() + EAT_MS;
|
stopTime = dep;
|
||||||
const depMsIntoDay = depEATMs % (24 * 60 * 60 * 1000);
|
} else if (index === route.stops.length - 1) {
|
||||||
const eatMidnightUTC = dep.getTime() - depMsIntoDay;
|
stopTime = arr;
|
||||||
|
} else {
|
||||||
const templateToScheduleUTC = (templateDt: Date): Date => {
|
const stopDistance = stop.distanceKm || 0;
|
||||||
// Pull the time-of-day in EAT from the template DateTime
|
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||||
const templateEATMs = templateDt.getTime() + EAT_MS;
|
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||||
const timeOfDayMs = templateEATMs % (24 * 60 * 60 * 1000);
|
}
|
||||||
const candidate = new Date(eatMidnightUTC + timeOfDayMs);
|
return {
|
||||||
// Overnight: if the stop time lands before departure, move to next day
|
sequence: stop.sequence,
|
||||||
if (candidate < dep) return new Date(candidate.getTime() + 24 * 60 * 60 * 1000);
|
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||||
return candidate;
|
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));
|
const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence));
|
||||||
@@ -340,59 +304,26 @@ export class SchedulesService {
|
|||||||
|
|
||||||
let plannedTimes = dto.plannedTimes;
|
let plannedTimes = dto.plannedTimes;
|
||||||
if (!plannedTimes || plannedTimes.length === 0) {
|
if (!plannedTimes || plannedTimes.length === 0) {
|
||||||
const hasRouteTimes = route.stops.some(
|
const totalDuration = arr.getTime() - dep.getTime();
|
||||||
s => (s as any).plannedArrivalTime != null || (s as any).plannedDepartureTime != null,
|
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||||
);
|
|
||||||
|
|
||||||
if (hasRouteTimes) {
|
plannedTimes = route.stops.map((stop, index) => {
|
||||||
const EAT_MS = 3 * 60 * 60 * 1000;
|
let stopTime: Date;
|
||||||
const depEATMs = dep.getTime() + EAT_MS;
|
if (index === 0) {
|
||||||
const depMsIntoDay = depEATMs % (24 * 60 * 60 * 1000);
|
stopTime = dep;
|
||||||
const eatMidnightUTC = dep.getTime() - depMsIntoDay;
|
} else if (index === route.stops.length - 1) {
|
||||||
|
stopTime = arr;
|
||||||
const templateToScheduleUTC = (templateDt: Date): Date => {
|
} else {
|
||||||
const templateEATMs = templateDt.getTime() + EAT_MS;
|
const stopDistance = stop.distanceKm || 0;
|
||||||
const timeOfDayMs = templateEATMs % (24 * 60 * 60 * 1000);
|
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||||
const candidate = new Date(eatMidnightUTC + timeOfDayMs);
|
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||||
if (candidate < dep) return new Date(candidate.getTime() + 24 * 60 * 60 * 1000);
|
}
|
||||||
return candidate;
|
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]));
|
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);
|
return this.getSchedule(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,6 @@ import Modal from '@/components/ui/Modal';
|
|||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
import { routesApi } from '@/lib/api/routes';
|
import { routesApi } from '@/lib/api/routes';
|
||||||
import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api';
|
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 {
|
interface RouteStop {
|
||||||
stationId: string;
|
stationId: string;
|
||||||
@@ -25,8 +17,6 @@ interface RouteStop {
|
|||||||
distanceKm?: number;
|
distanceKm?: number;
|
||||||
distanceFromOrigin?: number;
|
distanceFromOrigin?: number;
|
||||||
checkinMinutesBefore?: number;
|
checkinMinutesBefore?: number;
|
||||||
plannedArrivalTime?: string;
|
|
||||||
plannedDepartureTime?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'routes' | 'coaches';
|
type Tab = 'routes' | 'coaches';
|
||||||
@@ -185,8 +175,6 @@ export default function RoutesPage() {
|
|||||||
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
|
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
|
||||||
const [originCheckinMinutes, setOriginCheckinMinutes] = useState<number | undefined>(undefined);
|
const [originCheckinMinutes, setOriginCheckinMinutes] = useState<number | undefined>(undefined);
|
||||||
const [destinationCheckinMinutes, setDestinationCheckinMinutes] = 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 [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null });
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -257,27 +245,18 @@ export default function RoutesPage() {
|
|||||||
|
|
||||||
// distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm)
|
// distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm)
|
||||||
const stopsArray = [
|
const stopsArray = [
|
||||||
{
|
{ stationId: originStationId, sequence: 1, distanceKm: 0, checkinMinutesBefore: originCheckinMinutes ?? undefined },
|
||||||
stationId: originStationId,
|
|
||||||
sequence: 1,
|
|
||||||
distanceKm: 0,
|
|
||||||
checkinMinutesBefore: originCheckinMinutes ?? undefined,
|
|
||||||
plannedDepartureTime: originDepartureTime ? eatToISO(originDepartureTime) : undefined,
|
|
||||||
},
|
|
||||||
...sortedMiddleStops.map((stop, idx) => ({
|
...sortedMiddleStops.map((stop, idx) => ({
|
||||||
stationId: stop.stationId,
|
stationId: stop.stationId,
|
||||||
sequence: idx + 2,
|
sequence: idx + 2,
|
||||||
distanceKm: stop.distanceFromOrigin || 0,
|
distanceKm: stop.distanceFromOrigin || 0,
|
||||||
checkinMinutesBefore: stop.checkinMinutesBefore ?? undefined,
|
checkinMinutesBefore: stop.checkinMinutesBefore ?? undefined,
|
||||||
plannedArrivalTime: stop.plannedArrivalTime ? eatToISO(stop.plannedArrivalTime) : undefined,
|
|
||||||
plannedDepartureTime: stop.plannedDepartureTime ? eatToISO(stop.plannedDepartureTime) : undefined,
|
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
stationId: destinationStationId,
|
stationId: destinationStationId,
|
||||||
sequence: sortedMiddleStops.length + 2,
|
sequence: sortedMiddleStops.length + 2,
|
||||||
distanceKm: destinationDistance || 0,
|
distanceKm: destinationDistance || 0,
|
||||||
checkinMinutesBefore: destinationCheckinMinutes ?? undefined,
|
checkinMinutesBefore: destinationCheckinMinutes ?? undefined,
|
||||||
plannedArrivalTime: destinationArrivalTime ? eatToISO(destinationArrivalTime) : undefined,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -408,10 +387,8 @@ export default function RoutesPage() {
|
|||||||
const destStop = routeStops[routeStops.length - 1];
|
const destStop = routeStops[routeStops.length - 1];
|
||||||
setOriginStationId(originStop.stationId);
|
setOriginStationId(originStop.stationId);
|
||||||
setOriginCheckinMinutes(originStop.checkinMinutesBefore ?? undefined);
|
setOriginCheckinMinutes(originStop.checkinMinutesBefore ?? undefined);
|
||||||
setOriginDepartureTime(originStop.plannedDepartureTime ? isoToEAT(originStop.plannedDepartureTime) : '');
|
|
||||||
setDestinationStationId(destStop.stationId);
|
setDestinationStationId(destStop.stationId);
|
||||||
setDestinationCheckinMinutes(destStop.checkinMinutesBefore ?? undefined);
|
setDestinationCheckinMinutes(destStop.checkinMinutesBefore ?? undefined);
|
||||||
setDestinationArrivalTime(destStop.plannedArrivalTime ? isoToEAT(destStop.plannedArrivalTime) : '');
|
|
||||||
setDestinationDistance(destStop.distanceKm || 0);
|
setDestinationDistance(destStop.distanceKm || 0);
|
||||||
setStops(routeStops.slice(1, -1).map((s: any) => ({
|
setStops(routeStops.slice(1, -1).map((s: any) => ({
|
||||||
stationId: s.stationId,
|
stationId: s.stationId,
|
||||||
@@ -419,8 +396,6 @@ export default function RoutesPage() {
|
|||||||
distanceKm: s.distanceKm,
|
distanceKm: s.distanceKm,
|
||||||
distanceFromOrigin: s.distanceKm || 0,
|
distanceFromOrigin: s.distanceKm || 0,
|
||||||
checkinMinutesBefore: s.checkinMinutesBefore ?? undefined,
|
checkinMinutesBefore: s.checkinMinutesBefore ?? undefined,
|
||||||
plannedArrivalTime: s.plannedArrivalTime ? isoToEAT(s.plannedArrivalTime) : '',
|
|
||||||
plannedDepartureTime: s.plannedDepartureTime ? isoToEAT(s.plannedDepartureTime) : '',
|
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
@@ -458,10 +433,8 @@ export default function RoutesPage() {
|
|||||||
setEditingRoute(null);
|
setEditingRoute(null);
|
||||||
setOriginStationId('');
|
setOriginStationId('');
|
||||||
setOriginCheckinMinutes(undefined);
|
setOriginCheckinMinutes(undefined);
|
||||||
setOriginDepartureTime('');
|
|
||||||
setDestinationStationId('');
|
setDestinationStationId('');
|
||||||
setDestinationCheckinMinutes(undefined);
|
setDestinationCheckinMinutes(undefined);
|
||||||
setDestinationArrivalTime('');
|
|
||||||
setDestinationDistance(undefined);
|
setDestinationDistance(undefined);
|
||||||
setStops([]);
|
setStops([]);
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
@@ -546,7 +519,7 @@ export default function RoutesPage() {
|
|||||||
setSearch('');
|
setSearch('');
|
||||||
}}
|
}}
|
||||||
title={`${editingRoute ? 'Edit' : 'Add'} Route`}
|
title={`${editingRoute ? 'Edit' : 'Add'} Route`}
|
||||||
size="xl"
|
size="lg"
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
|
<form onSubmit={handleSubmit} className="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
|
||||||
{editingRoute && (
|
{editingRoute && (
|
||||||
@@ -692,7 +665,7 @@ export default function RoutesPage() {
|
|||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<label className="label mb-0">Route Stops</label>
|
<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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -710,7 +683,7 @@ export default function RoutesPage() {
|
|||||||
<span className="text-muted-foreground">Select origin station above</span>
|
<span className="text-muted-foreground">Select origin station above</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-24 flex-shrink-0">
|
<div className="w-28 flex-shrink-0">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="input input-sm"
|
className="input input-sm"
|
||||||
@@ -721,15 +694,7 @@ export default function RoutesPage() {
|
|||||||
title="Check-in cutoff override (minutes) for this stop"
|
title="Check-in cutoff override (minutes) for this stop"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-44 flex-shrink-0">
|
<div className="text-sm text-muted-foreground w-12 text-right flex-shrink-0">0 km</div>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{stops.map((stop, index) => (
|
{stops.map((stop, index) => (
|
||||||
@@ -764,7 +729,7 @@ export default function RoutesPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-20">
|
<div className="w-28">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="input input-sm"
|
className="input input-sm"
|
||||||
@@ -776,33 +741,17 @@ export default function RoutesPage() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-20">
|
<div className="w-28">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="input input-sm"
|
className="input input-sm"
|
||||||
placeholder="cutoff"
|
placeholder="cutoff min"
|
||||||
value={stop.checkinMinutesBefore ?? ''}
|
value={stop.checkinMinutesBefore ?? ''}
|
||||||
onChange={(e) => updateStop(index, 'checkinMinutesBefore', e.target.value ? parseInt(e.target.value) : undefined)}
|
onChange={(e) => updateStop(index, 'checkinMinutesBefore', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||||
min={1}
|
min={1}
|
||||||
title="Check-in cutoff override (minutes) for this stop"
|
title="Check-in cutoff override (minutes) for this stop"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeStop(index)}
|
onClick={() => removeStop(index)}
|
||||||
@@ -841,7 +790,7 @@ export default function RoutesPage() {
|
|||||||
<span className="text-muted-foreground">Select destination station above</span>
|
<span className="text-muted-foreground">Select destination station above</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-24">
|
<div className="w-28">
|
||||||
{destinationStationId && (
|
{destinationStationId && (
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -854,18 +803,7 @@ export default function RoutesPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-44">
|
<div className="w-28">
|
||||||
{destinationStationId && (
|
|
||||||
<DateTimePicker
|
|
||||||
value={destinationArrivalTime}
|
|
||||||
onChange={setDestinationArrivalTime}
|
|
||||||
placeholder="Arr time"
|
|
||||||
label="Planned Arrival"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="w-44" />
|
|
||||||
<div className="w-24">
|
|
||||||
{destinationStationId && (
|
{destinationStationId && (
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -249,10 +249,6 @@ export default function ConfirmationPage() {
|
|||||||
}
|
}
|
||||||
: undefined;
|
: 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)
|
// 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
|
// between them — a setTimeout delay would push later saves outside the click's
|
||||||
// synchronous user-activation window and risk iOS Safari silently blocking them.
|
// synchronous user-activation window and risk iOS Safari silently blocking them.
|
||||||
@@ -282,7 +278,7 @@ export default function ConfirmationPage() {
|
|||||||
outboundSchedule: outbound,
|
outboundSchedule: outbound,
|
||||||
inboundSchedule: inbound,
|
inboundSchedule: inbound,
|
||||||
isRoundTrip,
|
isRoundTrip,
|
||||||
fareMinor: hasSettledAmount ? (getEtbFare(i) === 0 ? 0 : Math.round(settledAmountMinor! / paidPassengerCount)) : getEtbFare(i),
|
fareMinor: hasSettledAmount ? settledAmountMinor! : getEtbFare(i),
|
||||||
currency: voucherCurrency,
|
currency: voucherCurrency,
|
||||||
fareIsMajorUnits: hasSettledAmount,
|
fareIsMajorUnits: hasSettledAmount,
|
||||||
createdAt,
|
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');
|
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'F');
|
||||||
|
|
||||||
const padX = 7;
|
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.setFontSize(7.5); doc.setFont('helvetica', 'bold'); doc.setTextColor(...SUCCESS);
|
||||||
doc.text('✓ PAID', margin + padX, y + 15);
|
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
|
// 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
|
// getByRef(). dateOfBirth is included purely to disambiguate same-name passengers when
|
||||||
// grouping leg rows back into one passenger below.
|
// 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;
|
schedule: VoucherSchedule;
|
||||||
returnSchedule?: VoucherSchedule | null;
|
returnSchedule?: VoucherSchedule | null;
|
||||||
totalMinor: number;
|
totalMinor: number;
|
||||||
@@ -475,44 +475,37 @@ interface VoucherData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const generateVoucherPDF = async (booking: VoucherData): Promise<void> => {
|
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 settledAmountMinor = booking.payment?.amountMinor;
|
||||||
const settledCurrency = booking.payment?.currency;
|
const settledCurrency = booking.payment?.currency;
|
||||||
const useSettledAmount = settledAmountMinor != null && !!settledCurrency;
|
const useSettledAmount = settledAmountMinor != null && !!settledCurrency;
|
||||||
// Prefer displayCurrency (passenger's home currency) over the internal ETB currency field.
|
// Prefer displayCurrency (passenger's home currency) over the internal ETB currency field.
|
||||||
const voucherCurrency = useSettledAmount ? settledCurrency! : (booking.displayCurrency || booking.currency || 'ETB');
|
const voucherCurrency = useSettledAmount ? settledCurrency! : (booking.displayCurrency || booking.currency || 'ETB');
|
||||||
// Display total in the booking's display currency (minor units for ETB, major for settled).
|
// Use displayTotalMinor when available so the voucher shows the passenger's currency amount.
|
||||||
const totalFareMinor = useSettledAmount ? settledAmountMinor! : (booking.displayTotalMinor ?? booking.totalMinor);
|
const voucherFareMinor = useSettledAmount ? settledAmountMinor! : (booking.displayTotalMinor ?? booking.totalMinor);
|
||||||
|
|
||||||
const isRoundTrip = booking.bookingType === 'ROUND_TRIP' && !!booking.returnSchedule;
|
const isRoundTrip = booking.bookingType === 'ROUND_TRIP' && !!booking.returnSchedule;
|
||||||
|
|
||||||
// Group leg rows back into one entry per real passenger — without this, a round trip
|
// 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
|
// 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.
|
// 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'];
|
type SeatInfo = VoucherData['passengers'][number]['seat'];
|
||||||
const grouped = new Map<
|
const grouped = new Map<
|
||||||
string,
|
string,
|
||||||
{ fullName: string; category: string; outboundSeat?: SeatInfo; returnSeat?: SeatInfo; etbFareMinor: number }
|
{ fullName: string; category: string; outboundSeat?: SeatInfo; returnSeat?: SeatInfo }
|
||||||
>();
|
>();
|
||||||
booking.passengers.forEach((p) => {
|
booking.passengers.forEach((p) => {
|
||||||
const key = `${p.fullName}|${p.dateOfBirth}|${p.category}`;
|
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;
|
if (p.leg === 2) entry.returnSeat = p.seat;
|
||||||
else entry.outboundSeat = p.seat;
|
else entry.outboundSeat = p.seat;
|
||||||
entry.etbFareMinor += p.fareMinor ?? 0;
|
|
||||||
grouped.set(key, entry);
|
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
|
// 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
|
// 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
|
// 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.
|
// if it doesn't match what's actually on file.
|
||||||
const ticketNumber = matchedTicket?.barcodePayload || 'Not yet issued';
|
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({
|
await generatePassengerVoucherPDF({
|
||||||
bookingRef: booking.bookingRef,
|
bookingRef: booking.bookingRef,
|
||||||
ticketNumber,
|
ticketNumber,
|
||||||
@@ -557,7 +536,7 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
|
|||||||
outboundCoachNumber: isRoundTrip ? p.outboundSeat?.coach : undefined,
|
outboundCoachNumber: isRoundTrip ? p.outboundSeat?.coach : undefined,
|
||||||
inboundSeatNumber: isRoundTrip ? p.returnSeat?.number : undefined,
|
inboundSeatNumber: isRoundTrip ? p.returnSeat?.number : undefined,
|
||||||
inboundCoachNumber: isRoundTrip ? p.returnSeat?.coach : undefined,
|
inboundCoachNumber: isRoundTrip ? p.returnSeat?.coach : undefined,
|
||||||
fareMinor: perPassengerFare,
|
fareMinor: voucherFareMinor,
|
||||||
currency: voucherCurrency,
|
currency: voucherCurrency,
|
||||||
fareIsMajorUnits: useSettledAmount,
|
fareIsMajorUnits: useSettledAmount,
|
||||||
createdAt: booking.createdAt,
|
createdAt: booking.createdAt,
|
||||||
|
|||||||
Reference in New Issue
Block a user