From 108d91b5fa1aa118021d4d4dd568ac1c324c7226 Mon Sep 17 00:00:00 2001 From: hagiye Date: Sat, 27 Jun 2026 04:58:21 +0300 Subject: [PATCH 1/8] Invoice and clearance seal approval --- .../warehouses/dto/bulk-receive.dto.ts | 5 + .../warehouses/warehouse-inventory.service.ts | 204 ++++++++++++++++-- .../warehouse-release-document.service.ts | 116 +++++++--- .../modules/warehouses/warehouses.module.ts | 2 + .../warehouses/ReceiveInventoryModal.tsx | 147 ++++++++++++- .../backoffice/src/types/warehouse.ts | 8 + 6 files changed, 427 insertions(+), 55 deletions(-) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts index 2feadaccd..4f0ce0012 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts @@ -22,6 +22,11 @@ export class TruckEntranceDto { @IsString() tin?: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + customerPhone?: string; + @ApiProperty() @IsString() truckPlateNumber!: string; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index ab3adb64a..662122438 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; @@ -6,6 +6,7 @@ import { Cargo } from '../cargoes/entities/cargoes.entity'; import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service'; import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity'; import { LastMileService } from '../last-mile/last-mile.service'; +import { NotificationsService } from '../notifications/notifications.service'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; @@ -191,6 +192,9 @@ export interface EligibleBookingRow { reference: string; customerId: string | null; customer: string | null; + customerTin: string | null; + customerPhone: string | null; + lastMileRequested: boolean; direction: string; origin: string | null; destination: string | null; @@ -205,6 +209,10 @@ export interface EligibleBookingRow { firstMileVehicleId: string | null; firstMileTruckPlateNumber: string | null; firstMileTrailerPlateNumber: string | null; + firstMileDriverName: string | null; + firstMileDriverPhone: string | null; + firstMileDriverLicenseNumber: string | null; + firstMileTruckType: string | null; } export interface BulkReceiveResult { @@ -289,6 +297,8 @@ export interface ImportUnloadedRow { @Injectable() export class WarehouseInventoryService { + private readonly logger = new Logger(WarehouseInventoryService.name); + constructor( private readonly dataSource: DataSource, private readonly inventoryRepository: WarehouseInventoryRepository, @@ -301,6 +311,7 @@ export class WarehouseInventoryService { private readonly releaseDocuments: WarehouseReleaseDocumentService, private readonly interchangeDocuments: InterchangeDocumentsService, private readonly lastMileService: LastMileService, + private readonly notifications: NotificationsService, ) {} /** @@ -644,6 +655,10 @@ export class WarehouseInventoryService { b.reference AS "reference", b.company_id AS "customerId", company.name AS "customer", + company.tin AS "customerTin", + COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested", oy.code AS "origin", dy.code AS "destination", oy.country AS "originCountry", @@ -659,7 +674,14 @@ export class WarehouseInventoryService { fm.status AS "firstMileStatus", fm.vehicle_id AS "firstMileVehicleId", v.plate_number AS "firstMileTruckPlateNumber", - v.trailer_plate_no AS "firstMileTrailerPlateNumber" + v.trailer_plate_no AS "firstMileTrailerPlateNumber", + COALESCE( + NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''), + v.assigned_driver_name + ) AS "firstMileDriverName", + driver.phone_number AS "firstMileDriverPhone", + driver.license_number AS "firstMileDriverLicenseNumber", + v.vehicle_type AS "firstMileTruckType" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id @@ -675,6 +697,7 @@ export class WarehouseInventoryService { LIMIT 1 ) fm ON true LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id + LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id WHERE b.deleted_at IS NULL AND b.payment_status = 'PAID' AND inv.id IS NULL @@ -695,7 +718,6 @@ export class WarehouseInventoryService { /** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */ async bulkReceive(dto: BulkReceiveDto): Promise { const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] }; - this.assertTruckEntrance(dto.truckEntrance); await this.dataSource.transaction(async (manager) => { await this.validateLocation(manager, { @@ -711,23 +733,40 @@ export class WarehouseInventoryService { }; const [booking] = await manager.query( - `SELECT b.payment_status AS "paymentStatus", b.cargo_total_weight_vgm AS "weight", + `SELECT b.reference AS "reference", + b.payment_status AS "paymentStatus", + b.cargo_total_weight_vgm AS "weight", + company.name AS "customer", + company.tin AS "customerTin", + COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", oy.country AS "originCountry", dy.country AS "destinationCountry", (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile", fm.id AS "firstMileRequestId", - fm.status AS "firstMileStatus" + fm.status AS "firstMileStatus", + v.plate_number AS "firstMileTruckPlateNumber", + v.trailer_plate_no AS "firstMileTrailerPlateNumber", + COALESCE( + NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''), + v.assigned_driver_name + ) AS "firstMileDriverName", + driver.phone_number AS "firstMileDriverPhone", + driver.license_number AS "firstMileDriverLicenseNumber", + v.vehicle_type AS "firstMileTruckType" FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.service_types st ON st.id = b.service_type_id LEFT JOIN LATERAL ( - SELECT first_mile.id, first_mile.status + SELECT first_mile.id, first_mile.status, first_mile.vehicle_id FROM freight.first_mile first_mile WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL ORDER BY first_mile.created_at DESC LIMIT 1 ) fm ON true + LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id + LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, [bookingId], ); @@ -758,11 +797,13 @@ export class WarehouseInventoryService { const now = new Date(); const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now); + const truckEntrance = this.mergeSystemTruckEntrance(dto.truckEntrance, booking); + this.assertTruckEntrance(truckEntrance); const receiveNote = this.buildReceiveNote({ grnNumber, direction: dto.direction, notes: `Bulk received (${dto.direction})`, - truckEntrance: dto.truckEntrance, + truckEntrance, }); const saved = await manager.getRepository(WarehouseInventory).save( manager.getRepository(WarehouseInventory).create({ @@ -783,12 +824,21 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_RECEIVED', inventoryId: saved.id, warehouseId: dto.warehouseId, - description: `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${dto.truckEntrance.truckPlateNumber}`, + description: `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}`, performedBy: dto.performedBy, }, manager, ); + await this.notifyOwnerInventoryReceived({ + phone: truckEntrance.customerPhone, + ownerName: truckEntrance.ownerName, + bookingReference: truckEntrance.edrDigitalBookingId, + grnNumber, + direction: dto.direction, + warehouseId: dto.warehouseId, + }); + result.receivedCount += 1; result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber }); } @@ -1483,7 +1533,6 @@ export class WarehouseInventoryService { } async receive(dto: ReceiveWarehouseInventoryDto): Promise { - this.assertTruckEntrance(dto.truckEntrance); const weight = Number(dto.weight) || 0; const volume = Number(dto.volume) || 0; const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0; @@ -1495,6 +1544,10 @@ export class WarehouseInventoryService { if (dto.bookingId) { await this.assertBookingExists(manager, dto.bookingId); } + const truckEntrance = dto.bookingId + ? this.mergeSystemTruckEntrance(dto.truckEntrance, await this.getBookingTruckEntranceSource(manager, dto.bookingId)) + : dto.truckEntrance; + this.assertTruckEntrance(truckEntrance); this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount); this.assertCapacity('Yard', yard, weight, volume, containerCount); @@ -1505,7 +1558,7 @@ export class WarehouseInventoryService { const receiveNote = this.buildReceiveNote({ grnNumber, notes: dto.notes?.trim() || 'Single booking received', - truckEntrance: dto.truckEntrance, + truckEntrance, }); const saved = await manager.getRepository(WarehouseInventory).save( manager.getRepository(WarehouseInventory).create({ @@ -1529,14 +1582,23 @@ export class WarehouseInventoryService { await this.activityLog.record( { - activityType: 'INVENTORY_RECEIVED', - inventoryId: saved.id, - warehouseId: dto.warehouseId, - description: `GRN ${grnNumber}: received ${weight}kg via truck ${dto.truckEntrance.truckPlateNumber}`, - performedBy: dto.performedBy, - }, - manager, - ); + activityType: 'INVENTORY_RECEIVED', + inventoryId: saved.id, + warehouseId: dto.warehouseId, + description: `GRN ${grnNumber}: received ${weight}kg via truck ${truckEntrance.truckPlateNumber}`, + performedBy: dto.performedBy, + }, + manager, + ); + + await this.notifyOwnerInventoryReceived({ + phone: truckEntrance.customerPhone, + ownerName: truckEntrance.ownerName, + bookingReference: truckEntrance.edrDigitalBookingId ?? dto.bookingId, + grnNumber, + direction: bookingDirection, + warehouseId: dto.warehouseId, + }); return saved.id; }); @@ -2522,6 +2584,111 @@ export class WarehouseInventoryService { } } + private mergeSystemTruckEntrance( + submitted: TruckEntranceDto, + booking: { + reference?: string | null; + customer?: string | null; + customerTin?: string | null; + customerPhone?: string | null; + firstMileTruckPlateNumber?: string | null; + firstMileTrailerPlateNumber?: string | null; + firstMileDriverName?: string | null; + firstMileDriverPhone?: string | null; + firstMileDriverLicenseNumber?: string | null; + firstMileTruckType?: string | null; + }, + ): TruckEntranceDto { + return { + ...submitted, + ownerName: booking.customer?.trim() || submitted.ownerName, + edrDigitalBookingId: booking.reference?.trim() || submitted.edrDigitalBookingId, + tin: booking.customerTin?.trim() || submitted.tin, + customerPhone: booking.customerPhone?.trim() || submitted.customerPhone, + truckPlateNumber: booking.firstMileTruckPlateNumber?.trim() || submitted.truckPlateNumber, + trailerPlateNumber: booking.firstMileTrailerPlateNumber?.trim() || submitted.trailerPlateNumber, + driverName: booking.firstMileDriverName?.trim() || submitted.driverName, + driverPhone: booking.firstMileDriverPhone?.trim() || submitted.driverPhone, + driverLicenseNumber: booking.firstMileDriverLicenseNumber?.trim() || submitted.driverLicenseNumber, + truckType: booking.firstMileTruckType?.trim() || submitted.truckType, + }; + } + + private async getBookingTruckEntranceSource( + manager: EntityManager, + bookingId: string, + ): Promise<{ + reference?: string | null; + customer?: string | null; + customerTin?: string | null; + customerPhone?: string | null; + firstMileTruckPlateNumber?: string | null; + firstMileTrailerPlateNumber?: string | null; + firstMileDriverName?: string | null; + firstMileDriverPhone?: string | null; + firstMileDriverLicenseNumber?: string | null; + firstMileTruckType?: string | null; + }> { + const [booking] = await manager.query( + `SELECT b.reference AS "reference", + company.name AS "customer", + company.tin AS "customerTin", + COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + v.plate_number AS "firstMileTruckPlateNumber", + v.trailer_plate_no AS "firstMileTrailerPlateNumber", + COALESCE( + NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''), + v.assigned_driver_name + ) AS "firstMileDriverName", + driver.phone_number AS "firstMileDriverPhone", + driver.license_number AS "firstMileDriverLicenseNumber", + v.vehicle_type AS "firstMileTruckType" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN LATERAL ( + SELECT first_mile.vehicle_id + FROM freight.first_mile first_mile + WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL + ORDER BY first_mile.created_at DESC + LIMIT 1 + ) fm ON true + LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id + LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id + WHERE b.id = $1 AND b.deleted_at IS NULL + LIMIT 1`, + [bookingId], + ); + return booking ?? {}; + } + + private async notifyOwnerInventoryReceived(params: { + phone?: string | null; + ownerName?: string | null; + bookingReference?: string | null; + grnNumber: string; + direction?: string | null; + warehouseId?: string | null; + }): Promise { + const phone = params.phone?.trim(); + if (!phone) return; + + const ownerName = params.ownerName?.trim() || 'Customer'; + const bookingReference = params.bookingReference?.trim(); + const message = + `Dear ${ownerName}, your cargo has been received by EDR warehouse. ` + + (bookingReference ? `Booking: ${bookingReference}. ` : '') + + `GRN: ${params.grnNumber}. ` + + (params.direction ? `Direction: ${params.direction}. ` : '') + + `Thank you.`; + + try { + await this.notifications.directSend('sms', phone, message); + } catch (error) { + // Receiving inventory must not be rolled back because an SMS provider is unavailable. + this.logger.error(`Failed to notify owner for GRN ${params.grnNumber}: ${String(error)}`); + } + } + private generateGrnNumber(direction: string, referenceId: string, date: Date): string { const stamp = date.toISOString().slice(0, 10).replace(/-/g, ''); const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase(); @@ -2542,6 +2709,7 @@ export class WarehouseInventoryService { truck.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null, truck.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null, truck.tin ? `TIN: ${truck.tin}` : null, + truck.customerPhone ? `Customer Phone: ${truck.customerPhone}` : null, `Truck Plate: ${truck.truckPlateNumber}`, truck.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null, truck.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts index 4398d943e..a77a46c29 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -99,26 +99,51 @@ export class WarehouseReleaseDocumentService { } private htmlToBasicPdfBuffer(html: string): Buffer { - const text = this.htmlToPlainText(html); - const lines = this.wrapLines(text, 86).slice(0, 52); - const body = lines - .map((line, index) => { - const y = 770 - index * 12; - const isTitle = index < 2 || /clearance|release order/i.test(line); - const size = index === 0 ? 13 : isTitle ? 11 : 9.6; - const font = isTitle ? 'F2' : 'F1'; - return this.textOp(line, 48, y, size, font); - }) - .join('\n'); + const doc = this.extractReleaseDocument(html); + const body: string[] = [ + this.lineOp(36, 810, 559, 810, '0 0 0', 2.2), + this.textOp('ETHIO-DJIBOUTI RAILWAY S.C.', 36, 787, 9, 'F2', '0.08 0.32 0.18'), + this.textOp('WAREHOUSE GATE', 36, 764, 24, 'F2', '0.02 0.08 0.16'), + this.textOp('CLEARANCE / RELEASE', 36, 742, 24, 'F2', '0.02 0.08 0.16'), + this.textOp('ORDER', 36, 720, 24, 'F2', '0.02 0.08 0.16'), + this.textOp('OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION', 36, 696, 8.5, 'F1', '0.25 0.34 0.45'), + this.textOp('Document / Release No.', 424, 781, 8.5, 'F1', '0.15 0.22 0.32'), + this.textOp(doc.reference, 504 - doc.reference.length * 2.2, 761, 15, 'F2', '0.02 0.08 0.16'), + this.textOp(`Issued: ${doc.issuedAt}`, 424, 742, 8.5, 'F1', '0.15 0.22 0.32'), + this.lineOp(36, 682, 559, 682, '0.08 0.32 0.18', 2), + this.rectOp(36, 625, 410, 52, '0.95 1 0.96', '0.38 0.85 0.55', 0.8), + this.lineOp(39, 625, 39, 677, '0.08 0.48 0.25', 2.2), + ...this.wrapLines(doc.notice, 68) + .slice(0, 4) + .map((line, index) => this.textOp(line, 52, 659 - index * 12, 9.2, 'F1')), + this.textOp('RELEASE PARTICULARS', 36, 604, 10, 'F2', '0.08 0.32 0.18'), + ]; + + let y = 586; + const rowHeight = 20; + for (const [label, value] of doc.rows.slice(0, 14)) { + body.push(this.rectOp(36, y - rowHeight + 3, 160, rowHeight, '0.97 0.98 0.99', '0.70 0.77 0.85', 0.6)); + body.push(this.rectOp(196, y - rowHeight + 3, 363, rowHeight, '1 1 1', '0.70 0.77 0.85', 0.6)); + body.push(this.textOp(label, 46, y - 10, 8.6, 'F2', '0.02 0.08 0.16')); + body.push(this.textOp(value || '-', 206, y - 10, 8.6, 'F1', '0.02 0.08 0.16')); + y -= rowHeight; + } + + body.push(this.textOp('AUTHORIZATION CLAUSE', 36, y - 10, 10, 'F2', '0.08 0.32 0.18')); + body.push(this.rectOp(36, y - 76, 523, 48, '1 1 1', '0.70 0.77 0.85', 0.7)); + body.push( + ...this.wrapLines(doc.clause, 92) + .slice(0, 4) + .map((line, index) => this.textOp(line, 48, y - 45 - index * 10, 8.2, 'F1')), + ); + const stream = [ - this.lineOp(48, 752, 548, 752), - body, - this.circularSealOps(184, 154), - this.lineOp(48, 92, 278, 92, '0 0 0'), - this.textOp('Officer in charge name / signature / date', 48, 76, 9, 'F1'), - this.lineOp(326, 92, 548, 92, '0 0 0'), - this.textOp('Customer or driver name / signature / date', 326, 76, 9, 'F1'), - this.textOp('OFFICIAL WAREHOUSE GATE CLEARANCE DOCUMENT', 145, 52, 9, 'F2', '0.08 0.32 0.18'), + ...body, + this.lineOp(36, 60, 218, 60, '0 0 0', 1), + this.textOp('Officer in charge name / signature / date', 36, 47, 7.4, 'F1'), + this.circularSealOps(286, 62, 38), + this.lineOp(341, 60, 559, 60, '0 0 0', 1), + this.textOp('Customer or driver name / signature / date', 341, 47, 7.4, 'F1'), ].join('\n'); const objects = [ @@ -149,6 +174,31 @@ export class WarehouseReleaseDocumentService { return Buffer.from(pdf, 'latin1'); } + private extractReleaseDocument(html: string): { + reference: string; + issuedAt: string; + notice: string; + clause: string; + rows: Array<[string, string]>; + } { + const textFromHtml = (value: string) => this.htmlToPlainText(value).replace(/\n/g, ' ').trim(); + const reference = textFromHtml(html.match(/([\s\S]*?)<\/strong>/i)?.[1] ?? 'DO'); + const issuedAt = textFromHtml(html.match(/Issued:\s*([^<]+)/i)?.[1] ?? '-'); + const notice = textFromHtml( + html.match(/
([\s\S]*?)<\/div>/i)?.[1] ?? + 'This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.', + ); + const clause = textFromHtml( + html.match(/
([\s\S]*?)<\/div>/i)?.[1] ?? + 'The warehouse officer and gate staff shall verify this document against the booking reference, customer or driver identity, cargo details, clearance status, and payment records before permitting exit from the warehouse premises.', + ); + const rows: Array<[string, string]> = []; + for (const match of html.matchAll(/([\s\S]*?)<\/th>([\s\S]*?)<\/td><\/tr>/gi)) { + rows.push([textFromHtml(match[1]), textFromHtml(match[2])]); + } + return { reference, issuedAt, notice, clause, rows }; + } + private htmlToPlainText(html: string): string { return html .replace(//gi, '') @@ -203,24 +253,36 @@ export class WarehouseReleaseDocumentService { return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${this.escapePdfText(text)}) Tj\nET`; } - private lineOp(x1: number, y1: number, x2: number, y2: number, color = '0.08 0.32 0.18'): string { - return `q\n${color} RG\n0.8 w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`; + private lineOp(x1: number, y1: number, x2: number, y2: number, color = '0.08 0.32 0.18', width = 0.8): string { + return `q\n${color} RG\n${width} w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`; } - private circularSealOps(cx: number, cy: number): string { + private rectOp( + x: number, + y: number, + width: number, + height: number, + fillColor = '1 1 1', + strokeColor = '0.08 0.32 0.18', + lineWidth = 0.8, + ): string { + return `q\n${fillColor} rg\n${strokeColor} RG\n${lineWidth} w\n${x} ${y} ${width} ${height} re\nB\nQ`; + } + + private circularSealOps(cx: number, cy: number, radius = 51): string { return [ 'q', '0.08 0.32 0.18 RG', '0.08 0.32 0.18 rg', '2.2 w', - this.circlePath(cx, cy, 51), + this.circlePath(cx, cy, radius), 'S', '0.8 w', - this.circlePath(cx, cy, 41), + this.circlePath(cx, cy, radius - 10), 'S', - this.textOp('EDR', cx - 14, cy + 18, 14, 'F2', '0.08 0.32 0.18'), - this.textOp('WAREHOUSE', cx - 31, cy + 2, 9, 'F2', '0.08 0.32 0.18'), - this.textOp('CLEARED', cx - 27, cy - 16, 11, 'F2', '0.08 0.32 0.18'), + this.textOp('EDR', cx - 11, cy + 13, 11, 'F2', '0.08 0.32 0.18'), + this.textOp('WAREHOUSE', cx - 25, cy, 7.5, 'F2', '0.08 0.32 0.18'), + this.textOp('CLEARED', cx - 21, cy - 13, 9, 'F2', '0.08 0.32 0.18'), 'Q', ].join('\n'); } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index 116bfabf1..cbdb5522c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { FilesModule } from '../files/files.module'; import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module'; import { LastMileModule } from '../last-mile/last-mile.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity'; @@ -71,6 +72,7 @@ import { WarehousesService } from './warehouses.service'; FilesModule, InterchangeDocumentsModule, forwardRef(() => LastMileModule), + NotificationsModule, ExchangeModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ExchangeOptions => diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index a2437fe8f..df282e224 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -58,6 +58,7 @@ interface TruckEntranceFormState { consigneeDetails: string; edrDigitalBookingId: string; tin: string; + customerPhone: string; truckPlateNumber: string; trailerPlateNumber: string; assignedEquipmentNumber: string; @@ -85,11 +86,21 @@ interface TruckEntranceFormState { warehouseManagerName: string; } +interface LockedTruckEntranceFields { + ownerName?: boolean; + tin?: boolean; + edrDigitalBookingId?: boolean; + customerPhone?: boolean; +} + +type PackagingFreightType = 'CONTAINER' | 'BULK' | 'MIXED'; + const emptyTruckEntrance = (): TruckEntranceFormState => ({ ownerName: '', consigneeDetails: '', edrDigitalBookingId: '', tin: '', + customerPhone: '', truckPlateNumber: '', trailerPlateNumber: '', assignedEquipmentNumber: '', @@ -122,6 +133,7 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl consigneeDetails: form.consigneeDetails.trim() || undefined, edrDigitalBookingId: form.edrDigitalBookingId.trim() || undefined, tin: form.tin.trim() || undefined, + customerPhone: form.customerPhone.trim() || undefined, truckPlateNumber: form.truckPlateNumber.trim(), trailerPlateNumber: form.trailerPlateNumber.trim() || undefined, assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined, @@ -149,13 +161,106 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl warehouseManagerName: form.warehouseManagerName.trim() || undefined, }); +const commonNonEmptyValue = (values: Array) => { + const unique = [...new Set(values.map((value) => value?.trim()).filter(Boolean))] as string[]; + return unique.length === 1 ? unique[0] : ''; +}; + +const truckEntranceFromBookings = (bookings: EligibleBooking[]): { + form: TruckEntranceFormState; + lockedFields: LockedTruckEntranceFields; + packagingFreightType: PackagingFreightType; +} => { + const ownerName = commonNonEmptyValue(bookings.map((booking) => booking.customer)); + const tin = commonNonEmptyValue(bookings.map((booking) => booking.customerTin)); + const customerPhone = commonNonEmptyValue(bookings.map((booking) => booking.customerPhone)); + const edrDigitalBookingId = + bookings.length === 1 + ? bookings[0]?.reference ?? bookings[0]?.id ?? '' + : commonNonEmptyValue(bookings.map((booking) => booking.reference)); + const firstMileBooking = bookings.length === 1 ? bookings[0] : null; + const freightTypes = [...new Set(bookings.map((booking) => booking.freightType).filter(Boolean))]; + const packagingFreightType = + freightTypes.length === 1 && freightTypes[0] === 'CONTAINER' + ? 'CONTAINER' + : freightTypes.length === 1 && freightTypes[0] === 'BULK' + ? 'BULK' + : 'MIXED'; + + return { + form: { + ...emptyTruckEntrance(), + ownerName, + tin, + customerPhone, + edrDigitalBookingId, + truckPlateNumber: firstMileBooking?.firstMileTruckPlateNumber ?? '', + trailerPlateNumber: firstMileBooking?.firstMileTrailerPlateNumber ?? '', + driverName: firstMileBooking?.firstMileDriverName ?? '', + driverPhone: firstMileBooking?.firstMileDriverPhone ?? '', + driverLicenseNumber: firstMileBooking?.firstMileDriverLicenseNumber ?? '', + truckType: firstMileBooking?.firstMileTruckType ?? '', + }, + lockedFields: { + ownerName: Boolean(ownerName), + tin: Boolean(tin), + edrDigitalBookingId: Boolean(edrDigitalBookingId), + customerPhone: Boolean(customerPhone), + }, + packagingFreightType, + }; +}; + +const BULK_PACKAGING_TYPE_OPTIONS = [ + { value: 'BAG', label: 'Bag' }, + { value: 'SACK', label: 'Sack' }, + { value: 'BALE', label: 'Bale' }, + { value: 'CARTON', label: 'Carton' }, + { value: 'CRATE', label: 'Crate' }, + { value: 'DRUM', label: 'Drum' }, + { value: 'BARREL', label: 'Barrel' }, + { value: 'PALLET', label: 'Pallet' }, + { value: 'LOOSE_BULK', label: 'Loose bulk' }, + { value: 'OTHER', label: 'Other' }, +]; + +const CONTAINER_PACKAGING_TYPE_OPTIONS = [ + { value: 'CONTAINER_20FT', label: '20 ft container' }, + { value: 'CONTAINER_40FT', label: '40 ft container' }, + { value: 'CONTAINER_45FT', label: '45 ft container' }, + { value: 'REEFER_CONTAINER', label: 'Reefer container' }, + { value: 'TANK_CONTAINER', label: 'Tank container' }, + { value: 'FLAT_RACK_CONTAINER', label: 'Flat rack container' }, + { value: 'OPEN_TOP_CONTAINER', label: 'Open top container' }, + { value: 'OTHER_CONTAINER', label: 'Other container' }, +]; + +const packagingOptionsFor = (freightType: PackagingFreightType) => + freightType === 'CONTAINER' + ? CONTAINER_PACKAGING_TYPE_OPTIONS + : freightType === 'BULK' + ? BULK_PACKAGING_TYPE_OPTIONS + : [...CONTAINER_PACKAGING_TYPE_OPTIONS, ...BULK_PACKAGING_TYPE_OPTIONS]; + function TruckEntranceFields({ value, onChange, + lockedFields, + packagingFreightType = 'MIXED', }: { value: TruckEntranceFormState; onChange: (next: TruckEntranceFormState) => void; + lockedFields?: LockedTruckEntranceFields; + packagingFreightType?: PackagingFreightType; }) { + const packagingOptions = packagingOptionsFor(packagingFreightType); + const quantityLabel = + packagingFreightType === 'CONTAINER' + ? 'Container quantity' + : packagingFreightType === 'BULK' + ? 'Unit count' + : 'Quantity'; + return ( Customer and cargo ownership @@ -163,6 +268,7 @@ function TruckEntranceFields({ onChange({ ...value, ownerName: e.currentTarget.value })} /> onChange({ ...value, edrDigitalBookingId: e.currentTarget.value })} /> onChange({ ...value, tin: e.currentTarget.value })} /> + onChange({ ...value, customerPhone: e.currentTarget.value })} + /> Transport and equipment tracking @@ -285,13 +399,16 @@ function TruckEntranceFields({ /> - onChange({ ...value, packagingType: e.currentTarget.value })} + onChange={(v) => onChange({ ...value, packagingType: v ?? '' })} /> onChange({ ...value, unitCount: v === '' ? '' : Number(v) })} @@ -466,6 +583,8 @@ function EligibleTab({ const [truckOpen, setTruckOpen] = useState(false); const [pendingReceiveIds, setPendingReceiveIds] = useState([]); const [truckForm, setTruckForm] = useState(emptyTruckEntrance()); + const [lockedTruckFields, setLockedTruckFields] = useState({}); + const [packagingFreightType, setPackagingFreightType] = useState('MIXED'); const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId); const canReceiveBooking = (row: EligibleBooking) => @@ -564,13 +683,14 @@ function EligibleTab({ toast({ variant: 'destructive', title: 'No selected booking is ready to receive' }); return; } - const row = filteredIds.length === 1 ? rows.find((item) => item.id === filteredIds[0]) : null; + const selectedRows = filteredIds + .map((id) => rows.find((item) => item.id === id)) + .filter(Boolean) as EligibleBooking[]; + const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows); setPendingReceiveIds(filteredIds); - setTruckForm({ - ...emptyTruckEntrance(), - truckPlateNumber: row?.firstMileTruckPlateNumber ?? '', - trailerPlateNumber: row?.firstMileTrailerPlateNumber ?? '', - }); + setTruckForm(form); + setLockedTruckFields(lockedFields); + setPackagingFreightType(nextPackagingFreightType); setTruckOpen(true); }; @@ -593,6 +713,8 @@ function EligibleTab({ setSelected(new Set()); setTruckOpen(false); setPendingReceiveIds([]); + setLockedTruckFields({}); + setPackagingFreightType('MIXED'); onChanged?.(); } catch (error) { toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) }); @@ -805,7 +927,12 @@ function EligibleTab({ Register the arriving truck and driver before receiving {pendingReceiveIds.length} booking{pendingReceiveIds.length === 1 ? '' : 's'}. - +
+ +
+ + + +
+
+ + + {/* Success Message */} + {success && ( +
+
+ + {success} +
+ + {lastScanned && ( +
+
+ + + {lastScanned.passengerName} + +
+ +
+ + + {lastScanned.route} + +
+ +
+ + + {lastScanned.trainName} - Coach {lastScanned.coach}, Seat {lastScanned.seat} + +
+ +
+ + + Boarded: {formatDateTime(lastScanned.boardedAt)} ({lastScanned.leg}) + +
+ + {lastScanned.isRoundTrip && ( +
+

+ ℹ️ Round-trip ticket: Scan again for return journey +

+
+ )} + +
+ Booking: {lastScanned.bookingRef} | Ticket: {lastScanned.ticketId} +
+ +
+ 📧 Email & SMS notifications sent to passenger +
+
+ )} +
+ )} + + {/* Error Message */} + {error && ( +
+
+ + {error} +
+
+ )} + + {/* Instructions */} +
+

How to scan:

+
    +
  • • Tap "Scan QR Code" and point at ticket QR code
  • +
  • • For manual option, type or paste booking reference
  • +
  • • Tickets can only be boarded on their departure date
  • +
  • • First scan boards outbound leg for round trips
  • +
  • • Email & SMS sent automatically to passenger contacts
  • +
  • • Red error shows validation issues
  • +
+
+ + {/* Quick Stats */} +
+

Session Summary

+
+ Status: + + Ready to scan + +
+
+ + + + + + + ); +} \ No newline at end of file diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index 6caa5733b..b4cf005e8 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Download, Eye, XCircle, Trash2 } from 'lucide-react'; +import { Download, Eye, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import Pagination from '@/components/ui/Pagination'; @@ -31,7 +31,6 @@ const SectionHeader = ({ title }: { title: string }) => ( function BookingsPageContent() { const canManage = usePermission(PERMS.bookings.manage); - const canCancel = usePermission(PERMS.bookings.cancel); const [filters, setFilters] = useState({ page: 1, pageSize: 20, search: '', status: '' }); const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' }); const [showExtraFilters, setShowExtraFilters] = useState(false); @@ -62,16 +61,6 @@ function BookingsPageContent() { }), }); - const cancelMutation = useMutation({ - mutationFn: ({ id, reason }: { id: string; reason?: string }) => bookingsApi.cancel(id, reason), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['bookings'] }); - setSuccessMessage('Booking cancelled successfully'); - setTimeout(() => setSuccessMessage(''), 3000); - }, - onError: (error: any) => alert(`Error: ${error.message || 'Failed to cancel booking'}`), - }); - const deleteMutation = useMutation({ mutationFn: (id: string) => apiClient.delete(`/bookings/${id}`), onSuccess: () => { @@ -87,12 +76,6 @@ function BookingsPageContent() { }, }); - const handleCancel = async (booking: any) => { - if (window.confirm(`Cancel booking ${booking.bookingRef}? This will process a refund.`)) { - await cancelMutation.mutateAsync({ id: booking.id, reason: 'Cancelled by admin' }); - } - }; - const BOOKING_COLS = [ { key: 'bookingRef', label: 'Booking Reference' }, { key: 'journeyType', label: 'Journey Type' }, { key: 'passengerNames', label: 'Passenger Names' }, { key: 'contactPhone', label: 'Contact Phone' }, @@ -167,9 +150,38 @@ function BookingsPageContent() { { key: 'passengerNames', label: 'Names', render: (booking: any) => { - const names: string[] = booking.passengerNames || []; - if (!names.length) return ; - return
{names.map((n, i) => {n})}
; + const passengers = booking.passengers || []; + if (!passengers.length) { + // Fallback to old logic if passengers array not available + const names: string[] = booking.passengerNames || []; + const adultCount = booking.adultCount || 0; + if (!names.length) return ; + return ( +
+ {names.map((name, i) => { + const isAdult = i < adultCount; + const passengerType = isAdult ? 'A' : 'C'; + return ( + + {name} ({passengerType}) + + ); + })} +
+ ); + } + return ( +
+ {passengers.map((p: any, i: number) => { + const passengerType = p.category === 'ADULT' ? 'A' : 'C'; + return ( + + {p.name} ({passengerType}) + + ); + })} +
+ ); }, }, { @@ -206,10 +218,6 @@ function BookingsPageContent() { const actions = [ { label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye }, - { - label: 'Cancel Booking', onClick: handleCancel, variant: 'danger' as const, icon: XCircle, - show: (b: any) => b.status !== 'CANCELLED' && b.status !== 'BOARDED', - }, { label: 'Delete', onClick: (b: any) => { setDeleteError(null); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 }, ]; diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index d755e5d2f..f8ee29091 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -144,8 +144,11 @@ export default function CoachesPage() { const [activeTab, setActiveTab] = useState('coaches'); const [search, setSearch] = useState(''); const [showModal, setShowModal] = useState(false); + const [showPreviewModal, setShowPreviewModal] = useState(false); + const [seatMapPreview, setSeatMapPreview] = useState(null); const [editingItem, setEditingItem] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null }); + const [selectedCoachTypeId, setSelectedCoachTypeId] = useState(''); const queryClient = useQueryClient(); // Coach Types Queries @@ -212,6 +215,14 @@ export default function CoachesPage() { }, }); + const generateSeatMapMutation = useMutation({ + mutationFn: fleetApi.generateSeatMap, + onSuccess: (data) => { + setSeatMapPreview(data); + setShowPreviewModal(true); + }, + }); + const handleCoachTypeSubmit = async (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.currentTarget); @@ -231,7 +242,8 @@ export default function CoachesPage() { const handleCoachSubmit = async (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.currentTarget); - const data = { + + const data: any = { number: formData.get('number') as string, coachTypeId: formData.get('coachTypeId') as string, arrangement: formData.get('arrangement') as string, @@ -240,6 +252,16 @@ export default function CoachesPage() { status: formData.get('status') as string, }; + // Add bed-specific fields if bed coach is selected + const bedCategory = formData.get('bedCategory') as string; + if (bedCategory) { + data.bedCategory = bedCategory as 'ECONOMY_BED' | 'VIP_BED'; + const bedsPerRoom = formData.get('bedsPerRoom') as string; + if (bedsPerRoom) { + data.bedsPerRoom = parseInt(bedsPerRoom); + } + } + if (editingItem?.isCoach) { await updateCoachMutation.mutateAsync({ id: editingItem.id, data }); } else { @@ -247,6 +269,27 @@ export default function CoachesPage() { } }; + const handlePreviewSeatMap = async () => { + const form = document.querySelector('form') as HTMLFormElement; + const formData = new FormData(form); + const bedCategory = formData.get('bedCategory') as string; + const capacity = parseInt(formData.get('capacity') as string); + + if (!bedCategory || !capacity) { + alert('Please select a bed category and enter capacity to preview seat map'); + return; + } + + const bedsPerRoom = bedCategory === 'VIP_BED' ? 4 : 6; + const roomsPerCoach = Math.ceil(capacity / bedsPerRoom); + + await generateSeatMapMutation.mutateAsync({ + coachCount: 1, + roomsPerCoach, + roomType: bedCategory, + }); + }; + const handleDelete = (item: any, isCoachType: boolean) => { setDeleteConfirm({ isOpen: true, item: { ...item, isCoachType } }); }; @@ -298,7 +341,6 @@ export default function CoachesPage() { const statusMap: Record = { ACTIVE: 'edr-badge-success', - MAINTENANCE: 'edr-badge-warning', INACTIVE: 'edr-badge-danger', }; @@ -373,10 +415,30 @@ export default function CoachesPage() { }, { key: 'arrangement', - label: 'Arrangement', - render: (coach: any) => ( - {coach.arrangement || 'N/A'} - ), + label: 'Type/Arrangement', + render: (coach: any) => { + // Check if this is a bed coach based on coach type name containing 'bed' + const coachTypeName = coach.coachType?.name?.toLowerCase() || ''; + const isBedCoach = coachTypeName.includes('bed') || coachTypeName.includes('sleeper') || coachTypeName.includes('berth'); + + if (isBedCoach) { + // Determine if it's VIP or Economy based on coach type name + const isVIP = coachTypeName.includes('vip'); + return ( +
+ + {coach.arrangement || 'N/A'} +
+ ); + } + + return ( +
+ + {coach.arrangement || 'N/A'} +
+ ); + }, }, { key: 'capacity', @@ -422,6 +484,7 @@ export default function CoachesPage() { label: 'Edit', onClick: (item: any) => { setEditingItem({ ...item, isCoach: true }); + setSelectedCoachTypeId(item.coachTypeId || ''); setShowModal(true); }, variant: 'secondary' as const, @@ -446,6 +509,7 @@ export default function CoachesPage() { icon={Plus} onClick={() => { setEditingItem(null); + setSelectedCoachTypeId(''); setSearch(''); setShowModal(true); }} @@ -556,6 +620,7 @@ export default function CoachesPage() { onClose={() => { setShowModal(false); setEditingItem(null); + setSelectedCoachTypeId(''); }} title={ activeTab === 'types' @@ -636,12 +701,13 @@ export default function CoachesPage() { name="coachTypeId" className="input" defaultValue={editingItem?.coachTypeId || ''} + onChange={(e) => setSelectedCoachTypeId(e.target.value)} required > {coachTypesArray.map((ct: any) => ( ))} @@ -659,17 +725,66 @@ export default function CoachesPage() { /> + {/* Conditionally show bed fields only for Economy and Regular coach types */} + {(() => { + const selectedCoachType = coachTypesArray.find((ct: any) => ct.id === (selectedCoachTypeId || editingItem?.coachTypeId)); + const isEconomyOrRegular = selectedCoachType && + (selectedCoachType.name?.toLowerCase().includes('economy') || + selectedCoachType.name?.toLowerCase().includes('regular') || + selectedCoachType.type?.toLowerCase().includes('economy') || + selectedCoachType.type?.toLowerCase().includes('regular')); + + return isEconomyOrRegular ? ( + <> +
+ + +

+ Select if this is a bed coach +

+
+ +
+ + +

+ Only applies to bed coaches +

+
+ + ) : null; + })()} +
-

Format: separate columns with +

+

+ For regular seats: columns separated by + +

@@ -691,12 +806,14 @@ export default function CoachesPage() { type="number" name="sequence" className="input" - defaultValue={editingItem?.sequence || 0} - min="0" + defaultValue={editingItem?.sequence || 1} + min="1" required - placeholder="e.g., 1" + placeholder="1" /> -

Used for ordering coaches in trains

+

+ Position in train consist +

@@ -708,19 +825,27 @@ export default function CoachesPage() { required > -
+ + Preview Bed Layout + { setShowModal(false); setEditingItem(null); + setSelectedCoachTypeId(''); }} > Cancel @@ -735,6 +860,58 @@ export default function CoachesPage() { )} + + {/* Seat Map Preview Modal */} + { + setShowPreviewModal(false); + setSeatMapPreview(null); + }} + title="Bed Layout Preview" + size="lg" + > + {seatMapPreview && ( +
+
+

Configuration

+
+
Room Type: {seatMapPreview.roomType}
+
Rooms per Coach: {seatMapPreview.roomsPerCoach}
+
Beds per Room: {seatMapPreview.bedsPerRoom}
+
Total Beds: {seatMapPreview.totalBeds}
+
+
+ +
+

Bed Layout Sample (First Few Rooms)

+
+ {seatMapPreview.seats?.slice(0, 24).map((seat: any, idx: number) => ( +
+ {seat.seat_id} - Room: {seat.room_id} - {seat.position} {seat.bed_type} +
+ ))} + {seatMapPreview.seats?.length > 24 && ( +
+ ... and {seatMapPreview.seats.length - 24} more beds +
+ )} +
+
+ +
+ { + setShowPreviewModal(false); + setSeatMapPreview(null); + }} + > + Close + +
+
+ )} +
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index 66d5ebeb6..41b7abce7 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PERMS } from '@/lib/permissions'; -import { Ticket, Users, DollarSign, Percent } from 'lucide-react'; +import { Ticket, Users, DollarSign, Percent, AlertCircle, TrendingUp, Calendar } from 'lucide-react'; import StatCard from '@/components/dashboard/StatCard'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; @@ -13,43 +13,83 @@ import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, R const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']; +// Mock data for fallback when API fails +const MOCK_STATS = { + totalBookings: 1247, + totalRevenue: 892450, + totalPassengers: 2156, + occupancyRate: 78 +}; + +const MOCK_RECENT_BOOKINGS = [ + { + id: '1', + bookingRef: 'BK-2024-001', + passenger: { fullName: 'John Doe' }, + totalMinor: 125000, + currency: 'ETB', + status: 'CONFIRMED', + createdAt: new Date().toISOString() + }, + { + id: '2', + bookingRef: 'BK-2024-002', + passenger: { fullName: 'Jane Smith' }, + totalMinor: 85000, + currency: 'ETB', + status: 'PENDING', + createdAt: new Date().toISOString() + } +]; + function DashboardPageContent() { - const { data: stats, isLoading: statsLoading } = useQuery({ + const { data: stats, isLoading: statsLoading, error: statsError } = useQuery({ queryKey: ['dashboard-stats'], queryFn: dashboardApi.getStats, + retry: 1, + staleTime: 60000, // 1 minute }); const { data: revenueData, isLoading: revenueLoading } = useQuery({ queryKey: ['revenue-chart'], queryFn: () => dashboardApi.getRevenueChart(30), + retry: 1, }); - const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery({ + const { data: recentBookingsData, isLoading: bookingsLoading, error: bookingsError } = useQuery({ queryKey: ['recent-bookings'], queryFn: () => dashboardApi.getRecentBookings(10), + retry: 1, }); const { data: topAgents, isLoading: agentsLoading } = useQuery({ queryKey: ['top-agents'], queryFn: () => dashboardApi.getTopAgents(5), + retry: 1, }); const { data: occupancyTrend, isLoading: occupancyLoading } = useQuery({ queryKey: ['occupancy-trend'], queryFn: () => dashboardApi.getOccupancyTrend(7), + retry: 1, }); const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({ queryKey: ['upcoming-trips'], queryFn: () => dashboardApi.getUpcomingTrips(5), + retry: 1, }); const { data: paymentMethods } = useQuery({ queryKey: ['payment-methods'], queryFn: dashboardApi.getPaymentMethods, + retry: 1, }); - const recentBookings = Array.isArray(recentBookingsData) ? recentBookingsData : []; + // Use actual data or fallback to mock/empty states + const displayStats = stats || (statsError ? MOCK_STATS : null); + const recentBookings = Array.isArray(recentBookingsData) ? recentBookingsData : + (bookingsError ? MOCK_RECENT_BOOKINGS : []); const bookingColumns = [ { key: 'reference', label: 'Reference', render: (item: any) => item.bookingRef || item.reference }, @@ -106,35 +146,52 @@ function DashboardPageContent() { ]; return ( -
+

Dashboard

Welcome back! Here's your operational summary.

+ {/* Error Alert */} + {(statsError || bookingsError) && ( +
+
+ +
+

+ Some data may be outdated +

+

+ Unable to fetch live data. Showing cached or sample information. +

+
+
+
+ )} + {/* Primary Metrics */} -
+
@@ -143,9 +200,16 @@ function DashboardPageContent() { {/* Charts Row */}
{/* Revenue Trend */} - {!revenueLoading && revenueData && revenueData.length > 0 && ( -
-

Revenue Trend (Last 30 Days)

+
+

+ + Revenue Trend (Last 30 Days) +

+ {revenueLoading ? ( +
+
+
+ ) : revenueData && revenueData.length > 0 ? ( @@ -155,13 +219,27 @@ function DashboardPageContent() { -
- )} + ) : ( +
+
+ +

No revenue data available

+
+
+ )} +
{/* Occupancy Trend */} - {!occupancyLoading && occupancyTrend && occupancyTrend.length > 0 && ( -
-

Occupancy Trend (Last 7 Days)

+
+

+ + Occupancy Trend (Last 7 Days) +

+ {occupancyLoading ? ( +
+
+
+ ) : occupancyTrend && occupancyTrend.length > 0 ? ( @@ -171,8 +249,15 @@ function DashboardPageContent() { -
- )} + ) : ( +
+
+ +

No occupancy data available

+
+
+ )} +
{/* Payment Methods Distribution */} @@ -202,40 +287,45 @@ function DashboardPageContent() { {/* Recent Bookings */}
-

Recent Bookings

+

+ + Recent Bookings +

{/* Upcoming Trips */} - {upcomingTrips && upcomingTrips.length > 0 && ( -
-

Upcoming Trips

- -
- )} +
+

+ + Upcoming Trips +

+ +
{/* Top Agents */} - {topAgents && topAgents.length > 0 && ( -
-

Top Performing Agents

- -
- )} +
+

+ + Top Performing Agents +

+ +
); } @@ -246,4 +336,4 @@ export default function DashboardPage() { ); -} +} \ No newline at end of file diff --git a/apps/edr-passenger-web/backoffice/src/app/docs/page.tsx b/apps/edr-passenger-web/backoffice/src/app/docs/page.tsx index deaf40007..650dde2cf 100644 --- a/apps/edr-passenger-web/backoffice/src/app/docs/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/docs/page.tsx @@ -14,6 +14,7 @@ const DocPage = () => { security: false, analytics: false, system: false, + enhanced: false, }); const toggleSection = (section: string) => { @@ -133,10 +134,32 @@ const DocPage = () => { { id: 'agents-how', label: '→ How-To' }, { id: 'users', label: 'Users' }, { id: 'users-how', label: '→ How-To' }, + { id: 'system-config', label: 'System Config' }, + { id: 'system-config-how', label: '→ How-To' }, { id: 'settings', label: 'Settings' }, { id: 'settings-how', label: '→ How-To' }, ] }, + { + id: 'enhanced', + title: '✨ Enhanced Features', + items: [ + { id: 'excess-baggage', label: 'Excess Baggage' }, + { id: 'excess-baggage-how', label: '→ How-To' }, + { id: 'packages', label: 'Travel Packages' }, + { id: 'packages-how', label: '→ How-To' }, + { id: 'package-inquiries', label: 'Package Inquiries' }, + { id: 'package-inquiries-how', label: '→ How-To' }, + { id: 'health', label: 'Health Monitoring' }, + { id: 'health-how', label: '→ How-To' }, + { id: 'boarding', label: 'Boarding Management' }, + { id: 'boarding-how', label: '→ How-To' }, + { id: 'fare-config', label: 'Advanced Fare Config' }, + { id: 'fare-config-how', label: '→ How-To' }, + { id: 'payment-methods', label: 'Payment Methods' }, + { id: 'payment-methods-how', label: '→ How-To' }, + ] + }, ]; const HowToStep = ({ number, title, children }: { number: number; title: string; children: React.ReactNode }) => ( @@ -203,7 +226,18 @@ const DocPage = () => {

Welcome to EDR Passenger Backoffice

-

Comprehensive management system for the Ethio-Djibouti Railway passenger platform. This documentation provides complete guidance on all features, operations, and best practices.

+

Comprehensive management system for the Ethio-Djibouti Railway passenger platform. This documentation provides complete guidance on all features, operations, and best practices.

+
+

🎆 Version 1.0.0 - Complete Platform Release

+
    +
  • Excess Baggage: Complete baggage handling with agent tools and passenger self-pay
  • +
  • Travel Packages: Bundled offerings with tiered pricing and inquiry management
  • +
  • Health Monitoring: Comprehensive system status and performance tracking
  • +
  • Boarding Management: Gate operations and passenger processing workflows
  • +
  • Advanced Fare Config: Dynamic pricing with segment-based rules
  • +
  • Payment Methods: Multi-provider payment configuration and management
  • +
+
@@ -222,7 +256,7 @@ const DocPage = () => {
    -
  1. Click {`"Bookings"`} in Operations section
  2. +
  3. Click "Bookings" in Operations section
  4. View all bookings in table format
@@ -234,704 +268,278 @@ const DocPage = () => {
    -
  1. Click {`"View Details"`} for full information
  2. -
  3. Click {`"Cancel Booking"`} to process refunds
  4. +
  5. Click "View Details" for full information
  6. +
  7. Click "Cancel Booking" to process refunds
- {/* PASSENGERS */} -
-

👥 Passengers

-

Manage passenger profiles, loyalty, and verification status.

+
+

⚙️ System Config

+

Centralized system configuration management with feature flags and operational controls.

-
-

👥 How-To: Manage Passengers

+
+

⚙️ How-To: Manage System Configuration

- +
    -
  1. Click {`"Passengers"`} in Operations
  2. -
  3. View all profiles with pagination
  4. +
  5. Click "System Config" in System section
  6. +
  7. View all configuration categories
  8. +
+
+ +
    +
  1. Adjust auth endpoints limit (default: 5 req/min)
  2. +
  3. Set strict endpoints limit (default: 20 req/min)
  4. +
  5. Configure default endpoints limit (default: 100 req/min)
  6. +
+
+ +
    +
  1. Set seat hold duration (default: 5 minutes)
  2. +
  3. Configure hold cutoff before departure (default: 2 hours)
  4. +
  5. Click "Save Changes" to apply
  6. +
+
+
+
+ + {/* EXCESS BAGGAGE */} +
+

📦 Excess Baggage

+

Manage excess baggage charges at boarding with agent tools and passenger self-pay options.

+
+ +
+

📦 How-To: Handle Excess Baggage

+
+ +
    +
  1. Click "Excess Baggage" in Enhanced Features
  2. +
  3. View all baggage charges and their status
    -
  1. Search by name, email, phone, ID
  2. -
  3. Filter by nationality, verification, loyalty tier
  4. +
  5. Search by booking reference
  6. +
  7. Filter by status: PENDING, PAID, CASH_COLLECTED, EXPIRED, WAIVED
  8. +
  9. Use date filters for specific periods
- +
    -
  1. Click passenger row to open modal
  2. -
  3. View account, loyalty, wallet, booking history
  4. +
  5. "Resend Link" for pending charges to passenger
  6. +
  7. "Waive" charges with reason (supervisor authority)
  8. +
  9. "Delete" expired or waived charges
- {/* TICKETS */} -
-

🎫 Tickets

-

Manage ticket generation, tracking, and validation.

+ {/* TRAVEL PACKAGES */} +
+

🎒 Travel Packages

+

Manage pilgrimage and group travel packages with tiered pricing and capacity management.

-
-

🎫 How-To: Manage Tickets

+
+

🎒 How-To: Manage Travel Packages

- +
    -
  1. Click {`"Tickets"`} in Operations
  2. -
  3. View all issued tickets with status
  4. +
  5. Click "New Package" button
  6. +
  7. Fill package details: code, name, stations, schedules
  8. +
  9. Set capacity, validity period, and included services
  10. +
  11. Save package (starts in DRAFT status)
- +
    -
  1. Search by booking reference or ticket number
  2. -
  3. Filter by validation status
  4. +
  5. Click "Tiers" on package to manage pricing
  6. +
  7. Add tiers: seat type, label, price, capacity
  8. +
  9. Edit existing tiers (limited if bookings exist)
  10. +
  11. Delete unused tiers
- +
    -
  1. Click ticket to view details
  2. -
  3. Click {`"Download PDF"`} for printable version
  4. +
  5. "Activate" draft packages to make bookable
  6. +
  7. "Deactivate" active packages to stop new bookings
  8. +
  9. "Delete" packages with no bookings if needed
- {/* STATIONS */} -
-

🏢 Stations

-

Configure railway stations with locations and timezones.

+ {/* PACKAGE INQUIRIES */} +
+

📝 Package Inquiries

+

Manage incoming package booking inquiries and track lead conversion.

-
-

🏢 How-To: Manage Stations

+
+

📝 How-To: Handle Package Inquiries

- +
    -
  1. Click {`"Stations"`} in Master Data
  2. -
  3. View all configured stations
  4. +
  5. Click "Package Inquiries" in Enhanced Features
  6. +
  7. Filter by package or inquiry status
  8. +
  9. View contact details, package interest, traveler count
- +
    -
  1. Click {`"Add Station"`}
  2. -
  3. Enter code, name, city, timezone, coordinates
  4. +
  5. Use status dropdown: NEW → CONTACTED → CONVERTED/CLOSED
  6. +
  7. Mark as CONTACTED after first customer contact
  8. +
  9. Mark as CONVERTED when inquiry becomes booking
  10. +
  11. Mark as CLOSED if customer not interested
- +
    -
  1. Click station to open details
  2. -
  3. Update information and save
  4. +
  5. Respond to NEW inquiries within 24 hours
  6. +
  7. Follow up on CONTACTED inquiries regularly
  8. +
  9. Delete spam or duplicate inquiries as needed
- {/* TRAINS */} -
-

🚂 Trains

-

Manage train fleet with coach assignments.

+ {/* HEALTH MONITORING */} +
+

🏥 Health Monitoring

+

Monitor EDR Passenger API health with real-time system status and performance metrics.

-
-

🚂 How-To: Manage Trains

+
+

🏥 How-To: Monitor System Health

- +
    -
  1. Click {`"Trains"`} in Master Data
  2. -
  3. View all trains and coaches
  4. +
  5. Click "Health Monitoring" in Enhanced Features
  6. +
  7. View overall system status banner
  8. +
  9. Check individual probe cards (auto-refreshing)
- +
    -
  1. Click {`"Add Train"`}
  2. -
  3. Enter code and select coaches
  4. +
  5. Liveness: API process alive (30s refresh)
  6. +
  7. Readiness: Database connectivity + latency (30s refresh)
  8. +
  9. App Info: Version, uptime, environment (60s refresh)
- +
    -
  1. Click train to edit
  2. -
  3. Add/remove coaches with position numbers
  4. +
  5. Red status: Check error details and system logs
  6. +
  7. High DB latency: Monitor database performance
  8. +
  9. Failed checks: Verify API server and connections
  10. +
  11. Use "Refresh" button for manual status update
- {/* COACHES */} -
-

🚃 Coaches

-

Manage coach inventory with seat configurations.

+ {/* BOARDING MANAGEMENT */} +
+

🚆 Boarding Management

+

Manage gate operations and passenger boarding processes with real-time tracking.

-
-

🚃 How-To: Manage Coaches

+
+

🚆 How-To: Manage Boarding Operations

- +
    -
  1. Click "Coaches" in Master Data
  2. -
  3. View all coaches and assignments
  4. +
  5. Click "Boarding" in Enhanced Features
  6. +
  7. Select active trip/schedule for boarding
  8. +
  9. View real-time boarding dashboard
- +
    -
  1. Click "Add Coach"
  2. -
  3. Enter code, select train, define seat layout
  4. +
  5. Track total passengers expected vs boarded
  6. +
  7. Monitor boarding progress percentage
  8. +
  9. View gate status and any alerts
- +
    -
  1. Click coach to edit
  2. -
  3. Add seats and assign classes
  4. +
  5. Validate passenger tickets and documents
  6. +
  7. Resolve seat conflicts or issues
  8. +
  9. Process last-minute passengers and no-shows
- {/* SEATS */} -
-

💺 Seats

-

Manage seat inventory with visual maps.

+ {/* ADVANCED FARE CONFIG */} +
+

📊 Advanced Fare Config

+

Configure complex fare rules and dynamic pricing strategies with segment-based pricing.

-
-

💺 How-To: Manage Seats

+
+

📊 How-To: Configure Advanced Fares

- +
    -
  1. Go to "Seats" in Master Data
  2. -
  3. Select coach from dropdown
  4. -
  5. Visual map shows: Green=Available, Red=Blocked
  6. +
  7. Click "Advanced Fare Config" in Enhanced Features
  8. +
  9. Choose between Schedule Fares or Segment Fares
  10. +
  11. View existing fare rules and calculations
- +
    -
  1. Click available seat
  2. -
  3. Click "Block" and select reason
  4. +
  5. Set fare amounts for specific schedules or segments
  6. +
  7. Define passenger categories (ADULT/CHILD) and nationalities
  8. +
  9. Configure validity periods and seasonal adjustments
- +
    -
  1. Click blocked seat
  2. -
  3. Click "Unblock" to restore
  4. +
  5. Apply route segment-specific pricing
  6. +
  7. Set nationality-based rate variations
  8. +
  9. Monitor fare engine integration and real-time calculations
- {/* SEAT CLASSES */} -
-

🎯 Seat Classes

-

Define seat class types with pricing.

+ {/* PAYMENT METHODS */} +
+

💳 Payment Methods

+

Configure and manage payment provider integrations with multi-provider support.

-
-

🎯 How-To: Manage Seat Classes

+
+

💳 How-To: Configure Payment Methods

- +
    -
  1. Click "Seat Classes" in Master Data
  2. -
  3. View all class types
  4. +
  5. Click "Payment Methods" in Enhanced Features
  6. +
  7. View all configured payment providers
  8. +
  9. Check provider status and connectivity
- +
    -
  1. Click "Add Class"
  2. -
  3. Enter name, base fare, premium, insurance
  4. +
  5. Set up API credentials (URLs, keys, merchant IDs)
  6. +
  7. Configure transaction fees and limits
  8. +
  9. Enable/disable specific payment methods
- +
    -
  1. Click class to edit
  2. -
  3. Update fares and save
  4. +
  5. Run test transactions for each provider
  6. +
  7. Validate webhook endpoints and security
  8. +
  9. Monitor API connectivity and error logs
- {/* ROUTES */} -
-

🛤️ Routes

-

Define railway routes with ordered stops.

-
- -
-

🛤️ How-To: Manage Routes

-
- -
    -
  1. Click "Routes" in Master Data
  2. -
  3. View all routes and stops
  4. -
-
- -
    -
  1. Click "Add Route"
  2. -
  3. Enter code and description
  4. -
-
- -
    -
  1. Click route to edit
  2. -
  3. Click "Add Stop" and select station
  4. -
-
-
-
- - {/* SCHEDULES */} -
-

📅 Schedules

-

Create and manage train schedules.

-
- -
-

📅 How-To: Create Schedules

-
- -
    -
  1. Go to "Schedules" in Master Data
  2. -
  3. Click "Create Schedule"
  4. -
  5. Fill train, route, departure/arrival times
  6. -
-
- -
    -
  1. Click "Bulk Generate"
  2. -
  3. Set recurring parameters and generate
  4. -
-
- -
    -
  1. Click schedule to edit
  2. -
  3. Update times and view fares
  4. -
-
-
-
- - {/* PRICING */} -
-

💰 Pricing & Fares

-

Configure dynamic pricing with segments.

-
- -
-

💰 How-To: Configure Pricing

-
- -
    -
  1. Click "Pricing & Fares" in Financial
  2. -
  3. Two tabs: Schedule Fares, Segment Fares
  4. -
-
- -
    -
  1. Click "Add Fare Rule"
  2. -
  3. Fill schedule, seat class, fare, nationality
  4. -
-
- -
    -
  1. Switch to "Segment Fares" tab
  2. -
  3. Select route and add origin/destination fare
  4. -
-
-
-
- - {/* CURRENCIES */} -
-

💵 Currencies

-

Manage exchange rates for multiple currencies.

-
- -
-

💵 How-To: Manage Currencies

-
- -
    -
  1. Click "Currencies" in Financial
  2. -
  3. View all configured rates
  4. -
-
- -
    -
  1. Click "Add Rate"
  2. -
  3. Select currency and enter exchange rate
  4. -
-
- -
    -
  1. Click rate to edit
  2. -
  3. Click "Sync" to update from provider
  4. -
-
-
-
- - {/* PAYMENTS */} -
-

💳 Payments

-

Monitor and process transactions.

-
- -
-

💳 How-To: Manage Payments

-
- -
    -
  1. Click "Payments" in Financial
  2. -
  3. View all transactions
  4. -
-
- -
    -
  1. Search by booking or transaction ID
  2. -
  3. Filter by status and payment method
  4. -
-
- -
    -
  1. Click transaction
  2. -
  3. Click "Refund" if eligible
  4. -
-
-
-
- - {/* PROMOS */} -
-

🎁 Promo Codes

-

Create and manage promotional campaigns.

-
- -
-

🎁 How-To: Manage Promo Codes

-
- -
    -
  1. Click "Promo Codes" in Financial
  2. -
  3. View all active codes
  4. -
-
- -
    -
  1. Click "Add Promo Code"
  2. -
  3. Enter code, discount type, validity dates
  4. -
-
- -
    -
  1. Click code to view analytics
  2. -
  3. View usage count and savings
  4. -
-
-
-
- - {/* LOYALTY */} -
-

🏆 Loyalty

-

Manage loyalty program and rewards.

-
- -
-

🏆 How-To: Manage Loyalty

-
- -
    -
  1. Click "Loyalty Program" in Services
  2. -
  3. View all loyalty accounts
  4. -
-
- -
    -
  1. Click account
  2. -
  3. Click "Adjust Points" and enter amount
  4. -
-
- -
    -
  1. Click account
  2. -
  3. Click "Grant Reward" and select reward
  4. -
-
-
-
- - {/* SUPPORT */} -
-

💬 Support

-

Manage support tickets and conversations.

-
- -
-

💬 How-To: Manage Support

-
- -
    -
  1. Click "Support Center" in Services
  2. -
  3. View all support tickets
  4. -
-
- -
    -
  1. Click ticket to open conversation
  2. -
  3. Add replies and update status
  4. -
-
- -
    -
  1. Go to FAQ management
  2. -
  3. Add or edit FAQ articles
  4. -
-
-
-
- - {/* NOTIFICATIONS */} -
-

🔔 Notifications

-

Send notifications via multiple channels.

-
- -
-

🔔 How-To: Manage Notifications

-
- -
    -
  1. Click "Notifications" in Services
  2. -
  3. View notification history
  4. -
-
- -
    -
  1. Click "Send Notification"
  2. -
  3. Select channel and message
  4. -
-
- -
    -
  1. Go to Templates section
  2. -
  3. Create or edit templates with variables
  4. -
-
-
-
- - {/* AUDIT */} -
-

📋 Audit Logs

-

Monitor system activities and user actions.

-
- -
-

📋 How-To: View Audit Logs

-
- -
    -
  1. Click "Audit Logs" in Security
  2. -
  3. View all recorded activities
  4. -
-
- -
    -
  1. Filter by user, action, or date
  2. -
  3. Search by entity ID
  4. -
-
- -
    -
  1. Click log entry for details
  2. -
  3. Click "Export" to download CSV
  4. -
-
-
-
- - {/* FRAUD */} -
-

🛡️ Fraud Detection

-

Monitor and manage fraud alerts.

-
- -
-

🛡️ How-To: Manage Fraud Detection

-
- -
    -
  1. Click "Fraud Detection" in Security
  2. -
  3. View all fraud alerts
  4. -
-
- -
    -
  1. Click alert to view details
  2. -
  3. Review triggered rules and patterns
  4. -
-
- -
    -
  1. Click "Allow" or "Block" with notes
  2. -
  3. Update user status
  4. -
-
-
-
- - {/* VERIFAYDA */} -
-

✅ Verifayda

-

Verify passenger identities against government database.

-
- -
-

✅ How-To: Manage Verifayda

-
- -
    -
  1. Click "Verifayda Integration" in Security
  2. -
  3. View verification history
  4. -
-
- -
    -
  1. Enter national ID or passport number
  2. -
  3. Click "Verify" to check database
  4. -
-
- -
    -
  1. View verified passenger data
  2. -
  3. Match with booking details
  4. -
-
-
-
- - {/* REPORTS */} -
-

📊 Reports

-

Generate business analytics and reports.

-
- -
-

📊 How-To: Generate Reports

-
- -
    -
  1. Click "Reports" in Analytics
  2. -
  3. View available report types
  4. -
-
- -
    -
  1. Click report type
  2. -
  3. Select date range and parameters
  4. -
-
- -
    -
  1. View report with charts
  2. -
  3. Click "Export" for PDF or CSV
  4. -
-
-
-
- - {/* AGENTS */} -
-

👤 Agents

-

Manage booking agents and commissions.

-
- -
-

👤 How-To: Manage Agents

-
- -
    -
  1. Click "Agents" in System
  2. -
  3. View all agents
  4. -
-
- -
    -
  1. Click "Add Agent"
  2. -
  3. Enter name, email, commission rate
  4. -
-
- -
    -
  1. Click agent to edit
  2. -
  3. Click "Create Shift" to assign schedule
  4. -
-
-
-
- - {/* USERS */} -
-

👥 Users

-

Manage backoffice user accounts and permissions.

-
- -
-

👥 How-To: Manage Users

-
- -
    -
  1. Click "Users" in System
  2. -
  3. View all user accounts
  4. -
-
- -
    -
  1. Click "Add User"
  2. -
  3. Enter email, name, select role
  4. -
-
- -
    -
  1. Click user to edit
  2. -
  3. Adjust roles and permissions
  4. -
-
-
-
- - {/* SETTINGS */} -
-

⚙️ Settings

-

Configure system-wide settings and integrations.

-
- -
-

⚙️ How-To: Configure Settings

-
- -
    -
  1. Click "Settings" in System
  2. -
  3. View configuration options
  4. -
-
- -
    -
  1. Go to Email tab
  2. -
  3. Enter SendGrid API key and email
  4. -
-
- -
    -
  1. Go to API tab
  2. -
  3. Add payment and Verifayda keys
  4. -
-
-
-
@@ -939,11 +547,11 @@ const DocPage = () => {
-

© 2026 Ethio-Djibouti Railway | Passenger Backoffice Documentation v1.0

+

© 2026 Ethio-Djibouti Railway | Passenger Backoffice Documentation v1.0.0

); }; -export default DocPage; +export default DocPage; \ No newline at end of file diff --git a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx index 1eb6b2946..7b5db209a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { RefreshCw, Send } from 'lucide-react'; +import { RefreshCw, Send, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; @@ -54,6 +54,11 @@ export default function ExcessBaggagePage() { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }), }); + const deleteMutation = useMutation({ + mutationFn: (id: string) => excessBaggageApi.delete(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }), + }); + const columns = [ { key: 'booking', label: 'Booking', @@ -120,14 +125,25 @@ export default function ExcessBaggagePage() { onClick: (c: any) => { setWaiveModal(c); setWaiveReason(''); setWaiveError(null); }, show: (c: any) => !['PAID', 'CASH_COLLECTED', 'WAIVED'].includes(c.status), }, + { + label: 'Delete', + icon: Trash2, + variant: 'danger' as const, + onClick: (c: any) => { + if (confirm('Are you sure you want to delete this charge?')) { + deleteMutation.mutate(c.id); + } + }, + show: (c: any) => ['EXPIRED', 'WAIVED'].includes(c.status), + }, ]; return (
-

Excess Baggage

-

Track and manage excess baggage charges at boarding

+

Excess Lugagge

+

Track and manage excess luggage charges at boarding

diff --git a/apps/edr-passenger-web/backoffice/src/app/fare-management/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/fare-management/layout.tsx new file mode 100644 index 000000000..33ccc861e --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/fare-management/layout.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Sidebar from '@/components/layout/Sidebar'; +import Header from '@/components/layout/Header'; +import { useAuthStore } from '@/lib/auth-store'; + +export default function FareManagementLayout({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const { isAuthenticated } = useAuthStore(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const timer = setTimeout(() => { + setIsLoading(false); + }, 100); + + return () => clearTimeout(timer); + }, []); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push('/login'); + } + }, [isAuthenticated, router, isLoading]); + + if (isLoading) { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + if (!isAuthenticated) { + return null; + } + + return ( +
+ +
+
+
+ {children} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/edr-passenger-web/backoffice/src/app/fare-management/page.tsx b/apps/edr-passenger-web/backoffice/src/app/fare-management/page.tsx new file mode 100644 index 000000000..218fbd832 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/fare-management/page.tsx @@ -0,0 +1,535 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Settings, Play, Square, Trash2, TestTube, History, Download } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { apiClient } from '@/lib/api-client'; + +interface FareConfiguration { + id: string; + name: string; + description?: string; + effective_date: string; + expiry_date?: string; + is_active: boolean; + is_default: boolean; + created_by?: string; + approved_by?: string; + approved_at?: string; + created_at: string; + updated_at: string; + rate_rules_count: number; + components_count: number; + age_rules_count: number; +} + +interface SystemStatus { + configurableFaresEnabled: boolean; + rolloutPercentage: number; + totalConfigurations: number; + activeConfiguration: string | null; + activeConfigurationName: string | null; + systemReady: boolean; +} + +interface FareTestResult { + baseFareMinor: number; + componentsTotal: number; + finalTotalMinor: number; + breakdown?: Array<{ + description: string; + runningTotal: number; + }>; +} + +export default function ConfigurableFarePage() { + const [showCreateModal, setShowCreateModal] = useState(false); + const [showTestModal, setShowTestModal] = useState(false); + const [selectedConfig, setSelectedConfig] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; config: FareConfiguration | null }>({ isOpen: false, config: null }); + const queryClient = useQueryClient(); + + // Queries + const { data: configurations = [], isLoading: configsLoading } = useQuery({ + queryKey: ['fare-configurations'], + queryFn: () => apiClient.get('/admin/fare-configurations'), + }); + + const { data: systemStatus } = useQuery({ + queryKey: ['fare-system-status'], + queryFn: () => apiClient.get('/admin/fare-migration/status'), + }); + + // Mutations + const activateMutation = useMutation({ + mutationFn: (id: string) => apiClient.post(`/admin/fare-configurations/${id}/activate`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); + queryClient.invalidateQueries({ queryKey: ['fare-system-status'] }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiClient.delete(`/admin/fare-configurations/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); + setDeleteConfirm({ isOpen: false, config: null }); + }, + }); + + const toggleSystemMutation = useMutation({ + mutationFn: (enabled: boolean) => + enabled + ? apiClient.post('/admin/fare-configurations/system/enable-configurable-fares', { rolloutPercentage: 100 }) + : apiClient.post('/admin/fare-configurations/system/disable-configurable-fares'), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fare-system-status'] }); + }, + }); + + const setupSystemMutation = useMutation({ + mutationFn: () => apiClient.post('/admin/fare-migration/complete-setup', { + activateNewFormula: true, + enableFeature: true + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); + queryClient.invalidateQueries({ queryKey: ['fare-system-status'] }); + }, + }); + + const handleActivate = async (config: FareConfiguration) => { + await activateMutation.mutateAsync(config.id); + }; + + const handleDelete = (config: FareConfiguration) => { + setDeleteConfirm({ isOpen: true, config }); + }; + + const confirmDelete = async () => { + if (deleteConfirm.config) { + await deleteMutation.mutateAsync(deleteConfirm.config.id); + } + }; + + const handleTest = (config: FareConfiguration) => { + setSelectedConfig(config); + setShowTestModal(true); + }; + + const columns = [ + { + key: 'name', + label: 'Configuration Name', + sortable: true, + render: (config: FareConfiguration) => ( +
+
{config.name}
+ {config.description && ( +
{config.description}
+ )} +
+ ), + }, + { + key: 'status', + label: 'Status', + render: (config: FareConfiguration) => ( +
+ + {config.is_active ? 'Active' : 'Inactive'} + + {config.is_default && ( + Default + )} +
+ ), + }, + { + key: 'rules', + label: 'Rules Count', + render: (config: FareConfiguration) => ( +
+
{config.rate_rules_count} rate rules
+
{config.components_count} components
+
{config.age_rules_count} age rules
+
+ ), + }, + { + key: 'dates', + label: 'Validity Period', + render: (config: FareConfiguration) => ( +
+
From: {new Date(config.effective_date).toLocaleDateString()}
+ {config.expiry_date && ( +
Until: {new Date(config.expiry_date).toLocaleDateString()}
+ )} +
+ ), + }, + { + key: 'created_at', + label: 'Created', + sortable: true, + render: (config: FareConfiguration) => ( +
+
{new Date(config.created_at).toLocaleDateString()}
+ {config.created_by && ( +
by {config.created_by}
+ )} +
+ ), + }, + ]; + + const actions = [ + { + label: 'Activate', + onClick: handleActivate, + variant: 'secondary' as const, + icon: Play, + show: (config: FareConfiguration) => !config.is_active, + }, + { + label: 'Test', + onClick: handleTest, + variant: 'secondary' as const, + icon: TestTube, + }, + { + label: 'Delete', + onClick: handleDelete, + variant: 'danger' as const, + icon: Trash2, + show: (config: FareConfiguration) => !config.is_active, + }, + ]; + + return ( +
+
+
+

Configurable Fare Management

+

+ Manage dynamic fare configurations with flexible rules, components, and pricing +

+
+
+ setupSystemMutation.mutate()} + loading={setupSystemMutation.isPending} + disabled={systemStatus?.systemReady} + > + {systemStatus?.systemReady ? 'System Ready' : 'Setup System'} + + setShowCreateModal(true)} + > + New Configuration + +
+
+ + {/* System Status */} +
+
+
+
+
System Status
+
+ {systemStatus?.systemReady ? 'Ready' : 'Setup Required'} +
+
+ + {systemStatus?.configurableFaresEnabled ? 'Enabled' : 'Disabled'} + +
+
+ +
+
Total Configurations
+
{systemStatus?.totalConfigurations || 0}
+
+ +
+
Rollout Percentage
+
{systemStatus?.rolloutPercentage || 0}%
+
+ +
+
Active Configuration
+
+ {systemStatus?.activeConfigurationName || 'None'} +
+
+
+ + {/* System Controls */} +
+
+
+

System Control

+

+ Enable or disable the configurable fare system globally +

+
+
+ + {systemStatus?.configurableFaresEnabled ? 'System Enabled' : 'Using Legacy System'} + + toggleSystemMutation.mutate(!systemStatus?.configurableFaresEnabled)} + loading={toggleSystemMutation.isPending} + icon={systemStatus?.configurableFaresEnabled ? Square : Play} + > + {systemStatus?.configurableFaresEnabled ? 'Disable' : 'Enable'} + +
+
+
+ + {/* Configurations Table */} +
+
+

Fare Configurations

+

+ Manage fare calculation configurations with custom rates, components, and age-based pricing +

+
+ + +
+ + {/* Delete Confirmation */} + setDeleteConfirm({ isOpen: false, config: null })} + onConfirm={confirmDelete} + title="Delete Configuration" + message={`Are you sure you want to delete "${deleteConfirm.config?.name}"? This action cannot be undone.`} + confirmText="Delete" + isDanger={true} + isLoading={deleteMutation.isPending} + warning="Active configurations cannot be deleted. Deactivate first if needed." + /> + + {/* Test Modal */} + {showTestModal && selectedConfig && ( + { + setShowTestModal(false); + setSelectedConfig(null); + }} + /> + )} + + {/* Create/Edit Modal */} + {showCreateModal && ( + setShowCreateModal(false)} + onSuccess={() => { + setShowCreateModal(false); + queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); + }} + /> + )} +
+ ); +} + +// Test Modal Component +function FareTestModal({ + configuration, + isOpen, + onClose +}: { + configuration: FareConfiguration; + isOpen: boolean; + onClose: () => void; +}) { + const [testData, setTestData] = useState({ + distanceKm: 100, + nationality: 'Ethiopian', + coachType: 'REGULAR_SEAT', + bedPosition: '', + adultCount: 2, + childCount: 1, + }); + + const testMutation = useMutation({ + mutationFn: () => apiClient.post(`/admin/fare-configurations/${configuration.id}/test`, testData), + }); + + const handleTest = () => { + testMutation.mutate(); + }; + + return ( + +
+
+
+ + setTestData({ ...testData, distanceKm: +e.target.value })} + /> +
+
+ + +
+
+ + +
+ {(testData.coachType === 'ECONOMY_BED' || testData.coachType === 'VIP_BED') && ( +
+ + +
+ )} +
+ + setTestData({ ...testData, adultCount: +e.target.value })} + /> +
+
+ + setTestData({ ...testData, childCount: +e.target.value })} + /> +
+
+ + + Calculate Fare + + + {testMutation.data && ( +
+

Calculation Result

+
+
+ Base Fare: + {(testMutation.data.baseFareMinor / 100).toFixed(2)} ETB +
+
+ Components: + {(testMutation.data.componentsTotal / 100).toFixed(2)} ETB +
+
+ Total: + {(testMutation.data.finalTotalMinor / 100).toFixed(2)} ETB +
+
+ + {testMutation.data.breakdown && ( +
+
Calculation Breakdown:
+
+ {testMutation.data.breakdown.map((step: any, index: number) => ( +
+ {step.description} + {(step.runningTotal / 100).toFixed(2)} ETB +
+ ))} +
+
+ )} +
+ )} + + {testMutation.error && ( +
+ {(testMutation.error as any)?.response?.data?.message || 'Test failed'} +
+ )} +
+
+ ); +} + +// Create Configuration Form Modal +function ConfigurationFormModal({ + isOpen, + onClose, + onSuccess +}: { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +}) { + return ( + +
+

Configuration Form

+

+ This would contain a comprehensive form for creating fare configurations with rate rules, components, and age pricing. +

+ + Close for Now + +
+
+ ); +} \ No newline at end of file diff --git a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx index d9c9b6220..b2c90d275 100644 --- a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx @@ -143,17 +143,9 @@ export default function LoginPage() { {/* Password field */}
-
- - -
+
{ + // Auth is already initialized in root providers + // Just wait a tick for hydration + const timer = setTimeout(() => { + setIsLoading(false); + }, 100); + + return () => clearTimeout(timer); + }, []); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push('/login'); + } + }, [isAuthenticated, router, isLoading]); + + if (isLoading) { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + if (!isAuthenticated) { + return null; + } + + return ( +
+ +
+
+
+ {children} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx b/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx new file mode 100644 index 000000000..ae3b20837 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx @@ -0,0 +1,421 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Edit2, Trash2 } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { apiClient, paymentsApi } from '@/lib/api'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { usePermission } from '@/lib/use-permission'; +import { PERMS } from '@/lib/permissions'; + +export default function PaymentMethodsPage() { + const canManagePayments = usePermission(PERMS.payments.manage); + const canManageAdmin = usePermission(PERMS.admin); + const canManage = canManagePayments || canManageAdmin; + const [createModalOpen, setCreateModalOpen] = useState(false); + const [editModalOpen, setEditModalOpen] = useState(false); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [selectedMethod, setSelectedMethod] = useState(null); + const [successMessage, setSuccessMessage] = useState(''); + const [formData, setFormData] = useState({ + name: '', + type: 'TELEBIRR', + region: 'ETHIOPIA', + currency: 'ETB', + isEnabled: true, + displayOrder: 1, + description: '', + fees: '', + processingTime: '' + }); + + const queryClient = useQueryClient(); + + const { data, isLoading, error } = useQuery({ + queryKey: ['payment-methods'], + queryFn: () => paymentsApi.getMethods(), + }); + + const createMutation = useMutation({ + mutationFn: (data: any) => paymentsApi.addMethod(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['payment-methods'] }); + setCreateModalOpen(false); + resetForm(); + setSuccessMessage('Payment method added successfully'); + setTimeout(() => setSuccessMessage(''), 3000); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, ...data }: any) => paymentsApi.updateMethod(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['payment-methods'] }); + queryClient.refetchQueries({ queryKey: ['payment-methods'] }); + setEditModalOpen(false); + setSelectedMethod(null); + resetForm(); + setSuccessMessage('Payment method updated successfully'); + setTimeout(() => setSuccessMessage(''), 3000); + }, + onError: (error) => { + console.error('Update failed:', error); + setSuccessMessage('Failed to update payment method'); + setTimeout(() => setSuccessMessage(''), 3000); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => paymentsApi.deleteMethod(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['payment-methods'] }); + setDeleteConfirmOpen(false); + setSelectedMethod(null); + setSuccessMessage('Payment method deleted successfully'); + setTimeout(() => setSuccessMessage(''), 3000); + }, + }); + + const resetForm = () => { + setFormData({ + name: '', + type: 'TELEBIRR', + region: 'ETHIOPIA', + currency: 'ETB', + isEnabled: true, + displayOrder: 1, + description: '', + fees: '', + processingTime: '' + }); + }; + + const handleEdit = (method: any) => { + setSelectedMethod(method); + setFormData({ + name: method.displayName || method.name || '', + type: method.type || 'TELEBIRR', + region: method.region || 'ETHIOPIA', + currency: method.currency || 'ETB', + isEnabled: method.enabled ?? method.isEnabled ?? true, + displayOrder: method.sortOrder ?? method.displayOrder ?? 1, + description: method.description || '', + fees: method.fees || '', + processingTime: method.processingTime || '' + }); + setEditModalOpen(true); + }; + + const handleDelete = (method: any) => { + setSelectedMethod(method); + setDeleteConfirmOpen(true); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const submitData = { + displayName: formData.name, + type: formData.type, + region: formData.region, + currency: formData.currency, + enabled: formData.isEnabled, + sortOrder: formData.displayOrder, + // Additional fields that might be expected + description: formData.description, + fees: formData.fees, + processingTime: formData.processingTime, + }; + + console.log('Submitting data:', submitData); + + if (selectedMethod) { + updateMutation.mutate({ id: selectedMethod.id, ...submitData }); + } else { + createMutation.mutate(submitData); + } + }; + + const columns = [ + { + key: 'displayName', + label: 'Name', + sortable: true, + render: (method: any) => ( +
+
{method.displayName || method.name}
+
{method.type}
+
+ ), + }, + { + key: 'region', + label: 'Region', + render: (method: any) => ( + {method.region} + ), + }, + { + key: 'currency', + label: 'Currency', + render: (method: any) => ( + {method.currency} + ), + }, + { + key: 'enabled', + label: 'Status', + render: (method: any) => ( + + {(method.enabled ?? method.isEnabled) ? 'Enabled' : 'Disabled'} + + ), + }, + { + key: 'sortOrder', + label: 'Order', + render: (method: any) => ( + {method.sortOrder ?? method.displayOrder} + ), + }, + ]; + + const actions = [ + { + label: 'Edit', + onClick: handleEdit, + variant: 'secondary' as const, + icon: Edit2, + show: () => canManage, + }, + { + label: 'Delete', + onClick: handleDelete, + variant: 'danger' as const, + icon: Trash2, + show: () => canManage, + }, + ]; + + const paymentTypes = [ + { value: 'TELEBIRR', label: 'Telebirr' }, + { value: 'CBE_BIRR', label: 'CBE Birr' }, + { value: 'EBIRR', label: 'eBirr' }, + { value: 'WAAFI', label: 'Waafi' }, + { value: 'CARD', label: 'Card Payment' }, + { value: 'WALLET', label: 'Internal Wallet' }, + ]; + + const regions = [ + { value: 'ETHIOPIA', label: 'Ethiopia' }, + { value: 'DJIBOUTI', label: 'Djibouti' }, + { value: 'INTERNATIONAL', label: 'International' }, + ]; + + const currencies = [ + { value: 'ETB', label: 'Ethiopian Birr (ETB)' }, + { value: 'DJF', label: 'Djiboutian Franc (DJF)' }, + { value: 'USD', label: 'US Dollar (USD)' }, + ]; + + return ( +
+
+
+

Payment Methods

+

Manage supported payment systems

+
+ + setCreateModalOpen(true)}> + Add Method + + +
+ + {successMessage && ( +
+ ✓ {successMessage} +
+ )} + + {error && ( +
+ Error loading payment methods: {error instanceof Error ? error.message : 'Unknown error'} +
+ )} + + + + { + setCreateModalOpen(false); + setEditModalOpen(false); + setSelectedMethod(null); + resetForm(); + }} + title={selectedMethod ? 'Edit Payment Method' : 'Add Payment Method'} + size="md" + > +
+
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g., Telebirr Mobile Money" + required + /> +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + setFormData({ ...formData, displayOrder: parseInt(e.target.value) || 1 })} + min="1" + /> +
+
+ +
+ +