mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Added group booking option
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function GroupBookingLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
1133
apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx
Normal file
1133
apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,7 @@ import {
|
||||
Activity,
|
||||
Smartphone,
|
||||
Layers,
|
||||
UsersRound,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -63,6 +64,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||
title: 'Operations',
|
||||
items: [
|
||||
{ name: 'Bookings', href: '/bookings', icon: Ticket, permission: PERMS.bookings.view },
|
||||
{ name: 'Group Booking', href: '/group-booking', icon: UsersRound, permission: PERMS.bookings.manage },
|
||||
{ name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view },
|
||||
{ name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view },
|
||||
{ name: 'Boarding', href: '/boarding', icon: LogIn, permission: PERMS.tickets.manage },
|
||||
|
||||
219
apps/edr-passenger-web/backoffice/src/lib/api/group-booking.ts
Normal file
219
apps/edr-passenger-web/backoffice/src/lib/api/group-booking.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
// ── Search (POST /search) ──────────────────────────────────────────────────
|
||||
|
||||
export interface SearchTripsRequest {
|
||||
originStationId: string;
|
||||
destinationStationId: string;
|
||||
date: string;
|
||||
adultCount: number;
|
||||
childCount?: number;
|
||||
journeyType: 'ONE_WAY';
|
||||
/** Drives which fare tier (Local vs International) gets quoted — see fareTier in the page component. */
|
||||
nationality?: string;
|
||||
}
|
||||
|
||||
export interface ScheduleClassOption {
|
||||
name: string;
|
||||
baseFareMinor: number;
|
||||
displayCurrency: string;
|
||||
displayAmountMinor: number;
|
||||
available: number;
|
||||
}
|
||||
|
||||
export interface ScheduleCoachType {
|
||||
coachTypeId: string;
|
||||
coachTypeName: string;
|
||||
coachTypeCode: string;
|
||||
coachId: string;
|
||||
classes: ScheduleClassOption[];
|
||||
}
|
||||
|
||||
export interface ScheduleResult {
|
||||
type: 'DIRECT';
|
||||
scheduleId: string;
|
||||
trainNumber: string;
|
||||
trainName: string;
|
||||
origin: { id: string; code: string; name: string; city: string; sequence: number };
|
||||
destination: { id: string; code: string; name: string; city: string; sequence: number };
|
||||
departureAt: string;
|
||||
arrivalAt: string;
|
||||
durationMinutes: number;
|
||||
status: string;
|
||||
hasAvailability: boolean;
|
||||
displayCurrency: string;
|
||||
coachTypes: ScheduleCoachType[];
|
||||
}
|
||||
|
||||
export type SearchEmptyReasonCode =
|
||||
| 'NO_ROUTE'
|
||||
| 'NO_SCHEDULE_ON_DATE'
|
||||
| 'CANCELLED'
|
||||
| 'PACKAGE_ONLY'
|
||||
| 'CHECKIN_CLOSED'
|
||||
| 'FULLY_BOOKED';
|
||||
|
||||
/** Structured, not a string — always render via a code→message lookup, never directly. */
|
||||
export interface SearchEmptyReason {
|
||||
code: SearchEmptyReasonCode;
|
||||
originStationName: string;
|
||||
destinationStationName: string;
|
||||
}
|
||||
|
||||
export interface SearchTripsResponse {
|
||||
journeyType: string;
|
||||
outbound: ScheduleResult[];
|
||||
requestedDate: string;
|
||||
outboundReason?: SearchEmptyReason;
|
||||
/** Nearby schedules for the same station pair on a different date, offered when `outbound` is empty. */
|
||||
alternativeOutbound?: ScheduleResult[];
|
||||
}
|
||||
|
||||
// ── Seat classes (GET /seat-classes) ───────────────────────────────────────
|
||||
|
||||
export interface SeatClassOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ── Auto-assign + hold (POST /seats/auto-assign-hold) ─────────────────────
|
||||
|
||||
export interface AutoAssignHoldRequest {
|
||||
scheduleId: string;
|
||||
originStationId: string;
|
||||
destinationStationId: string;
|
||||
seatClassName: string;
|
||||
adultCount: number;
|
||||
childCount?: number;
|
||||
}
|
||||
|
||||
export interface HeldPassengerSeat {
|
||||
passengerId: string;
|
||||
seat: {
|
||||
id: string;
|
||||
label?: string;
|
||||
seatNumber?: string;
|
||||
coach?: string;
|
||||
row?: number;
|
||||
col?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AutoAssignHoldResponse {
|
||||
holdId: string;
|
||||
expiresAt: string;
|
||||
ttlSeconds: number;
|
||||
schedule: { id: string; trainNumber: string; trainName: string; departureAt: string; arrivalAt: string } | null;
|
||||
passengers: HeldPassengerSeat[];
|
||||
}
|
||||
|
||||
// ── Group booking creation (POST /bookings/group) ──────────────────────────
|
||||
|
||||
export interface GroupBookingPassengerInput {
|
||||
seatId: string;
|
||||
passengerName: string;
|
||||
dateOfBirth: string;
|
||||
idDocumentType: 'NATIONAL_ID' | 'PASSPORT' | 'DRIVING_LICENSE' | 'OTHER';
|
||||
idDocumentNumber?: string;
|
||||
passportNumber?: string;
|
||||
passportCountry?: string;
|
||||
nationality?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface CreateGroupBookingRequest {
|
||||
scheduleId: string;
|
||||
holdId: string;
|
||||
originStationId: string;
|
||||
destinationStationId: string;
|
||||
seatClassId: string;
|
||||
bookingType: 'ONE_WAY';
|
||||
passengers: GroupBookingPassengerInput[];
|
||||
}
|
||||
|
||||
export interface GroupBookingSeat {
|
||||
seatId: string;
|
||||
passengerName: string;
|
||||
passengerCategory: 'ADULT' | 'CHILD';
|
||||
seat: { seatNumber: string; bedPosition?: string | null; coach: { number: string } };
|
||||
}
|
||||
|
||||
export interface CreateGroupBookingResponse {
|
||||
id: string;
|
||||
bookingRef: string;
|
||||
status: string;
|
||||
totalMinor: number;
|
||||
currency: string;
|
||||
adultCount: number;
|
||||
childCount: number;
|
||||
seats: GroupBookingSeat[];
|
||||
schedule: {
|
||||
departureAt: string;
|
||||
arrivalAt: string;
|
||||
train: { number: string; name: string };
|
||||
originStation: { name: string };
|
||||
destinationStation: { name: string };
|
||||
};
|
||||
}
|
||||
|
||||
// ── Payment (GET /payments/methods, POST /payments/initiate) ───────────────
|
||||
|
||||
export type PaymentMethodType =
|
||||
| 'TELEBIRR' | 'CBE_BIRR' | 'EBIRR' | 'WAAFI' | 'DMONEY' | 'CAC_BANK' | 'CARD' | 'WALLET' | 'CBE_BILL';
|
||||
|
||||
export interface SupportedPaymentMethod {
|
||||
id: string;
|
||||
type: PaymentMethodType;
|
||||
displayName: string;
|
||||
region: string;
|
||||
currency: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface InitiatePaymentRequest {
|
||||
bookingId: string;
|
||||
method: PaymentMethodType;
|
||||
paymentMethodId?: string;
|
||||
platform?: 'web' | 'mobile' | 'inapp';
|
||||
}
|
||||
|
||||
export interface PaymentClientAction {
|
||||
type: 'REDIRECT' | 'LAUNCH_APP' | 'INVOKE_BRIDGE' | 'COLLECT_OTP' | 'AWAIT_PUSH' | 'SHOW_BILL_REFERENCE';
|
||||
url?: string;
|
||||
/** Set when type=SHOW_BILL_REFERENCE (CBE bill payment) — the number the payer enters at any CBE channel. */
|
||||
billReference?: string;
|
||||
instructions?: string;
|
||||
expiresAt?: string;
|
||||
message?: string;
|
||||
payerAccountMasked?: string;
|
||||
}
|
||||
|
||||
export interface InitiatePaymentResponse {
|
||||
intentId: string;
|
||||
status: string;
|
||||
clientAction?: PaymentClientAction;
|
||||
merchantOrderId?: string;
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
sessionExpiresAt?: string;
|
||||
paymentDeadline?: string;
|
||||
}
|
||||
|
||||
export const groupBookingApi = {
|
||||
searchTrips: (dto: SearchTripsRequest) =>
|
||||
apiClient.post<SearchTripsResponse>('/search', dto),
|
||||
|
||||
getSeatClasses: () => apiClient.get<SeatClassOption[]>('/seat-classes'),
|
||||
|
||||
autoAssignHold: (dto: AutoAssignHoldRequest) =>
|
||||
apiClient.post<AutoAssignHoldResponse>('/seats/auto-assign-hold', dto),
|
||||
|
||||
createGroupBooking: (dto: CreateGroupBookingRequest) =>
|
||||
apiClient.post<CreateGroupBookingResponse>('/bookings/group', dto),
|
||||
|
||||
getPaymentMethods: () => apiClient.get<SupportedPaymentMethod[]>('/payments/methods'),
|
||||
|
||||
initiatePayment: (dto: InitiatePaymentRequest) =>
|
||||
apiClient.post<InitiatePaymentResponse>('/payments/initiate', dto),
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
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' });
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
const VALID_ID_TYPES = ['NATIONAL_ID', 'PASSPORT', 'DRIVING_LICENSE', 'OTHER'];
|
||||
|
||||
/**
|
||||
* Mirrors guest-booking.service.ts's per-passenger nationality inference exactly (NATIONAL_ID
|
||||
* always forces 'Ethiopian'; PASSPORT falls back to Djiboutian/Other by passport country), so a
|
||||
* row that will actually be priced at a different fare tier than the one quoted at search time
|
||||
* is caught here instead of silently mispricing the group later.
|
||||
*/
|
||||
function inferredFareTier(docType: string, nationality: string, passportCountry: string): 'LOCAL' | 'INTERNATIONAL' {
|
||||
const natUpper = nationality.trim().toUpperCase();
|
||||
const isEthiopian = natUpper === 'ETHIOPIAN' || docType === 'NATIONAL_ID';
|
||||
let resolved = nationality;
|
||||
if (isEthiopian && docType === 'NATIONAL_ID') resolved = 'Ethiopian';
|
||||
else if (!isEthiopian && docType === 'PASSPORT') resolved = nationality || (passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
|
||||
else if (isEthiopian && docType === 'PASSPORT') resolved = 'Ethiopian';
|
||||
const resolvedUpper = resolved.trim().toUpperCase();
|
||||
return resolvedUpper === 'ETHIOPIAN' || resolvedUpper === 'DJIBOUTIAN' ? 'LOCAL' : 'INTERNATIONAL';
|
||||
}
|
||||
|
||||
export interface ParsedPassengerRow {
|
||||
/** 1-based row number in the sheet, for error messages ("row 8"). */
|
||||
rowNumber: number;
|
||||
fullName: string;
|
||||
dateOfBirth: string; // normalized YYYY-MM-DD, empty if invalid/missing
|
||||
passengerType: 'Adult' | 'Child' | '';
|
||||
idDocumentType: string;
|
||||
idDocumentNumber: string;
|
||||
passportNumber: string;
|
||||
passportCountry: string;
|
||||
nationality: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ParsePassengerExcelResult {
|
||||
rows: ParsedPassengerRow[];
|
||||
/** Structural problems (wrong file, missing columns) — nothing in `rows` can be trusted if this is non-empty. */
|
||||
fileErrors: string[];
|
||||
}
|
||||
|
||||
function cellText(row: ExcelJS.Row, colIndex: number): string {
|
||||
if (colIndex < 1) return '';
|
||||
const v = row.getCell(colIndex).value;
|
||||
if (v === null || v === undefined) return '';
|
||||
if (v instanceof Date) return v.toISOString().split('T')[0];
|
||||
if (typeof v === 'object') {
|
||||
const anyV = v as any;
|
||||
if (typeof anyV.text === 'string') return anyV.text.trim();
|
||||
if (anyV.result !== undefined) return String(anyV.result).trim();
|
||||
if (anyV.richText) return anyV.richText.map((t: any) => t.text).join('').trim();
|
||||
}
|
||||
return String(v).trim();
|
||||
}
|
||||
|
||||
/** Strips a trailing " *" (required-column marker) so header matching survives the template's own formatting. */
|
||||
function normalizeHeader(h: string): string {
|
||||
return h.replace(/\s*\*\s*$/, '').trim();
|
||||
}
|
||||
|
||||
export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' | 'INTERNATIONAL'): Promise<ParsePassengerExcelResult> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const wb = new ExcelJS.Workbook();
|
||||
try {
|
||||
await wb.xlsx.load(buffer);
|
||||
} catch {
|
||||
return {
|
||||
rows: [],
|
||||
fileErrors: ['Could not read this file. Make sure it is a valid .xlsx or .xls file exported from the downloaded template.'],
|
||||
};
|
||||
}
|
||||
|
||||
const ws = wb.worksheets[0];
|
||||
if (!ws) return { rows: [], fileErrors: ['The workbook has no sheets.'] };
|
||||
|
||||
// Locate the header row by scanning the first several rows for one starting with "Full Name" —
|
||||
// the template puts it at row 5 (after the title/instruction banners), but scanning is more
|
||||
// forgiving of an edited file than hardcoding a row number.
|
||||
let headerRowIndex = -1;
|
||||
let headers: string[] = [];
|
||||
for (let r = 1; r <= 10; r++) {
|
||||
const row = ws.getRow(r);
|
||||
const values: string[] = [];
|
||||
for (let c = 1; c <= 12; c++) values.push(normalizeHeader(cellText(row, c)));
|
||||
if (values.some((v) => v.toLowerCase().startsWith('full name'))) {
|
||||
headerRowIndex = r;
|
||||
headers = values;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (headerRowIndex === -1) {
|
||||
return {
|
||||
rows: [],
|
||||
fileErrors: ['Could not find the expected header row (starting with "Full Name"). Please use the downloaded template without changing its structure.'],
|
||||
};
|
||||
}
|
||||
|
||||
const colFor = (label: string) => headers.findIndex((h) => h.toLowerCase().startsWith(label.toLowerCase())) + 1;
|
||||
const idx = {
|
||||
fullName: colFor('Full Name'),
|
||||
dob: colFor('Date of Birth'),
|
||||
type: colFor('Passenger Type'),
|
||||
docType: colFor('ID Document Type'),
|
||||
docNumber: colFor('ID Document Number'),
|
||||
passportNumber: colFor('Passport Number'),
|
||||
passportCountry: colFor('Passport Country'),
|
||||
nationality: colFor('Nationality'),
|
||||
phone: colFor('Phone'),
|
||||
email: colFor('Email'),
|
||||
};
|
||||
if (idx.fullName < 1 || idx.dob < 1 || idx.type < 1 || idx.docType < 1) {
|
||||
return {
|
||||
rows: [],
|
||||
fileErrors: ['One or more required columns (Full Name, Date of Birth, Passenger Type, ID Document Type) are missing. Please use the downloaded template.'],
|
||||
};
|
||||
}
|
||||
|
||||
const rows: ParsedPassengerRow[] = [];
|
||||
const lastRow = ws.actualRowCount || ws.rowCount;
|
||||
|
||||
for (let r = headerRowIndex + 1; r <= lastRow; r++) {
|
||||
const row = ws.getRow(r);
|
||||
const fullName = cellText(row, idx.fullName);
|
||||
const dobRaw = cellText(row, idx.dob);
|
||||
const typeRaw = cellText(row, idx.type);
|
||||
const docTypeRaw = cellText(row, idx.docType).toUpperCase();
|
||||
const docNumber = cellText(row, idx.docNumber);
|
||||
const passportNumber = cellText(row, idx.passportNumber);
|
||||
const passportCountry = cellText(row, idx.passportCountry);
|
||||
const nationality = cellText(row, idx.nationality);
|
||||
const phone = cellText(row, idx.phone);
|
||||
const email = cellText(row, idx.email);
|
||||
|
||||
// Skip fully blank trailing rows (the template pre-formats borders down to row 200).
|
||||
if (![fullName, dobRaw, typeRaw, docTypeRaw, docNumber, passportNumber, nationality, phone, email].some((v) => v)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!fullName) errors.push('Full Name is required');
|
||||
|
||||
let dateOfBirth = '';
|
||||
let ageYears: number | null = null;
|
||||
if (!dobRaw) {
|
||||
errors.push('Date of Birth is required');
|
||||
} else {
|
||||
const parsed = new Date(dobRaw);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
errors.push(`Date of Birth "${dobRaw}" is not a valid date (use YYYY-MM-DD)`);
|
||||
} else if (parsed.getTime() > Date.now()) {
|
||||
errors.push('Date of Birth cannot be in the future');
|
||||
} else {
|
||||
dateOfBirth = parsed.toISOString().split('T')[0];
|
||||
ageYears = (Date.now() - parsed.getTime()) / (365.25 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
let passengerType: 'Adult' | 'Child' | '' = '';
|
||||
const normalizedType = typeRaw.trim().toLowerCase();
|
||||
if (normalizedType === 'adult') passengerType = 'Adult';
|
||||
else if (normalizedType === 'child') passengerType = 'Child';
|
||||
else errors.push(`Passenger Type "${typeRaw}" must be "Adult" or "Child"`);
|
||||
|
||||
// The backend computes ADULT/CHILD from date of birth alone (under 5 = Child), regardless
|
||||
// of this column — flag a mismatch so the uploader notices before it surprises them later.
|
||||
if (passengerType && ageYears !== null) {
|
||||
const impliedType = ageYears < 5 ? 'Child' : 'Adult';
|
||||
if (impliedType !== passengerType) {
|
||||
warnings.push(`Date of Birth implies ${impliedType}, but Passenger Type is set to ${passengerType} — seats/fare are priced by age, not this column`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!docTypeRaw) {
|
||||
errors.push('ID Document Type is required');
|
||||
} else if (!VALID_ID_TYPES.includes(docTypeRaw)) {
|
||||
errors.push(`ID Document Type "${docTypeRaw}" must be one of NATIONAL_ID, PASSPORT, DRIVING_LICENSE, OTHER`);
|
||||
}
|
||||
|
||||
if (docTypeRaw === 'PASSPORT' && !passportNumber) {
|
||||
warnings.push('Passport Number is empty for a PASSPORT document type');
|
||||
}
|
||||
|
||||
// The whole group is priced at one uniform fare tier (the one quoted at search time) — a
|
||||
// row whose document type/nationality would actually resolve to the other tier will be
|
||||
// priced wrong (over- or under-charged) with no per-passenger fare split to fix it.
|
||||
if (quotedFareTier && VALID_ID_TYPES.includes(docTypeRaw)) {
|
||||
const rowTier = inferredFareTier(docTypeRaw, nationality, passportCountry);
|
||||
if (rowTier !== quotedFareTier) {
|
||||
warnings.push(
|
||||
`This passenger's documents imply ${rowTier === 'LOCAL' ? 'Local (Ethiopian/Djiboutian)' : 'International'} pricing, but the group was quoted at ${quotedFareTier === 'LOCAL' ? 'Local' : 'International'} rates — this passenger's actual fare will differ from the group rate`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
rows.push({
|
||||
rowNumber: r,
|
||||
fullName,
|
||||
dateOfBirth,
|
||||
passengerType,
|
||||
idDocumentType: docTypeRaw,
|
||||
idDocumentNumber: docNumber,
|
||||
passportNumber,
|
||||
passportCountry,
|
||||
nationality,
|
||||
phone,
|
||||
email,
|
||||
errors,
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return { rows: [], fileErrors: ['No passenger rows found below the header. Fill in at least one row and try again.'] };
|
||||
}
|
||||
|
||||
return { rows, fileErrors: [] };
|
||||
}
|
||||
|
||||
export function countByType(rows: ParsedPassengerRow[]): { adults: number; children: number } {
|
||||
return {
|
||||
adults: rows.filter((r) => r.passengerType === 'Adult').length,
|
||||
children: rows.filter((r) => r.passengerType === 'Child').length,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user