Files
edr-platform/apps/edr-passenger-web/backoffice/src/lib/export/passenger-template.ts
2026-08-24 18:04:13 +03:00

150 lines
6.2 KiB
TypeScript

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<ExcelJS.Borders> = {
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<Blob> {
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' });
}