Added group booking option

This commit is contained in:
Roba Boru
2026-08-24 18:04:13 +03:00
parent 8e2bab27cf
commit a5385e1462
13 changed files with 2000 additions and 51 deletions

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function GroupBookingLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 },

View 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),
};

View File

@@ -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' });
}

View File

@@ -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,
};
}