import ExcelJS from 'exceljs'; // Brand palette — matches finance-workbook.ts / ActionButton's primary variant, kept as a // small local copy rather than a shared import since these are two unrelated export domains. const BRAND = 'FF14714C'; const BRAND_TINT = 'FFEAF5EF'; const INK = 'FF1F2937'; const MUTED = 'FF6B7280'; const BORDER = 'FFE2E5E1'; const WHITE = 'FFFFFFFF'; const THIN_BORDER: Partial = { top: { style: 'thin', color: { argb: BORDER } }, left: { style: 'thin', color: { argb: BORDER } }, bottom: { style: 'thin', color: { argb: BORDER } }, right: { style: 'thin', color: { argb: BORDER } }, }; /** Column order is the contract — passenger-excel.ts reads by this same header order. */ export const PASSENGER_TEMPLATE_COLUMNS = [ 'Full Name', 'Date of Birth (YYYY-MM-DD)', 'Passenger Type', 'ID Document Type', 'ID Document Number', 'Passport Number', 'Passport Country', 'Nationality', 'Phone', 'Email', ] as const; const REQUIRED_ROW = 200; export interface PassengerTemplateInput { trainNumber: string; origin: string; destination: string; travelDate: string; seatClassName: string; adultCount: number; childCount: number; } export async function buildPassengerTemplate(input: PassengerTemplateInput): Promise { const wb = new ExcelJS.Workbook(); wb.creator = 'EDR Passenger Backoffice'; wb.created = new Date(); const ws = wb.addWorksheet('Passengers', { views: [{ state: 'frozen', ySplit: 5 }] }); ws.columns = PASSENGER_TEMPLATE_COLUMNS.map((h) => ({ width: h.length < 14 ? 18 : h.length + 4 })); // ── Title + trip context banner ────────────────────────────────────────── ws.mergeCells(1, 1, 1, PASSENGER_TEMPLATE_COLUMNS.length); const title = ws.getCell(1, 1); title.value = 'EDR Group Booking — Passenger Template'; title.font = { bold: true, size: 16, color: { argb: WHITE } }; title.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND } }; title.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 }; ws.getRow(1).height = 30; for (let c = 1; c <= PASSENGER_TEMPLATE_COLUMNS.length; c++) ws.getCell(1, c).fill = title.fill; ws.mergeCells(2, 1, 2, PASSENGER_TEMPLATE_COLUMNS.length); const subtitle = ws.getCell(2, 1); subtitle.value = `Train ${input.trainNumber} · ${input.origin} → ${input.destination} · ${input.travelDate} · ${input.seatClassName}`; subtitle.font = { size: 11, color: { argb: INK } }; subtitle.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 }; ws.getRow(2).height = 20; ws.mergeCells(3, 1, 3, PASSENGER_TEMPLATE_COLUMNS.length); const requirement = ws.getCell(3, 1); const total = input.adultCount + input.childCount; requirement.value = `Fill in exactly ${total} passenger row${total === 1 ? '' : 's'} below — ${input.adultCount} Adult${input.adultCount === 1 ? '' : 's'} + ${input.childCount} Child${input.childCount === 1 ? '' : 'ren'}. One row per passenger, in any order.`; requirement.font = { italic: true, size: 10, color: { argb: MUTED } }; requirement.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 }; ws.getRow(3).height = 18; ws.mergeCells(4, 1, 4, PASSENGER_TEMPLATE_COLUMNS.length); const instructions = ws.getCell(4, 1); instructions.value = 'Columns marked * are required. Date of Birth must be YYYY-MM-DD and not in the future — it determines Adult/Child pricing (under 5 = Child). ' + 'ID Document Type must be one of: NATIONAL_ID, PASSPORT, DRIVING_LICENSE, OTHER. Do not rename or reorder columns.'; instructions.font = { size: 9, color: { argb: MUTED } }; instructions.alignment = { vertical: 'middle', horizontal: 'left', indent: 1, wrapText: true }; ws.getRow(4).height = 28; // ── Header row ──────────────────────────────────────────────────────────── const headerRow = ws.getRow(5); const requiredCols = new Set([0, 1, 2, 3]); // Full Name, DOB, Passenger Type, ID Document Type PASSENGER_TEMPLATE_COLUMNS.forEach((h, i) => { const cell = headerRow.getCell(i + 1); cell.value = requiredCols.has(i) ? `${h} *` : h; cell.font = { bold: true, color: { argb: WHITE }, size: 11 }; cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND } }; cell.alignment = { vertical: 'middle', horizontal: 'left', wrapText: true }; cell.border = THIN_BORDER; }); headerRow.height = 30; // ── One filled example row so the format is obvious at a glance ────────── const example = ws.getRow(6); const exampleValues = [ 'Abebe Kebede', '1990-05-15', 'Adult', 'NATIONAL_ID', 'ET123456789', '', '', 'Ethiopian', '+251911234567', 'abebe@example.com', ]; exampleValues.forEach((v, i) => { const cell = example.getCell(i + 1); cell.value = v; cell.font = { italic: true, color: { argb: MUTED } }; cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND_TINT } }; cell.border = THIN_BORDER; }); // ── Blank rows with borders + dropdown validation for Passenger Type / ID Document Type ── // exceljs's types only expose per-cell `cell.dataValidation`, not a worksheet-level range API. for (let r = 7; r <= REQUIRED_ROW; r++) { const row = ws.getRow(r); for (let c = 1; c <= PASSENGER_TEMPLATE_COLUMNS.length; c++) { row.getCell(c).border = THIN_BORDER; } row.getCell(3).dataValidation = { type: 'list', allowBlank: true, formulae: ['"Adult,Child"'], showErrorMessage: true, errorTitle: 'Invalid Passenger Type', error: 'Choose Adult or Child.', }; row.getCell(4).dataValidation = { type: 'list', allowBlank: true, formulae: ['"NATIONAL_ID,PASSPORT,DRIVING_LICENSE,OTHER"'], showErrorMessage: true, errorTitle: 'Invalid ID Document Type', error: 'Choose NATIONAL_ID, PASSPORT, DRIVING_LICENSE, or OTHER.', }; } const buffer = await wb.xlsx.writeBuffer(); return new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); }