mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
567 lines
28 KiB
TypeScript
567 lines
28 KiB
TypeScript
import jsPDF from 'jspdf';
|
|
import QRCode from 'qrcode';
|
|
|
|
// Train schedules are stored/computed server-side in Ethiopian time (EAT - UTC+3) — see
|
|
// apps/edr-passenger-api's timezone.utils.ts. Pinning every date/time on the voucher to
|
|
// this same zone (instead of the generating device's own local zone) is what keeps the
|
|
// PDF's journey times matching what the booking pages show, regardless of the device.
|
|
const APP_TIMEZONE = 'Africa/Addis_Ababa';
|
|
|
|
interface ScheduleInfo {
|
|
trainNumber: string;
|
|
trainName?: string;
|
|
origin: { name: string; code: string; city: string };
|
|
destination: { name: string; code: string; city: string };
|
|
departureAt: string;
|
|
arrivalAt: string;
|
|
seatClass?: string;
|
|
}
|
|
|
|
interface PassengerVoucherData {
|
|
bookingRef: string;
|
|
ticketNumber: string;
|
|
passengerName: string;
|
|
dateOfBirth?: string;
|
|
nationality?: string;
|
|
seatNumber?: string;
|
|
coachNumber?: string;
|
|
outboundSeatNumber?: string;
|
|
outboundCoachNumber?: string;
|
|
inboundSeatNumber?: string;
|
|
inboundCoachNumber?: string;
|
|
status: string;
|
|
outboundSchedule: ScheduleInfo;
|
|
inboundSchedule?: ScheduleInfo;
|
|
isRoundTrip: boolean;
|
|
fareMinor: number;
|
|
currency: string;
|
|
createdAt: string;
|
|
// True when fareMinor is already a display-ready amount (e.g. the settled
|
|
// payment.amountMinor straight from the API) and must NOT be divided by 100 — as
|
|
// opposed to the normal case where fareMinor is genuine minor units (cents).
|
|
fareIsMajorUnits?: boolean;
|
|
}
|
|
|
|
// ─── palette ───────────────────────────────────────────────────────────────
|
|
// A restrained, mostly-neutral palette (ink / slate / hairline / surface) with the
|
|
// brand green reserved for the few elements that should draw the eye — the PNR,
|
|
// times, and the fare — rather than tinting large areas of the page.
|
|
|
|
const BRAND = [20, 113, 76] as const; // brand green — accents only
|
|
const BRAND_SOFT = [235, 245, 240] as const; // pale green tint for subtle fills
|
|
const INK = [24, 28, 33] as const; // headings, high-emphasis text
|
|
const BODY = [71, 85, 105] as const; // slate-600 — body text
|
|
const MUTED = [148, 163, 184] as const; // slate-400 — labels/captions
|
|
const HAIRLINE = [226, 232, 240] as const; // slate-200 — borders/dividers
|
|
const SURFACE = [250, 250, 251] as const; // near-white card fill
|
|
const SUCCESS = [21, 128, 61] as const; // green-700
|
|
const AMBER_TEXT = [146, 64, 14] as const; // amber-800
|
|
const AMBER_FILL = [255, 251, 235] as const; // amber-50
|
|
const AMBER_BORDER = [251, 191, 36] as const; // amber-400
|
|
|
|
const PAGE_MARGIN = 18;
|
|
|
|
// ─── QR code ───────────────────────────────────────────────────────────────
|
|
// Encodes everything a gate scanner needs to verify this specific ticket without
|
|
// a network round-trip: booking reference, ticket number, passenger, train, seat(s),
|
|
// departure time and fare. Kept as compact JSON so any generic QR reader can parse it.
|
|
// Field names (`ref`/`ticketNumber`) must match what the backoffice boarding scanner and
|
|
// tickets.service.ts's scanAndBoard() read from the QR payload — see apps/edr-passenger-api/
|
|
// src/modules/tickets/tickets.service.ts.
|
|
function buildTicketQrPayload(data: PassengerVoucherData): string {
|
|
return JSON.stringify({
|
|
type: 'EDR_TICKET',
|
|
ref: data.bookingRef,
|
|
ticketNumber: data.ticketNumber,
|
|
passenger: data.passengerName,
|
|
status: data.status,
|
|
train: data.outboundSchedule.trainNumber,
|
|
seat: data.isRoundTrip
|
|
? { outbound: data.outboundSeatNumber || null, inbound: data.inboundSeatNumber || null }
|
|
: (data.seatNumber || null),
|
|
departure: data.outboundSchedule.departureAt,
|
|
fare: { amountMinor: data.fareMinor, currency: data.currency },
|
|
});
|
|
}
|
|
|
|
async function generateTicketQrDataUrl(data: PassengerVoucherData): Promise<string | null> {
|
|
try {
|
|
return await QRCode.toDataURL(buildTicketQrPayload(data), {
|
|
width: 240,
|
|
margin: 0,
|
|
errorCorrectionLevel: 'M',
|
|
color: { dark: '#181c21', light: '#ffffff' },
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to generate ticket QR code:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── shared drawing helpers ───────────────────────────────────────────────────
|
|
|
|
function label(doc: jsPDF, text: string, x: number, y: number, opts?: { align?: 'left' | 'right' | 'center'; color?: readonly [number, number, number] }): void {
|
|
doc.setFont('helvetica', 'normal');
|
|
doc.setFontSize(7.5);
|
|
const c = opts?.color ?? MUTED;
|
|
doc.setTextColor(c[0], c[1], c[2]);
|
|
doc.text(text.toUpperCase(), x, y, { align: opts?.align ?? 'left', charSpace: 0.3 });
|
|
}
|
|
|
|
function hairline(doc: jsPDF, x1: number, y: number, x2: number): void {
|
|
doc.setDrawColor(...HAIRLINE);
|
|
doc.setLineWidth(0.25);
|
|
doc.line(x1, y, x2, y);
|
|
}
|
|
|
|
// ─── header ────────────────────────────────────────────────────────────────
|
|
|
|
// Fetched once and reused for the lifetime of the page — re-fetching this same static
|
|
// asset on every passenger/every voucher adds a real network round-trip in the middle of
|
|
// what needs to stay close to the original click's synchronous execution window (iOS
|
|
// Safari silently blocks a file save triggered too long after user activation).
|
|
let logoCache: Promise<{ dataUrl: string; width: number; height: number }> | null = null;
|
|
function loadLogo(): Promise<{ dataUrl: string; width: number; height: number }> {
|
|
if (!logoCache) {
|
|
logoCache = (async () => {
|
|
const logoImg = await fetch('/edr-logo.png');
|
|
const logoBlob = await logoImg.blob();
|
|
const dataUrl = await new Promise<string>((resolve) => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => resolve(reader.result as string);
|
|
reader.readAsDataURL(logoBlob);
|
|
});
|
|
const img = new Image();
|
|
await new Promise((resolve) => { img.onload = resolve; img.src = dataUrl; });
|
|
return { dataUrl, width: img.width, height: img.height };
|
|
})();
|
|
}
|
|
return logoCache;
|
|
}
|
|
|
|
async function drawHeader(doc: jsPDF, margin: number): Promise<number> {
|
|
const pageWidth = doc.internal.pageSize.getWidth();
|
|
const bandHeight = 24;
|
|
|
|
doc.setFillColor(...BRAND);
|
|
doc.rect(0, 0, pageWidth, bandHeight, 'F');
|
|
|
|
try {
|
|
const { dataUrl: logoDataUrl, width, height } = await loadLogo();
|
|
const logoH = 13;
|
|
const logoW = (width / height) * logoH;
|
|
const textX = margin + logoW + 5;
|
|
doc.addImage(logoDataUrl, 'PNG', margin, (bandHeight - logoH) / 2, logoW, logoH);
|
|
doc.setTextColor(255, 255, 255);
|
|
doc.setFontSize(13); doc.setFont('helvetica', 'bold');
|
|
doc.text('ETHIO-DJIBOUTI RAILWAY', textX, bandHeight / 2 - 1);
|
|
doc.setFontSize(7.5); doc.setFont('helvetica', 'normal');
|
|
doc.setTextColor(230, 240, 236);
|
|
doc.text('E-TICKET · BOARDING VOUCHER', textX, bandHeight / 2 + 5, { charSpace: 0.4 });
|
|
} catch {
|
|
doc.setTextColor(255, 255, 255);
|
|
doc.setFontSize(15); doc.setFont('helvetica', 'bold');
|
|
doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, bandHeight / 2 - 1, { align: 'center' });
|
|
doc.setFontSize(7.5); doc.setFont('helvetica', 'normal');
|
|
doc.setTextColor(230, 240, 236);
|
|
doc.text('E-TICKET · BOARDING VOUCHER', pageWidth / 2, bandHeight / 2 + 5, { align: 'center', charSpace: 0.4 });
|
|
}
|
|
return bandHeight + 16;
|
|
}
|
|
|
|
// ─── status pill ───────────────────────────────────────────────────────────
|
|
|
|
function drawStatusPill(doc: jsPDF, status: string, x: number, y: number, align: 'left' | 'right' = 'right'): void {
|
|
const isConfirmed = status === 'TICKETED' || status === 'CONFIRMED';
|
|
const text = isConfirmed ? 'CONFIRMED' : status;
|
|
const color = isConfirmed ? SUCCESS : [180, 83, 9] as const;
|
|
|
|
doc.setFontSize(7.5); doc.setFont('helvetica', 'bold');
|
|
const textWidth = doc.getTextWidth(text.toUpperCase());
|
|
const padX = 3.5;
|
|
const pillH = 5.5;
|
|
const pillW = textWidth + padX * 2;
|
|
const pillX = align === 'right' ? x - pillW : x;
|
|
|
|
doc.setFillColor(color[0], color[1], color[2]);
|
|
doc.roundedRect(pillX, y, pillW, pillH, pillH / 2, pillH / 2, 'F');
|
|
doc.setTextColor(255, 255, 255);
|
|
doc.text(text.toUpperCase(), pillX + pillW / 2, y + pillH / 2 + 1.4, { align: 'center', charSpace: 0.3 });
|
|
}
|
|
|
|
// ─── hero card: PNR + ticket number + QR ──────────────────────────────────
|
|
|
|
function drawTicketHero(doc: jsPDF, bookingRef: string, ticketNumber: string, status: string, qrDataUrl: string | null, y: number, margin: number, pageWidth: number): number {
|
|
const cardH = 32;
|
|
const qrSize = 22;
|
|
const qrPad = 2.5;
|
|
const cardSize = qrSize + qrPad * 2;
|
|
const qrGap = 3;
|
|
// The QR box is anchored to the right edge of the card — reserve that space so the
|
|
// status pill (also right-anchored) never draws underneath/over it.
|
|
const qrCardX = pageWidth - margin - cardSize - qrGap;
|
|
|
|
doc.setFillColor(...SURFACE);
|
|
doc.setDrawColor(...HAIRLINE);
|
|
doc.setLineWidth(0.3);
|
|
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'FD');
|
|
|
|
const padX = 7;
|
|
const pillRightX = qrDataUrl ? qrCardX - qrGap : pageWidth - margin - padX;
|
|
drawStatusPill(doc, status, pillRightX, y + 5.5, 'right');
|
|
|
|
label(doc, 'Booking reference', margin + padX, y + 12);
|
|
doc.setTextColor(...INK); doc.setFontSize(21); doc.setFont('helvetica', 'bold');
|
|
doc.text(bookingRef, margin + padX, y + 23, { charSpace: 0.6 });
|
|
|
|
label(doc, 'Ticket no.', margin + padX, y + 28.5);
|
|
doc.setTextColor(...BODY); doc.setFontSize(9); doc.setFont('helvetica', 'normal');
|
|
doc.text(ticketNumber, margin + padX + 22, y + 28.7);
|
|
|
|
if (qrDataUrl) {
|
|
const cardY = y + (cardH - cardSize) / 2;
|
|
doc.setFillColor(255, 255, 255);
|
|
doc.setDrawColor(...HAIRLINE);
|
|
doc.setLineWidth(0.3);
|
|
doc.roundedRect(qrCardX, cardY, cardSize, cardSize, 2, 2, 'FD');
|
|
doc.addImage(qrDataUrl, 'PNG', qrCardX + qrPad, cardY + qrPad, qrSize, qrSize);
|
|
}
|
|
|
|
return y + cardH + 8;
|
|
}
|
|
|
|
// ─── journey card ──────────────────────────────────────────────────────────
|
|
|
|
function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, legLabel: string | null, y: number, margin: number, pageWidth: number): number {
|
|
const cardW = pageWidth - margin * 2;
|
|
// Tall enough to clear the time+date block below (topY + 22, see below) with a margin
|
|
// before the train sub-row's hairline — previously 30, which the date line (topY + 25)
|
|
// overran by several mm, printing "Wed, Jul 15" directly on top of the TRAIN row.
|
|
const routeH = 35;
|
|
const trainRowH = 8;
|
|
const cardH = routeH + trainRowH;
|
|
|
|
doc.setDrawColor(...HAIRLINE);
|
|
doc.setLineWidth(0.3);
|
|
doc.roundedRect(margin, y, cardW, cardH, 3, 3, 'D');
|
|
|
|
if (legLabel) {
|
|
doc.setFillColor(...BRAND);
|
|
doc.roundedRect(margin + 6, y - 3, doc.getTextWidth(legLabel.toUpperCase()) + 7, 6, 3, 3, 'F');
|
|
doc.setTextColor(255, 255, 255); doc.setFontSize(7.5); doc.setFont('helvetica', 'bold');
|
|
doc.text(legLabel.toUpperCase(), margin + 6 + (doc.getTextWidth(legLabel.toUpperCase()) + 7) / 2, y, { align: 'center', charSpace: 0.3 });
|
|
}
|
|
|
|
const padX = 8;
|
|
const topY = y + (legLabel ? 11 : 7);
|
|
|
|
// Origin block
|
|
label(doc, 'From', margin + padX, topY);
|
|
doc.setTextColor(...INK); doc.setFontSize(16); doc.setFont('helvetica', 'bold');
|
|
doc.text(schedule.origin.code, margin + padX, topY + 7);
|
|
doc.setTextColor(...BODY); doc.setFontSize(8.5); doc.setFont('helvetica', 'normal');
|
|
doc.text(schedule.origin.name, margin + padX, topY + 11.5);
|
|
|
|
const dep = new Date(schedule.departureAt);
|
|
doc.setTextColor(...BRAND); doc.setFontSize(11.5); doc.setFont('helvetica', 'bold');
|
|
doc.text(dep.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, timeZone: APP_TIMEZONE }), margin + padX, topY + 18);
|
|
doc.setTextColor(...MUTED); doc.setFontSize(7); doc.setFont('helvetica', 'normal');
|
|
doc.text(dep.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', timeZone: APP_TIMEZONE }), margin + padX, topY + 22);
|
|
|
|
// Destination block (right-aligned)
|
|
const dx = pageWidth - margin - padX;
|
|
label(doc, 'To', dx, topY, { align: 'right' });
|
|
doc.setTextColor(...INK); doc.setFontSize(16); doc.setFont('helvetica', 'bold');
|
|
doc.text(schedule.destination.code, dx, topY + 7, { align: 'right' });
|
|
doc.setTextColor(...BODY); doc.setFontSize(8.5); doc.setFont('helvetica', 'normal');
|
|
doc.text(schedule.destination.name, dx, topY + 11.5, { align: 'right' });
|
|
|
|
const arr = new Date(schedule.arrivalAt);
|
|
doc.setTextColor(...BRAND); doc.setFontSize(11.5); doc.setFont('helvetica', 'bold');
|
|
doc.text(arr.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, timeZone: APP_TIMEZONE }), dx, topY + 18, { align: 'right' });
|
|
doc.setTextColor(...MUTED); doc.setFontSize(7); doc.setFont('helvetica', 'normal');
|
|
doc.text(arr.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', timeZone: APP_TIMEZONE }), dx, topY + 22, { align: 'right' });
|
|
|
|
// Dashed route line with endpoint markers, connecting the two blocks
|
|
const lineY = topY + 7.5;
|
|
const lineX1 = margin + padX + 24;
|
|
const lineX2 = dx - 24;
|
|
doc.setDrawColor(...HAIRLINE);
|
|
doc.setLineWidth(0.5);
|
|
doc.setLineDashPattern([1, 1.2], 0);
|
|
doc.line(lineX1, lineY, lineX2, lineY);
|
|
doc.setLineDashPattern([], 0);
|
|
doc.setFillColor(...BRAND);
|
|
doc.circle(lineX1, lineY, 0.9, 'F');
|
|
doc.circle(lineX2, lineY, 0.9, 'F');
|
|
|
|
// Train info sub-row — starts at routeH, comfortably below the date line above (topY +
|
|
// 22, i.e. y + 33 at most) so it never overlaps the journey block's text.
|
|
const rowY = y + routeH;
|
|
hairline(doc, margin, rowY, margin + cardW);
|
|
doc.setFontSize(8); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal');
|
|
doc.text('TRAIN', margin + padX, rowY + 5.5, { charSpace: 0.3 });
|
|
doc.setTextColor(...INK); doc.setFont('helvetica', 'bold');
|
|
doc.text(schedule.trainNumber + (schedule.trainName ? ` · ${schedule.trainName}` : ''), margin + padX + 15, rowY + 5.5);
|
|
if (schedule.seatClass) {
|
|
doc.setFont('helvetica', 'normal'); doc.setTextColor(...BODY);
|
|
doc.text(schedule.seatClass, pageWidth - margin - padX, rowY + 5.5, { align: 'right' });
|
|
}
|
|
|
|
return y + cardH + 6;
|
|
}
|
|
|
|
// ─── passenger details ─────────────────────────────────────────────────────
|
|
|
|
function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number, margin: number, pageWidth: number): number {
|
|
label(doc, 'Passenger details', margin, y);
|
|
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][] = [
|
|
['Full name', data.passengerName || '—'],
|
|
['Date of birth', data.dateOfBirth ? new Date(data.dateOfBirth).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '—'],
|
|
['Nationality', data.nationality || '—'],
|
|
];
|
|
if (data.isRoundTrip) {
|
|
rows.push(['Outbound seat', formatSeat(data.outboundCoachNumber, data.outboundSeatNumber)]);
|
|
rows.push(['Return seat', formatSeat(data.inboundCoachNumber, data.inboundSeatNumber)]);
|
|
} else {
|
|
rows.push(['Seat', formatSeat(data.coachNumber, data.seatNumber)]);
|
|
}
|
|
|
|
const rowH = 7;
|
|
rows.forEach(([k, v], i) => {
|
|
const rowY = y + i * rowH;
|
|
doc.setFontSize(8.5); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal');
|
|
doc.text(k.toUpperCase(), margin, rowY + 5, { charSpace: 0.2 });
|
|
doc.setFontSize(9.5); doc.setTextColor(...INK); doc.setFont('helvetica', 'bold');
|
|
doc.text(v, pageWidth - margin, rowY + 5, { align: 'right' });
|
|
if (i < rows.length - 1) hairline(doc, margin, rowY + rowH, pageWidth - margin);
|
|
});
|
|
|
|
return y + rows.length * rowH + 6;
|
|
}
|
|
|
|
// ─── fare summary ──────────────────────────────────────────────────────────
|
|
|
|
function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: number, margin: number, pageWidth: number, fareIsMajorUnits = false): number {
|
|
const cardH = 20;
|
|
doc.setFillColor(...BRAND_SOFT);
|
|
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'F');
|
|
|
|
const padX = 7;
|
|
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);
|
|
|
|
doc.setTextColor(...BRAND); doc.setFontSize(16); doc.setFont('helvetica', 'bold');
|
|
const displayAmount = fareIsMajorUnits ? fareMinor : fareMinor / 100;
|
|
doc.text(`${currency} ${displayAmount.toFixed(2)}`, pageWidth - margin - padX, y + 13, { align: 'right' });
|
|
|
|
return y + cardH + 6;
|
|
}
|
|
|
|
// ─── instructions ──────────────────────────────────────────────────────────
|
|
|
|
function drawInstructions(doc: jsPDF, y: number, margin: number, pageWidth: number): number {
|
|
const cardH = 17;
|
|
const barW = 1.4;
|
|
|
|
doc.setFillColor(...AMBER_FILL);
|
|
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 2, 2, 'F');
|
|
doc.setFillColor(...AMBER_BORDER);
|
|
doc.rect(margin, y, barW, cardH, 'F');
|
|
|
|
const padX = 6;
|
|
doc.setFontSize(8); doc.setTextColor(...AMBER_TEXT); doc.setFont('helvetica', 'bold');
|
|
doc.text('BEFORE YOU TRAVEL', margin + padX, y + 6, { charSpace: 0.3 });
|
|
doc.setFont('helvetica', 'normal'); doc.setFontSize(8);
|
|
doc.text('Present this voucher (printed or on your phone) at the terminal for boarding.', margin + padX, y + 11);
|
|
doc.text('Please arrive at least 2 hours before scheduled departure.', margin + padX, y + 15);
|
|
|
|
return y + cardH + 4;
|
|
}
|
|
|
|
// ─── footer ────────────────────────────────────────────────────────────────
|
|
|
|
function drawFooter(doc: jsPDF, createdAt: string, contentY: number): void {
|
|
const pageWidth = doc.internal.pageSize.getWidth();
|
|
const pageHeight = doc.internal.pageSize.getHeight();
|
|
// Pinned near the bottom for short (one-way) content, same as before — but grows past
|
|
// that floor instead of staying fixed when a round trip's extra journey card pushes
|
|
// content lower, which previously made the footer overlap the instructions card.
|
|
const footerY = Math.max(contentY + 4, pageHeight - 20);
|
|
|
|
hairline(doc, PAGE_MARGIN, footerY, pageWidth - PAGE_MARGIN);
|
|
doc.setFontSize(7.5); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal');
|
|
doc.text('edr_@edrsc.com · 9546 · www.edr.com', pageWidth / 2, footerY + 6, { align: 'center' });
|
|
doc.setFontSize(6.5);
|
|
doc.text(`Issued ${new Date(createdAt).toLocaleString('en-US', { timeZone: APP_TIMEZONE })}`, pageWidth / 2, footerY + 10.5, { align: 'center' });
|
|
}
|
|
|
|
// ─── public API ──────────────────────────────────────────────────────────────
|
|
|
|
async function drawPassengerVoucherPage(doc: jsPDF, data: PassengerVoucherData): Promise<void> {
|
|
const pageW = doc.internal.pageSize.getWidth();
|
|
const margin = PAGE_MARGIN;
|
|
|
|
let y = await drawHeader(doc, margin);
|
|
const qrDataUrl = await generateTicketQrDataUrl(data);
|
|
|
|
y = drawTicketHero(doc, data.bookingRef, data.ticketNumber, data.status, qrDataUrl, y, margin, pageW);
|
|
|
|
label(doc, 'Journey details', margin, y);
|
|
y += 6;
|
|
y = drawJourneyLeg(doc, data.outboundSchedule, data.isRoundTrip ? 'Outbound' : null, y, margin, pageW);
|
|
|
|
if (data.isRoundTrip && data.inboundSchedule) {
|
|
y = drawJourneyLeg(doc, data.inboundSchedule, 'Return', y, margin, pageW);
|
|
}
|
|
|
|
y = drawPassengerDetails(doc, data, y, margin, pageW);
|
|
y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW, data.fareIsMajorUnits);
|
|
y = drawInstructions(doc, y, margin, pageW);
|
|
drawFooter(doc, data.createdAt, y);
|
|
}
|
|
|
|
/** Generates and downloads one PDF voucher for a single passenger. */
|
|
export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise<void> => {
|
|
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
|
await drawPassengerVoucherPage(doc, data);
|
|
|
|
const safeName = (data.passengerName || 'Passenger').replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
doc.save(`Voucher_${safeName}.pdf`);
|
|
};
|
|
|
|
// ─── legacy combined voucher (kept for backward compat) ──────────────────────
|
|
|
|
interface VoucherSchedule {
|
|
trainNumber: string;
|
|
trainName?: string;
|
|
origin: { name: string; code: string; city: string };
|
|
destination: { name: string; code: string; city: string };
|
|
departureAt: string;
|
|
arrivalAt: string;
|
|
}
|
|
|
|
interface VoucherData {
|
|
bookingRef: string;
|
|
status: string;
|
|
// /bookings/:ref returns one row per passenger PER LEG for round trips (leg 1 =
|
|
// 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. fareMinor is that specific row's own
|
|
// fare (ETB minor units) — e.g. a free child's row is 0 even though other passengers on
|
|
// the same booking paid full fare — mirrored from the same field the booking detail
|
|
// page's "Fare breakdown" section already reads (booking/detail/page.tsx).
|
|
passengers: Array<{ fullName: string; dateOfBirth?: string; category: string; leg?: number; fareMinor?: number; seat?: { number: string; coach: string; seatClass: string } }>;
|
|
schedule: VoucherSchedule;
|
|
returnSchedule?: VoucherSchedule | null;
|
|
totalMinor: number;
|
|
displayTotalMinor?: number;
|
|
currency?: string;
|
|
displayCurrency?: string;
|
|
bookingType: string;
|
|
createdAt: string;
|
|
// One ticket per passenger per leg (round trips have a separate ticket/barcode for the
|
|
// return leg) — matched below by passengerName + leg. Optional/absent falls back to a
|
|
// client-generated placeholder number.
|
|
tickets?: Array<{ passengerName?: string; leg?: number; barcodePayload?: string }>;
|
|
// The actual settled amount/currency for this booking's payment — preferred over the
|
|
// ETB booking total once available, since it reflects what was really charged.
|
|
payment?: { amountMinor?: number; currency?: string };
|
|
}
|
|
|
|
export const generateVoucherPDF = async (booking: VoucherData): Promise<void> => {
|
|
// Currency always comes straight from the booking/payment data, never hardcoded — the
|
|
// settled payment currency when a payment has settled, otherwise the booking's own
|
|
// display currency (falling back to the internal ETB currency field).
|
|
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');
|
|
// Basis total for the currency this voucher displays — the settled payment amount when
|
|
// available, otherwise the display-currency total (falling back to the raw ETB total).
|
|
const voucherBasisTotal = useSettledAmount ? settledAmountMinor! : (booking.displayTotalMinor ?? booking.totalMinor);
|
|
// Ratio that converts a passenger's own ETB fareMinor into the same currency/amount basis
|
|
// as voucherBasisTotal above — e.g. if the settled payment is 60% of the ETB total (a
|
|
// currency conversion), each passenger's own ETB fare is scaled by that same 60% ratio.
|
|
// This is NOT an equal split: two passengers with different fares still get different
|
|
// scaled amounts, in exact proportion to what each of them actually paid. Falls back to no
|
|
// scaling (ratio 1) only when the totals are missing or already equal, mirroring the same
|
|
// fallback the booking detail page's "Fare breakdown" section uses for this identical ratio.
|
|
const etbTotalMinor = booking.totalMinor;
|
|
const fareScaleFactor =
|
|
!etbTotalMinor || !voucherBasisTotal || etbTotalMinor === voucherBasisTotal
|
|
? 1
|
|
: voucherBasisTotal / etbTotalMinor;
|
|
|
|
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. Each leg row's own
|
|
// fareMinor is summed here too, so a round trip's voucher reflects both legs' fares
|
|
// and a one-way voucher reflects just its single row's fare.
|
|
type SeatInfo = VoucherData['passengers'][number]['seat'];
|
|
const grouped = new Map<
|
|
string,
|
|
{ fullName: string; category: string; fareMinor: number; 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, fareMinor: 0, outboundSeat: undefined, returnSeat: undefined };
|
|
entry.fareMinor += p.fareMinor ?? 0;
|
|
if (p.leg === 2) entry.returnSeat = p.seat;
|
|
else entry.outboundSeat = p.seat;
|
|
grouped.set(key, entry);
|
|
});
|
|
|
|
// 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
|
|
// inside generatePassengerVoucherPDF is itself just microtasks (cached logo, QR encode),
|
|
// which doesn't have that effect.
|
|
for (const p of grouped.values()) {
|
|
// The displayed ticket number is always the outbound leg's — matched by leg, not just
|
|
// name, so a round trip doesn't end up showing whichever ticket happens to sort first.
|
|
const matchedTicket =
|
|
booking.tickets?.find((t) => t.passengerName === p.fullName && (t.leg ?? 1) === 1) ??
|
|
booking.tickets?.find((t) => t.passengerName === p.fullName) ??
|
|
null;
|
|
// No fabricated placeholder — a made-up TKT-... number reads as real and is misleading
|
|
// if it doesn't match what's actually on file.
|
|
const ticketNumber = matchedTicket?.barcodePayload || 'Not yet issued';
|
|
|
|
// This passenger's own fare, scaled onto the same currency/amount basis as the rest of
|
|
// the voucher — not the booking's overall total, and not an equal share of it.
|
|
const passengerFareMinor = Math.round(p.fareMinor * fareScaleFactor);
|
|
|
|
await generatePassengerVoucherPDF({
|
|
bookingRef: booking.bookingRef,
|
|
ticketNumber,
|
|
passengerName: p.fullName,
|
|
status: booking.status,
|
|
outboundSchedule: { ...booking.schedule, seatClass: p.outboundSeat?.seatClass },
|
|
inboundSchedule: isRoundTrip ? { ...booking.returnSchedule!, seatClass: p.returnSeat?.seatClass } : undefined,
|
|
isRoundTrip,
|
|
seatNumber: isRoundTrip ? undefined : p.outboundSeat?.number,
|
|
coachNumber: isRoundTrip ? undefined : p.outboundSeat?.coach,
|
|
outboundSeatNumber: isRoundTrip ? p.outboundSeat?.number : undefined,
|
|
outboundCoachNumber: isRoundTrip ? p.outboundSeat?.coach : undefined,
|
|
inboundSeatNumber: isRoundTrip ? p.returnSeat?.number : undefined,
|
|
inboundCoachNumber: isRoundTrip ? p.returnSeat?.coach : undefined,
|
|
fareMinor: passengerFareMinor,
|
|
currency: voucherCurrency,
|
|
fareIsMajorUnits: useSettledAmount,
|
|
createdAt: booking.createdAt,
|
|
});
|
|
}
|
|
};
|