Merge pull request #823 from Tria-plc/alpha

Include coach number to voucher
This commit is contained in:
robiman
2026-07-20 00:22:00 +03:00
committed by GitHub
7 changed files with 625 additions and 224 deletions

View File

@@ -18,12 +18,11 @@
* parseEthiopianTime('2026-06-15') // Treats as midnight EAT * parseEthiopianTime('2026-06-15') // Treats as midnight EAT
*/ */
export function parseEthiopianTime(dateInput: string | Date): Date { export function parseEthiopianTime(dateInput: string | Date): Date {
if (dateInput instanceof Date) { if (dateInput instanceof Date) return dateInput;
return dateInput; // Already has timezone info (Z or +HH:MM) — parse directly as UTC
} if (/Z$|[+-]\d{2}:\d{2}$/.test(dateInput)) return new Date(dateInput);
// Bare local string (e.g. "2026-07-23T21:00") — treat explicitly as EAT (UTC+3)
// Parse as local time (EAT) since TZ is set to Africa/Addis_Ababa return new Date(dateInput + '+03:00');
return new Date(dateInput);
} }
/** /**

View File

@@ -447,7 +447,7 @@ export class ReportsService {
_count: { select: { seats: true } }, _count: { select: { seats: true } },
}, },
}, },
seat: { include: { coach: { select: { number: true, coachType: { select: { name: true } } } } } }, seat: { select: { seatNumber: true, bedPosition: true, coach: { select: { number: true, coachType: { select: { name: true, seatClasses: { select: { name: true, bedPosition: true } } } } } } } },
}, },
orderBy: [{ seat: { coach: { number: "asc" } } }, { seat: { seatNumber: "asc" } }], orderBy: [{ seat: { coach: { number: "asc" } } }, { seat: { seatNumber: "asc" } }],
}); });
@@ -474,13 +474,25 @@ export class ReportsService {
passportNumber: bs.passportNumber, passportNumber: bs.passportNumber,
passportCountry: bs.passportCountry, passportCountry: bs.passportCountry,
seatLabel: bs.seatLabelSnapshot, seatLabel: bs.seatLabelSnapshot,
seatNumber: bs.seat?.seatNumber ?? null,
seatClassName: (() => {
const classes = bs.seat?.coach?.coachType?.seatClasses ?? [];
const matched = bs.seat?.bedPosition
? classes.find((sc: any) => sc.bedPosition?.toLowerCase() === bs.seat!.bedPosition!.toLowerCase())
: null;
return (matched ?? classes[0])?.name ?? bs.seat?.coach?.coachType?.name ?? null;
})(),
coachNumber: bs.seat?.coach?.number ?? null, coachNumber: bs.seat?.coach?.number ?? null,
coachType: (bs.seat?.coach as any)?.coachType?.name ?? null, coachType: bs.seat?.coach?.coachType?.name ?? null,
nationality: bs.passportCountry
? (bs.passportCountry === 'Djibouti' ? 'Djiboutian' : bs.passportCountry)
: bs.idDocumentType === 'NATIONAL_ID' ? 'Ethiopian' : null,
origin: bs.booking.originStationId ? (stationName.get(bs.booking.originStationId) ?? null) : null, origin: bs.booking.originStationId ? (stationName.get(bs.booking.originStationId) ?? null) : null,
destination: bs.booking.destinationStationId ? (stationName.get(bs.booking.destinationStationId) ?? null) : null, destination: bs.booking.destinationStationId ? (stationName.get(bs.booking.destinationStationId) ?? null) : null,
amountPaidMinor: bs.booking.totalMinor, amountPaidMinor: bs.booking.totalMinor,
currency: bs.booking.currency ?? 'ETB', currency: bs.booking.currency ?? 'ETB',
isGroupBooking: (bs.booking._count?.seats ?? 0) > 1, isGroupBooking: (bs.booking._count?.seats ?? 0) > 1,
bookingStatus: bs.booking.status,
})); }));
} }

File diff suppressed because it is too large Load Diff

View File

@@ -52,13 +52,17 @@ interface PassengerRow {
passportNumber: string | null; passportNumber: string | null;
passportCountry: string | null; passportCountry: string | null;
seatLabel: string | null; seatLabel: string | null;
seatNumber: string | null;
seatClassName: string | null;
coachNumber: string | null; coachNumber: string | null;
coachType: string | null; coachType: string | null;
nationality: string | null;
origin: string | null; origin: string | null;
destination: string | null; destination: string | null;
amountPaidMinor: number; amountPaidMinor: number;
currency: string; currency: string;
isGroupBooking: boolean; isGroupBooking: boolean;
bookingStatus: string;
} }
type Tab = "occupancy" | "list"; type Tab = "occupancy" | "list";
@@ -105,6 +109,7 @@ export default function PassengersReportPage() {
.filter((p) => { .filter((p) => {
if (filterCoach && p.coachNumber !== filterCoach) return false; if (filterCoach && p.coachNumber !== filterCoach) return false;
if (filterOrigin && p.origin !== filterOrigin) return false; if (filterOrigin && p.origin !== filterOrigin) return false;
if (filterSeatClass && p.seatClassName !== filterSeatClass) return false;
if (listSearch.trim()) { if (listSearch.trim()) {
const q = listSearch.toLowerCase(); const q = listSearch.toLowerCase();
return ( return (
@@ -454,6 +459,30 @@ export default function PassengersReportPage() {
value={listSearch} value={listSearch}
onChange={(e) => setListSearch(e.target.value)} onChange={(e) => setListSearch(e.target.value)}
/> />
<select
className="input w-40"
value={filterSeatClass}
onChange={(e) => setFilterSeatClass(e.target.value)}
>
<option value="">All classes</option>
{seatClassOptions.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<select
className="input w-36"
value={filterOrigin}
onChange={(e) => setFilterOrigin(e.target.value)}
>
<option value="">All origins</option>
{originOptions.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
{passengerList.length > 0 && ( {passengerList.length > 0 && (
<ActionButton <ActionButton
icon={Download} icon={Download}

View File

@@ -514,7 +514,7 @@ export default function SchedulesPage() {
const payload: any = { const payload: any = {
trainId: bulkForm.trainId, trainId: bulkForm.trainId,
routeId: bulkForm.routeId, routeId: bulkForm.routeId,
startDateTime: bulkForm.startDateTime, startDateTime: new Date(bulkForm.startDateTime).toISOString(),
durationHours: parseInt(bulkForm.durationHours), durationHours: parseInt(bulkForm.durationHours),
repeatEveryDays: parseInt(bulkForm.repeatEveryDays), repeatEveryDays: parseInt(bulkForm.repeatEveryDays),
forNextDays: parseInt(bulkForm.forNextDays), forNextDays: parseInt(bulkForm.forNextDays),

View File

@@ -263,21 +263,24 @@ export default function ConfirmationPage() {
const ticketNumber = matchedTicket?.barcodePayload || "Not yet issued"; const ticketNumber = matchedTicket?.barcodePayload || "Not yet issued";
await generatePassengerVoucherPDF({ await generatePassengerVoucherPDF({
bookingRef: pnr, bookingRef: pnr,
ticketNumber, ticketNumber,
passengerName: p.name || `Passenger ${i + 1}`, passengerName: p.name || `Passenger ${i + 1}`,
dateOfBirth: p.dateOfBirth, dateOfBirth: p.dateOfBirth,
nationality: p.nationality, nationality: p.nationality,
seatNumber: p.seatNumber, seatNumber: p.seatNumber,
outboundSeatNumber: (p as any).outboundSeatNumber, coachNumber: p.coachNumber,
inboundSeatNumber: (p as any).inboundSeatNumber, outboundSeatNumber: p.outboundSeatNumber,
outboundCoachNumber: p.outboundCoachNumber,
inboundSeatNumber: p.inboundSeatNumber,
inboundCoachNumber: p.inboundCoachNumber,
status, status,
outboundSchedule: outbound, outboundSchedule: outbound,
inboundSchedule: inbound, inboundSchedule: inbound,
isRoundTrip, isRoundTrip,
fareMinor: hasSettledAmount ? settledAmountMinor! : getEtbFare(i), fareMinor: hasSettledAmount ? settledAmountMinor! : getEtbFare(i),
currency: voucherCurrency, currency: voucherCurrency,
fareIsMajorUnits: hasSettledAmount, fareIsMajorUnits: hasSettledAmount,
createdAt, createdAt,
}); });
} }

View File

@@ -24,8 +24,11 @@ interface PassengerVoucherData {
dateOfBirth?: string; dateOfBirth?: string;
nationality?: string; nationality?: string;
seatNumber?: string; seatNumber?: string;
coachNumber?: string;
outboundSeatNumber?: string; outboundSeatNumber?: string;
outboundCoachNumber?: string;
inboundSeatNumber?: string; inboundSeatNumber?: string;
inboundCoachNumber?: string;
status: string; status: string;
outboundSchedule: ScheduleInfo; outboundSchedule: ScheduleInfo;
inboundSchedule?: ScheduleInfo; inboundSchedule?: ScheduleInfo;
@@ -314,16 +317,21 @@ function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number,
label(doc, 'Passenger details', margin, y); label(doc, 'Passenger details', margin, y);
y += 7; y += 7;
const formatSeat = (coach: string | undefined, seat: string | undefined): string => {
if (!seat) return '—';
return coach ? `Coach ${coach} · Seat ${seat}` : `Seat ${seat}`;
};
const rows: [string, string][] = [ const rows: [string, string][] = [
['Full name', data.passengerName || '—'], ['Full name', data.passengerName || '—'],
['Date of birth', data.dateOfBirth ? new Date(data.dateOfBirth).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '—'], ['Date of birth', data.dateOfBirth ? new Date(data.dateOfBirth).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '—'],
['Nationality', data.nationality || '—'], ['Nationality', data.nationality || '—'],
]; ];
if (data.isRoundTrip) { if (data.isRoundTrip) {
rows.push(['Outbound seat', data.outboundSeatNumber || '—']); rows.push(['Outbound seat', formatSeat(data.outboundCoachNumber, data.outboundSeatNumber)]);
rows.push(['Return seat', data.inboundSeatNumber || '—']); rows.push(['Return seat', formatSeat(data.inboundCoachNumber, data.inboundSeatNumber)]);
} else { } else {
rows.push(['Seat', data.seatNumber || '—']); rows.push(['Seat', formatSeat(data.coachNumber, data.seatNumber)]);
} }
const rowH = 7; const rowH = 7;
@@ -485,9 +493,10 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
// 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.
type SeatInfo = VoucherData['passengers'][number]['seat'];
const grouped = new Map< const grouped = new Map<
string, string,
{ fullName: string; category: string; outboundSeat?: VoucherData['passengers'][number]['seat']; returnSeat?: VoucherData['passengers'][number]['seat'] } { 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}`;
@@ -514,20 +523,23 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
const ticketNumber = matchedTicket?.barcodePayload || 'Not yet issued'; const ticketNumber = matchedTicket?.barcodePayload || 'Not yet issued';
await generatePassengerVoucherPDF({ await generatePassengerVoucherPDF({
bookingRef: booking.bookingRef, bookingRef: booking.bookingRef,
ticketNumber, ticketNumber,
passengerName: p.fullName, passengerName: p.fullName,
status: booking.status, status: booking.status,
outboundSchedule: { ...booking.schedule, seatClass: p.outboundSeat?.seatClass }, outboundSchedule: { ...booking.schedule, seatClass: p.outboundSeat?.seatClass },
inboundSchedule: isRoundTrip ? { ...booking.returnSchedule!, seatClass: p.returnSeat?.seatClass } : undefined, inboundSchedule: isRoundTrip ? { ...booking.returnSchedule!, seatClass: p.returnSeat?.seatClass } : undefined,
isRoundTrip, isRoundTrip,
seatNumber: isRoundTrip ? undefined : p.outboundSeat?.number, seatNumber: isRoundTrip ? undefined : p.outboundSeat?.number,
outboundSeatNumber: isRoundTrip ? p.outboundSeat?.number : undefined, coachNumber: isRoundTrip ? undefined : p.outboundSeat?.coach,
inboundSeatNumber: isRoundTrip ? p.returnSeat?.number : undefined, outboundSeatNumber: isRoundTrip ? p.outboundSeat?.number : undefined,
fareMinor: voucherFareMinor, outboundCoachNumber: isRoundTrip ? p.outboundSeat?.coach : undefined,
currency: voucherCurrency, inboundSeatNumber: isRoundTrip ? p.returnSeat?.number : undefined,
fareIsMajorUnits: useSettledAmount, inboundCoachNumber: isRoundTrip ? p.returnSeat?.coach : undefined,
createdAt: booking.createdAt, fareMinor: voucherFareMinor,
currency: voucherCurrency,
fareIsMajorUnits: useSettledAmount,
createdAt: booking.createdAt,
}); });
} }
}; };