mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into contrat-backup2
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
"seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts",
|
||||
"seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",
|
||||
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
|
||||
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
|
||||
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
|
||||
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
||||
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
|
||||
|
||||
@@ -59,6 +59,7 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k
|
||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||
import { PaidIndodeDemoBookingsSeeder } from "./seed/paid-indode-demo-bookings.seeder";
|
||||
//New Trains, Wagons, Container and Cargo management modules
|
||||
import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { WagonsModule } from './modules/wagons/wagons.module';
|
||||
@@ -160,6 +161,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
|
||||
ExportDjiboutiInterchangeDemoSeeder,
|
||||
MarshallingDemoTrainsSeeder,
|
||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||
PaidIndodeDemoBookingsSeeder,
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
@@ -178,6 +180,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
||||
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
|
||||
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
|
||||
private readonly paidIndodeDemoBookingsSeeder: PaidIndodeDemoBookingsSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
private readonly govCompaniesSeeder: GovCompaniesSeeder,
|
||||
@@ -199,6 +202,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
await this.warehouseDemoSeeder.run();
|
||||
await this.exportDjiboutiInterchangeDemoSeeder.run();
|
||||
await this.marshallingDemoTrainsSeeder.run();
|
||||
await this.paidIndodeDemoBookingsSeeder.run();
|
||||
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
|
||||
// Each block self-guards on an empty-table check, so this is safe every boot.
|
||||
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
|
||||
|
||||
@@ -116,6 +116,7 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
|
||||
freightMigrationsGlob,
|
||||
],
|
||||
migrationsRun: true,
|
||||
migrationsTransactionMode: "each",
|
||||
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
|
||||
synchronize: false,
|
||||
logging:
|
||||
|
||||
@@ -56,6 +56,7 @@ export class AddWarehouseAllocationAndFeeRules1791000000000 implements Migration
|
||||
{ name: 'zone_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'free_days', type: 'int', default: 0 },
|
||||
{ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 },
|
||||
{ name: 'tiers', type: 'jsonb', default: "'[]'" },
|
||||
{ name: 'currency', type: 'varchar', length: '8', default: "'USD'" },
|
||||
{ name: 'is_active', type: 'boolean', default: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
|
||||
@@ -62,20 +62,96 @@ export class CreateInvoices1821000000002 implements MigrationInterface {
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`,
|
||||
`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS id uuid DEFAULT uuid_generate_v4(),
|
||||
ADD COLUMN IF NOT EXISTS invoice_number varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS company_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS company_profile_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS total_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS currency varchar(8) NOT NULL DEFAULT 'ETB',
|
||||
ADD COLUMN IF NOT EXISTS status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT',
|
||||
ADD COLUMN IF NOT EXISTS source varchar(255),
|
||||
ADD COLUMN IF NOT EXISTS source_id varchar(255),
|
||||
ADD COLUMN IF NOT EXISTS type varchar(255),
|
||||
ADD COLUMN IF NOT EXISTS issued_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS payment_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS due_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(),
|
||||
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
|
||||
`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.invoices
|
||||
SET due_at = COALESCE(due_at, issued_at, created_at, now())
|
||||
WHERE due_at IS NULL;
|
||||
`);
|
||||
await queryRunner.query(`ALTER TABLE freight.invoices ALTER COLUMN due_at SET NOT NULL;`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE contype = 'p'
|
||||
AND conrelid = 'freight.invoices'::regclass
|
||||
) THEN
|
||||
ALTER TABLE freight.invoices ADD CONSTRAINT pk_invoices PRIMARY KEY (id);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'uq_invoices_invoice_number'
|
||||
AND conrelid = 'freight.invoices'::regclass
|
||||
) THEN
|
||||
ALTER TABLE freight.invoices ADD CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'fk_invoices_company'
|
||||
AND conrelid = 'freight.invoices'::regclass
|
||||
) THEN
|
||||
ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_company
|
||||
FOREIGN KEY (company_id) REFERENCES freight.companies (id) ON DELETE RESTRICT;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'fk_invoices_company_profile'
|
||||
AND conrelid = 'freight.invoices'::regclass
|
||||
) THEN
|
||||
ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_company_profile
|
||||
FOREIGN KEY (company_profile_id) REFERENCES freight.company_profiles (id) ON DELETE RESTRICT;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'fk_invoices_payment'
|
||||
AND conrelid = 'freight.invoices'::regclass
|
||||
) THEN
|
||||
ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_payment
|
||||
FOREIGN KEY (payment_id) REFERENCES freight.payments (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_invoices_company ON freight.invoices (company_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_invoices_company_profile ON freight.invoices (company_profile_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_invoices_source ON freight.invoices (source, source_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_invoices_status ON freight.invoices (status);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_invoices_status ON freight.invoices (status);`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.invoice_lines (
|
||||
CREATE TABLE IF NOT EXISTS freight.invoice_lines (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
invoice_id uuid NOT NULL,
|
||||
charge_type varchar NOT NULL,
|
||||
@@ -95,7 +171,7 @@ export class CreateInvoices1821000000002 implements MigrationInterface {
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,31 @@ export class CentralizeWarehouseInvoices1829000000000 implements MigrationInterf
|
||||
name = 'CentralizeWarehouseInvoices1829000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'freight'
|
||||
AND table_name = 'invoices'
|
||||
AND column_name = 'booking_id'
|
||||
) THEN
|
||||
ALTER TABLE freight.invoices ALTER COLUMN booking_id DROP NOT NULL;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'freight'
|
||||
AND table_name = 'invoices'
|
||||
AND column_name = 'amount'
|
||||
) THEN
|
||||
ALTER TABLE freight.invoices ALTER COLUMN amount DROP NOT NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// 1. Invoice headers. Keep the same id so items still link, and so any
|
||||
// external reference to the invoice id stays valid.
|
||||
await queryRunner.query(`
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddWarehouseFeeRuleTiers1831000000000 implements MigrationInterface {
|
||||
name = 'AddWarehouseFeeRuleTiers1831000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_fee_rules
|
||||
ADD COLUMN IF NOT EXISTS tiers jsonb NOT NULL DEFAULT '[]';
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_fee_rules
|
||||
DROP COLUMN IF EXISTS tiers;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddCustomerTruckAssignmentToBookings1832000000000 implements MigrationInterface {
|
||||
name = 'AddCustomerTruckAssignmentToBookings1832000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_plate_number varchar(32),
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_driver_name varchar(120),
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_type varchar(60),
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_container_number varchar(16),
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_assigned_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_arrived_at timestamptz
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS customer_truck_arrived_at,
|
||||
DROP COLUMN IF EXISTS customer_truck_assigned_at,
|
||||
DROP COLUMN IF EXISTS customer_truck_container_number,
|
||||
DROP COLUMN IF EXISTS customer_truck_type,
|
||||
DROP COLUMN IF EXISTS customer_truck_driver_name,
|
||||
DROP COLUMN IF EXISTS customer_truck_plate_number
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -867,6 +867,11 @@ export class BillingService {
|
||||
);
|
||||
}
|
||||
|
||||
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
|
||||
if (!(amountDue > 0)) {
|
||||
throw new BadRequestException("Invoice has no outstanding balance.");
|
||||
}
|
||||
|
||||
const result = await this.payment.initiate({
|
||||
referenceId: invoice.sourceId,
|
||||
source: invoice.source,
|
||||
|
||||
@@ -58,10 +58,11 @@ import {
|
||||
RequestOperationDto,
|
||||
OperationReviewDto,
|
||||
StaffRejectDto,
|
||||
} from "./dto/request-changes.dto";
|
||||
import { ContractViewDto } from "./dto/contract-view.dto";
|
||||
import { SignContractDto } from "./dto/sign-contract.dto";
|
||||
import { UpdateBookingDto } from "./dto/update-booking.dto";
|
||||
} from './dto/request-changes.dto';
|
||||
import { ContractViewDto } from './dto/contract-view.dto';
|
||||
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||
import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
@@ -275,7 +276,40 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(":id/tracking")
|
||||
@Post(':id/customer-truck-assignment')
|
||||
@ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' })
|
||||
async assignCustomerTruck(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CustomerTruckAssignmentDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
const assigned = await this.bookingsService.assignCustomerTruck(id, dto);
|
||||
return this.transitionService.enrichBookingResponse(assigned);
|
||||
}
|
||||
|
||||
@Get(':id/customer-truck-assignment/freight-order')
|
||||
@ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' })
|
||||
async customerTruckFreightOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
const { filename, buffer } =
|
||||
await this.bookingsService.customerTruckFreightOrderCopies(id);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Get(':id/tracking')
|
||||
@ApiOperation({
|
||||
summary: "Shipment tracking timeline for a booking",
|
||||
description:
|
||||
|
||||
@@ -45,6 +45,8 @@ import {
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
|
||||
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
|
||||
export interface PaginatedBookings {
|
||||
@@ -92,8 +94,62 @@ export class BookingsService {
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
private readonly contractPdfService: ContractPdfService,
|
||||
) {}
|
||||
|
||||
async assignCustomerTruck(
|
||||
bookingId: string,
|
||||
dto: CustomerTruckAssignmentDto,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.findById(bookingId);
|
||||
const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim());
|
||||
const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim());
|
||||
const usesMileService =
|
||||
booking.tradeDirection === 'IMPORT'
|
||||
? hasLastMile
|
||||
: booking.tradeDirection === 'EXPORT'
|
||||
? hasFirstMile
|
||||
: hasFirstMile || hasLastMile;
|
||||
if (usesMileService) {
|
||||
throw new BadRequestException(
|
||||
'Customer truck assignment is only allowed when first/last mile delivery is not selected',
|
||||
);
|
||||
}
|
||||
if (booking.customerTruckAssignedAt) {
|
||||
throw new ConflictException('Customer truck assignment is already submitted and locked');
|
||||
}
|
||||
if (booking.paymentStatus !== 'PAID') {
|
||||
throw new BadRequestException('Booking must be paid before assigning an external customer truck');
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'TRUCK_ASSIGNED',
|
||||
customerTruckPlateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
||||
customerTruckDriverName: dto.driverName.trim(),
|
||||
customerTruckType: dto.truckType.trim(),
|
||||
customerTruckContainerNumber: dto.containerNumberToLoad.trim().toUpperCase(),
|
||||
customerTruckAssignedAt: new Date(),
|
||||
});
|
||||
|
||||
return this.findById(bookingId);
|
||||
}
|
||||
|
||||
async customerTruckFreightOrderCopies(
|
||||
bookingId: string,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const booking = await this.findById(bookingId);
|
||||
if (!booking.customerTruckAssignedAt) {
|
||||
throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated');
|
||||
}
|
||||
|
||||
const html = this.buildCustomerTruckFreightOrderHtml(booking);
|
||||
const buffer = await this.contractPdfService.htmlToPdfBuffer(html);
|
||||
return {
|
||||
filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||
buffer,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve trade direction from yard countries; reject client mismatch. */
|
||||
private async resolveTradeDirectionForBooking(
|
||||
originYardId: string,
|
||||
@@ -131,6 +187,79 @@ export class BookingsService {
|
||||
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
private buildCustomerTruckFreightOrderHtml(booking: Booking): string {
|
||||
const assignedAt = booking.customerTruckAssignedAt
|
||||
? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB')
|
||||
: '-';
|
||||
const rows: Array<[string, string | null | undefined]> = [
|
||||
['Booking Reference', booking.reference],
|
||||
['Client Name', booking.company?.name],
|
||||
['Client ID', booking.companyId],
|
||||
['Trade Direction', booking.tradeDirection],
|
||||
['Freight Type', booking.freightType],
|
||||
['Truck Plate Number', booking.customerTruckPlateNumber],
|
||||
['Driver Name', booking.customerTruckDriverName],
|
||||
['Truck Type', booking.customerTruckType],
|
||||
['Container Number to Load', booking.customerTruckContainerNumber],
|
||||
['Assigned At', assignedAt],
|
||||
['Booking Status', booking.status],
|
||||
];
|
||||
const rowHtml = rows
|
||||
.map(([label, value]) => `<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`)
|
||||
.join('');
|
||||
const copy = (watermark: string) => `
|
||||
<section class="copy">
|
||||
<div class="watermark">${this.escapeHtml(watermark)}</div>
|
||||
<header>
|
||||
<div>
|
||||
<h1>Freight Order</h1>
|
||||
<p>Customer external truck assignment</p>
|
||||
</div>
|
||||
<strong>${this.escapeHtml(booking.reference)}</strong>
|
||||
</header>
|
||||
<table>${rowHtml}</table>
|
||||
<div class="signatures">
|
||||
<div>Customer / Carrier Signature</div>
|
||||
<div>Port Operations Verification</div>
|
||||
<div>Gate Security Verification</div>
|
||||
</div>
|
||||
</section>`;
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; color: #10202f; margin: 0; }
|
||||
.copy { position: relative; min-height: 46vh; padding: 28px 32px; page-break-inside: avoid; border-bottom: 1px dashed #94a3b8; }
|
||||
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; font-weight: 800; color: rgba(16, 32, 47, 0.08); transform: rotate(-18deg); pointer-events: none; }
|
||||
header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; }
|
||||
h1 { margin: 0; font-size: 28px; letter-spacing: 0; }
|
||||
p { margin: 4px 0 0; color: #64748b; }
|
||||
strong { font-size: 16px; color: #0a9f6a; }
|
||||
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; }
|
||||
th { width: 34%; background: #f1f5f9; }
|
||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; }
|
||||
.signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${copy('Copy 1: Port Operations Copy')}
|
||||
${copy('Copy 2: Gate Security & Carrier Copy')}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** Build evaluation input from booking freight shape. */
|
||||
/**
|
||||
* Whether a service type bundles customs clearance. This is the single source
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { IsIn, IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
export const CUSTOMER_TRUCK_TYPES = [
|
||||
'Flatbed',
|
||||
'Container Chassis',
|
||||
'Lowboy',
|
||||
'Box Truck',
|
||||
'Tipper',
|
||||
] as const;
|
||||
|
||||
export class CustomerTruckAssignmentDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(32)
|
||||
truckPlateNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
driverName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(CUSTOMER_TRUCK_TYPES)
|
||||
truckType!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(16)
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
message: 'containerNumberToLoad must match ISO container format, e.g. ABCD1234567',
|
||||
})
|
||||
containerNumberToLoad!: string;
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export const BOOKING_STATUSES = [
|
||||
// Road (truck) drawdown orders skip the train batch pool and wait here for
|
||||
// truck dispatch after Marketing accepts; billed by KM, not wagons.
|
||||
'ROAD_DISPATCH_PENDING',
|
||||
'TRUCK_ASSIGNED',
|
||||
'OPERATION_REQUESTED',
|
||||
// Operations review gate: customer picks a schedule day and submits the
|
||||
// operation request; the operations team reviews capacity/docs/route before
|
||||
@@ -260,6 +261,24 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'last_mile_delivery_lng', type: 'numeric', precision: 10, scale: 7, nullable: true })
|
||||
lastMileDeliveryLng?: number | null;
|
||||
|
||||
@Column({ name: 'customer_truck_plate_number', type: 'varchar', length: 32, nullable: true })
|
||||
customerTruckPlateNumber?: string | null;
|
||||
|
||||
@Column({ name: 'customer_truck_driver_name', type: 'varchar', length: 120, nullable: true })
|
||||
customerTruckDriverName?: string | null;
|
||||
|
||||
@Column({ name: 'customer_truck_type', type: 'varchar', length: 60, nullable: true })
|
||||
customerTruckType?: string | null;
|
||||
|
||||
@Column({ name: 'customer_truck_container_number', type: 'varchar', length: 16, nullable: true })
|
||||
customerTruckContainerNumber?: string | null;
|
||||
|
||||
@Column({ name: 'customer_truck_assigned_at', type: 'timestamptz', nullable: true })
|
||||
customerTruckAssignedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true })
|
||||
customerTruckArrivedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false })
|
||||
customsClearingEnabled!: boolean;
|
||||
|
||||
|
||||
@@ -131,6 +131,12 @@ export class LastMileService {
|
||||
}
|
||||
|
||||
async create(dto: CreateLastMileDto): Promise<LastMile> {
|
||||
const [existing] = await this.lastMileRepository.findAll({
|
||||
where: { bookingId: dto.bookingId },
|
||||
take: 1,
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
return this.lastMileRepository.create({
|
||||
bookingId: dto.bookingId,
|
||||
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||
import { IsDateString, IsIn, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export const IMPORT_DJIBOUTI_DOCUMENT_TYPES = [
|
||||
'GATE_PASS',
|
||||
'DELIVERY_ORDER',
|
||||
'PORT_INVOICE',
|
||||
'DJIBOUTI_T1',
|
||||
@@ -43,6 +44,26 @@ export class UploadImportDjiboutiDocumentDto {
|
||||
}
|
||||
|
||||
export class ImportDjiboutiActionDto {
|
||||
@ApiPropertyOptional({ description: 'Gate pass secured date/time. Defaults to now.' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
securedAt?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fileId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fileUrl?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reference?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm';
|
||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
|
||||
export type ImportDjiboutiDocumentType =
|
||||
| 'GATE_PASS'
|
||||
| 'DELIVERY_ORDER'
|
||||
| 'PORT_INVOICE'
|
||||
| 'DJIBOUTI_T1'
|
||||
|
||||
@@ -54,7 +54,6 @@ import {
|
||||
type ImportDjiboutiDocumentType,
|
||||
} from './entities/import-djibouti-operation.entity';
|
||||
import {
|
||||
IMPORT_DJIBOUTI_DOCUMENT_TYPES,
|
||||
ImportDjiboutiActionDto,
|
||||
UploadImportDjiboutiDocumentDto,
|
||||
} from './dto/import-djibouti-operation.dto';
|
||||
@@ -832,7 +831,7 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
async getImportDjiboutiOperation(scheduleId: string) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
return this.mapImportDjiboutiOperation(schedule, operation);
|
||||
}
|
||||
@@ -841,7 +840,7 @@ export class TrainSchedulingService {
|
||||
scheduleId: string,
|
||||
dto: UploadImportDjiboutiDocumentDto,
|
||||
) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
const documents = {
|
||||
...(operation.documents ?? {}),
|
||||
@@ -865,21 +864,30 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
async grantImportDjiboutiGatepass(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
const missing = this.missingImportDjiboutiDocuments(operation);
|
||||
if (missing.length) {
|
||||
throw new BadRequestException(`Gatepass cannot be granted until documents are uploaded: ${missing.join(', ')}`);
|
||||
const securedAt = dto.securedAt ? new Date(dto.securedAt) : new Date();
|
||||
const documents = { ...(operation.documents ?? {}) };
|
||||
if (dto.fileId || dto.fileUrl || dto.reference || dto.notes) {
|
||||
documents.GATE_PASS = {
|
||||
fileId: dto.fileId ?? null,
|
||||
fileUrl: dto.fileUrl ?? null,
|
||||
reference: dto.reference ?? null,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
uploadedBy: dto.performedBy ?? null,
|
||||
notes: dto.notes ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
gatepassGrantedAt: operation.gatepassGrantedAt ?? new Date(),
|
||||
documents,
|
||||
gatepassGrantedAt: securedAt,
|
||||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||||
notes: dto.notes ?? operation.notes ?? null,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[NOTIFY] Import gatepass granted for train ${schedule.trainNumber ?? schedule.id}; loading may proceed.`,
|
||||
`[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`,
|
||||
);
|
||||
return this.getImportDjiboutiOperation(schedule.id);
|
||||
}
|
||||
@@ -1299,16 +1307,28 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
private async getImportDjiboutiSchedule(scheduleId: string): Promise<TrainSchedule> {
|
||||
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
|
||||
if (!this.isImportDjiboutiSchedule(schedule)) {
|
||||
throw new BadRequestException('This action applies only to IMPORT schedules originating from Djibouti');
|
||||
}
|
||||
return schedule;
|
||||
}
|
||||
|
||||
private async getDjiboutiGatepassSchedule(scheduleId: string): Promise<TrainSchedule> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (!this.isImportDjiboutiSchedule(schedule)) {
|
||||
throw new BadRequestException('Batch 7 actions apply only to IMPORT schedules originating from Djibouti');
|
||||
if (!this.isDjiboutiGatepassSchedule(schedule)) {
|
||||
throw new BadRequestException('Gate pass applies only to trains entering Djibouti Port on import or export routes');
|
||||
}
|
||||
return schedule;
|
||||
}
|
||||
|
||||
private isDjiboutiGatepassSchedule(schedule: TrainSchedule): boolean {
|
||||
return this.isImportDjiboutiSchedule(schedule) || this.isExportDjiboutiSchedule(schedule);
|
||||
}
|
||||
|
||||
private isImportDjiboutiSchedule(schedule: TrainSchedule): boolean {
|
||||
const direction =
|
||||
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
|
||||
@@ -1323,6 +1343,20 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
|
||||
private isExportDjiboutiSchedule(schedule: TrainSchedule): boolean {
|
||||
const direction =
|
||||
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
|
||||
(schedule.originStation && schedule.destinationStation
|
||||
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
|
||||
: null);
|
||||
return (
|
||||
direction === 'EXPORT' &&
|
||||
this.isDjiboutiPortDestination(
|
||||
`${schedule.destinationStation?.code ?? ''} ${schedule.destinationStation?.label ?? ''}`,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async getOrCreateImportDjiboutiOperation(scheduleId: string): Promise<ImportDjiboutiOperation> {
|
||||
const repo = this.dataSource.getRepository(ImportDjiboutiOperation);
|
||||
const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } });
|
||||
@@ -1331,8 +1365,8 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
private missingImportDjiboutiDocuments(operation?: ImportDjiboutiOperation | null): ImportDjiboutiDocumentType[] {
|
||||
const documents = operation?.documents ?? {};
|
||||
return IMPORT_DJIBOUTI_DOCUMENT_TYPES.filter((type) => !documents[type]);
|
||||
void operation;
|
||||
return [];
|
||||
}
|
||||
|
||||
private assertImportDjiboutiGatepassGranted(operation?: ImportDjiboutiOperation | null): void {
|
||||
@@ -1343,6 +1377,7 @@ export class TrainSchedulingService {
|
||||
|
||||
private mapImportDjiboutiOperation(schedule: TrainSchedule, operation: ImportDjiboutiOperation) {
|
||||
const missingDocuments = this.missingImportDjiboutiDocuments(operation);
|
||||
const gatepassStatus = operation.gatepassGrantedAt ? 'SECURED' : 'NOT_SECURED';
|
||||
return {
|
||||
trainScheduleId: schedule.id,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
@@ -1350,6 +1385,7 @@ export class TrainSchedulingService {
|
||||
status: {
|
||||
documentsComplete: missingDocuments.length === 0,
|
||||
missingDocuments,
|
||||
gatepassStatus,
|
||||
gatepassGranted: Boolean(operation.gatepassGrantedAt),
|
||||
readyForLoading: Boolean(operation.readyForLoadingAt),
|
||||
loadedOnTrain: Boolean(operation.loadedOnTrainAt),
|
||||
@@ -1358,6 +1394,8 @@ export class TrainSchedulingService {
|
||||
},
|
||||
documents: operation.documents ?? {},
|
||||
gatepassGrantedAt: operation.gatepassGrantedAt ?? null,
|
||||
gatepassSecuredAt: operation.gatepassGrantedAt ?? null,
|
||||
gatepassStatus,
|
||||
readyForLoadingAt: operation.readyForLoadingAt ?? null,
|
||||
loadedOnTrainAt: operation.loadedOnTrainAt ?? null,
|
||||
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayNotEmpty, IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||
import { ArrayNotEmpty, IsArray, IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||
import { ValidateNested } from 'class-validator';
|
||||
|
||||
export class TruckEntranceDto {
|
||||
@@ -90,6 +90,11 @@ export class TruckEntranceDto {
|
||||
@Min(0)
|
||||
grossWeightKg?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Whether the customer truck was weighed at receipt.' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
weighingRequired?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@@ -135,10 +140,11 @@ export class TruckEntranceDto {
|
||||
@IsString()
|
||||
truckType?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
entranceTareWeightKg!: number;
|
||||
entranceTareWeightKg?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min, ValidateNested } from 'class-validator';
|
||||
|
||||
import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity';
|
||||
|
||||
export class FeeRuleTierDto {
|
||||
@ApiProperty({ example: 4 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
fromDay!: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 4, description: 'Inclusive. Leave empty for an open-ended tier.' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
toDay?: number | null;
|
||||
|
||||
@ApiProperty({ example: 2500 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
ratePerDay!: number;
|
||||
}
|
||||
|
||||
export class CreateFeeRuleDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@@ -67,6 +86,13 @@ export class CreateFeeRuleDto {
|
||||
@Min(0)
|
||||
ratePerDay!: number;
|
||||
|
||||
@ApiPropertyOptional({ type: [FeeRuleTierDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => FeeRuleTierDto)
|
||||
tiers?: FeeRuleTierDto[];
|
||||
|
||||
@ApiPropertyOptional({ default: 'USD' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -4,6 +4,12 @@ import { Column, Entity, Index } from 'typeorm';
|
||||
export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const;
|
||||
export type FeeRuleType = (typeof FEE_RULE_TYPES)[number];
|
||||
|
||||
export interface WarehouseFeeTier {
|
||||
fromDay: number;
|
||||
toDay: number | null;
|
||||
ratePerDay: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch 5 — configurable storage / demurrage fee rules (no invoice/payment here — that is Batch 6).
|
||||
* The most specific active rule (highest `specificity` then lowest `priority`) applies to an item.
|
||||
@@ -54,6 +60,9 @@ export class WarehouseFeeRule extends BaseEntity {
|
||||
@Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
ratePerDay!: number;
|
||||
|
||||
@Column({ name: 'tiers', type: 'jsonb', default: () => "'[]'" })
|
||||
tiers!: WarehouseFeeTier[];
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' })
|
||||
currency!: string;
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface ImportTrainItemRow {
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
allocatedWeightTons: number | null;
|
||||
freightType: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
weight: number | null;
|
||||
@@ -274,6 +275,7 @@ export class SchedulingReadFacade {
|
||||
w.wagon_number AS "wagonNumber",
|
||||
tsw.sequence_no AS "sequenceNo",
|
||||
wba.allocated_weight_tons AS "allocatedWeightTons",
|
||||
b.freight_type AS "freightType",
|
||||
(SELECT c.container_number FROM freight.containers c
|
||||
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
|
||||
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
||||
import { FeeRuleType, WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
|
||||
import { FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
|
||||
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
|
||||
|
||||
interface ItemAttributes {
|
||||
@@ -39,6 +39,15 @@ export interface FeePreview {
|
||||
containerCount: number;
|
||||
billableUnits: number;
|
||||
amount: number;
|
||||
tiers: Array<{
|
||||
fromDay: number;
|
||||
toDay: number | null;
|
||||
appliedFromDay: number;
|
||||
appliedToDay: number;
|
||||
days: number;
|
||||
ratePerDay: number;
|
||||
amount: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
@@ -57,11 +66,16 @@ export class WarehouseFeeService {
|
||||
}
|
||||
|
||||
createRule(dto: CreateFeeRuleDto): Promise<WarehouseFeeRule> {
|
||||
return this.feeRuleRepository.create({ isActive: true, priority: 100, currency: 'USD', ...dto });
|
||||
return this.feeRuleRepository.create({
|
||||
isActive: true,
|
||||
priority: 100,
|
||||
currency: 'USD',
|
||||
...this.normalizeRuleInput(dto),
|
||||
});
|
||||
}
|
||||
|
||||
async updateRule(id: string, dto: UpdateFeeRuleDto): Promise<WarehouseFeeRule> {
|
||||
const updated = await this.feeRuleRepository.update(id, dto);
|
||||
const updated = await this.feeRuleRepository.update(id, this.normalizeRuleInput(dto));
|
||||
if (!updated) throw new NotFoundException(`Fee rule ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
@@ -70,6 +84,40 @@ export class WarehouseFeeService {
|
||||
return this.feeRuleRepository.softDelete(id);
|
||||
}
|
||||
|
||||
private normalizeRuleInput<T extends CreateFeeRuleDto | UpdateFeeRuleDto>(dto: T): T {
|
||||
if (dto.tiers === undefined) return dto;
|
||||
const tiers = (dto.tiers ?? [])
|
||||
.map((tier) => ({
|
||||
fromDay: Number(tier.fromDay),
|
||||
toDay: tier.toDay == null ? null : Number(tier.toDay),
|
||||
ratePerDay: Number(tier.ratePerDay),
|
||||
}))
|
||||
.filter((tier) => tier.fromDay > 0 || tier.toDay != null || tier.ratePerDay > 0);
|
||||
|
||||
for (const tier of tiers) {
|
||||
if (!Number.isInteger(tier.fromDay) || tier.fromDay < 1) {
|
||||
throw new BadRequestException('Fee tier from day must be a positive whole number.');
|
||||
}
|
||||
if (tier.toDay != null && (!Number.isInteger(tier.toDay) || tier.toDay < tier.fromDay)) {
|
||||
throw new BadRequestException('Fee tier to day must be empty or greater than/equal to from day.');
|
||||
}
|
||||
if (!Number.isFinite(tier.ratePerDay) || tier.ratePerDay < 0) {
|
||||
throw new BadRequestException('Fee tier rate per day must be zero or greater.');
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...tiers].sort((a, b) => a.fromDay - b.fromDay || (a.toDay ?? Infinity) - (b.toDay ?? Infinity));
|
||||
for (let i = 1; i < sorted.length; i += 1) {
|
||||
const prev = sorted[i - 1];
|
||||
const current = sorted[i];
|
||||
if (prev.toDay == null || current.fromDay <= prev.toDay) {
|
||||
throw new BadRequestException('Fee tiers cannot overlap. Use separate from/to day ranges.');
|
||||
}
|
||||
}
|
||||
|
||||
return { ...dto, tiers: sorted } as T;
|
||||
}
|
||||
|
||||
private async loadItem(inventoryId: string): Promise<ItemAttributes> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.arrived_at AS "arrivedAt",
|
||||
@@ -82,16 +130,27 @@ export class WarehouseFeeService {
|
||||
w.facility_id AS "facilityId",
|
||||
b.freight_type AS "freightType",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
cgt.code AS "cargoTypeCode",
|
||||
ctt.code AS "containerTypeCode",
|
||||
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
|
||||
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
|
||||
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
|
||||
LEFT JOIN freight.cargo_types booking_cgt ON booking_cgt.id = b.cargo_type_id
|
||||
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
||||
LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT bc.container_type_id
|
||||
FROM freight.booking_container bc
|
||||
WHERE bc.booking_id = inv.booking_id
|
||||
AND bc.deleted_at IS NULL
|
||||
AND bc.container_type_id IS NOT NULL
|
||||
ORDER BY bc.created_at ASC
|
||||
LIMIT 1
|
||||
) booking_container_type ON true
|
||||
LEFT JOIN freight.container_types booking_ctt ON booking_ctt.id = booking_container_type.container_type_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(bc.quantity), 0)::int AS container_count
|
||||
FROM freight.booking_container bc
|
||||
@@ -108,16 +167,26 @@ export class WarehouseFeeService {
|
||||
private matchScore(rule: WarehouseFeeRule, item: ItemAttributes): number | null {
|
||||
// Returns specificity score (#matched non-null scope fields), or null if any constraint fails.
|
||||
let score = 0;
|
||||
const check = (ruleVal: string | null | undefined, itemVal: string | null) => {
|
||||
if (ruleVal == null) return true;
|
||||
if (itemVal != null && ruleVal.toUpperCase() === itemVal.toUpperCase()) {
|
||||
const normalized = (value: string | null | undefined) => value?.trim().toUpperCase() || null;
|
||||
const check = (
|
||||
ruleVal: string | null | undefined,
|
||||
itemVal: string | null,
|
||||
opts: { allowBoth?: boolean } = {},
|
||||
) => {
|
||||
const ruleCode = normalized(ruleVal);
|
||||
if (ruleCode == null || ruleCode === 'ANY' || ruleCode === 'ALL') return true;
|
||||
if (opts.allowBoth && ruleCode === 'BOTH') {
|
||||
score += 1;
|
||||
return true;
|
||||
}
|
||||
if (ruleCode === normalized(itemVal)) {
|
||||
score += 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (!check(rule.freightType, item.freightType)) return null;
|
||||
if (!check(rule.tradeDirection, item.tradeDirection)) return null;
|
||||
if (!check(rule.tradeDirection, item.tradeDirection, { allowBoth: true })) return null;
|
||||
if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null;
|
||||
if (!check(rule.containerType, item.containerTypeCode)) return null;
|
||||
if (!check(rule.facilityId, item.facilityId)) return null;
|
||||
@@ -153,6 +222,60 @@ export class WarehouseFeeService {
|
||||
return Math.round(amount * rate * 100) / 100;
|
||||
}
|
||||
|
||||
private calculateTieredAmount(
|
||||
tiers: WarehouseFeeTier[] | null | undefined,
|
||||
elapsedDays: number,
|
||||
containerCount: number,
|
||||
): {
|
||||
sourceAmount: number;
|
||||
billableUnits: number;
|
||||
chargeableDays: number;
|
||||
weightedRatePerDay: number;
|
||||
tiers: FeePreview['tiers'];
|
||||
} {
|
||||
const sourceTiers = (tiers ?? [])
|
||||
.map((tier) => ({
|
||||
fromDay: Number(tier.fromDay),
|
||||
toDay: tier.toDay == null ? null : Number(tier.toDay),
|
||||
ratePerDay: Number(tier.ratePerDay),
|
||||
}))
|
||||
.filter((tier) => Number.isFinite(tier.fromDay) && tier.fromDay > 0 && Number.isFinite(tier.ratePerDay))
|
||||
.sort((a, b) => a.fromDay - b.fromDay);
|
||||
|
||||
let sourceAmount = 0;
|
||||
let tierDays = 0;
|
||||
const appliedTiers: FeePreview['tiers'] = [];
|
||||
|
||||
for (const tier of sourceTiers) {
|
||||
if (elapsedDays < tier.fromDay) continue;
|
||||
const appliedFromDay = tier.fromDay;
|
||||
const appliedToDay = Math.min(elapsedDays, tier.toDay ?? elapsedDays);
|
||||
const days = Math.max(0, appliedToDay - appliedFromDay + 1);
|
||||
if (days <= 0) continue;
|
||||
|
||||
const amount = Math.round(days * containerCount * tier.ratePerDay * 100) / 100;
|
||||
sourceAmount += amount;
|
||||
tierDays += days;
|
||||
appliedTiers.push({
|
||||
fromDay: tier.fromDay,
|
||||
toDay: tier.toDay,
|
||||
appliedFromDay,
|
||||
appliedToDay,
|
||||
days,
|
||||
ratePerDay: tier.ratePerDay,
|
||||
amount,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sourceAmount: Math.round(sourceAmount * 100) / 100,
|
||||
billableUnits: tierDays * containerCount,
|
||||
chargeableDays: tierDays,
|
||||
weightedRatePerDay: tierDays > 0 ? Math.round((sourceAmount / tierDays / containerCount) * 100) / 100 : 0,
|
||||
tiers: appliedTiers,
|
||||
};
|
||||
}
|
||||
|
||||
private async compute(
|
||||
ruleType: FeeRuleType,
|
||||
rule: WarehouseFeeRule | null,
|
||||
@@ -176,13 +299,25 @@ export class WarehouseFeeService {
|
||||
const elapsedDays = start
|
||||
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
|
||||
: 0;
|
||||
const chargeableDays = Math.max(0, elapsedDays - freeDays);
|
||||
const billableUnits = chargeableDays * containerCount;
|
||||
const sourceAmount = Math.round(billableUnits * ratePerDay * 100) / 100;
|
||||
const tiered = this.calculateTieredAmount(rule?.tiers, elapsedDays, containerCount);
|
||||
const hasTiers = Boolean(rule?.tiers?.length);
|
||||
const chargeableDays = hasTiers ? tiered.chargeableDays : Math.max(0, elapsedDays - freeDays);
|
||||
const billableUnits = hasTiers ? tiered.billableUnits : chargeableDays * containerCount;
|
||||
const sourceAmount = hasTiers ? tiered.sourceAmount : Math.round(billableUnits * ratePerDay * 100) / 100;
|
||||
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
|
||||
const sourceRatePerDay = hasTiers ? tiered.weightedRatePerDay : ratePerDay;
|
||||
const convertedRatePerDay = ruleCurrency
|
||||
? await this.convertAmount(ratePerDay, ruleCurrency, targetCurrency)
|
||||
? await this.convertAmount(sourceRatePerDay, ruleCurrency, targetCurrency)
|
||||
: 0;
|
||||
const convertedTiers = ruleCurrency
|
||||
? await Promise.all(
|
||||
tiered.tiers.map(async (tier) => ({
|
||||
...tier,
|
||||
ratePerDay: await this.convertAmount(tier.ratePerDay, ruleCurrency, targetCurrency),
|
||||
amount: await this.convertAmount(tier.amount, ruleCurrency, targetCurrency),
|
||||
})),
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
ruleType,
|
||||
@@ -201,6 +336,7 @@ export class WarehouseFeeService {
|
||||
containerCount,
|
||||
billableUnits,
|
||||
amount,
|
||||
tiers: hasTiers ? convertedTiers : [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -84,9 +84,11 @@ export class WarehouseInspectionService {
|
||||
`SELECT inv.booking_id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
b.last_mile_delivery_address AS "lastMileDeliveryAddress"
|
||||
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
|
||||
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[inventoryId],
|
||||
@@ -98,7 +100,10 @@ export class WarehouseInspectionService {
|
||||
readyForPickupAt: new Date(),
|
||||
});
|
||||
|
||||
if (row.bookingReference && row.lastMileDeliveryAddress) {
|
||||
const hasLastMile =
|
||||
Boolean(row.lastMileDeliveryAddress?.trim?.()) || Boolean(row.serviceIncludesLastMile);
|
||||
|
||||
if (row.bookingReference && hasLastMile) {
|
||||
await this.lastMileService.acceptBooking(row.bookingReference);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,8 +139,18 @@ export class WarehouseInventoryController {
|
||||
|
||||
@Post('import/auto-unload-arrived-bookings')
|
||||
@ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' })
|
||||
autoUnloadArrivedBookings(@Body() dto: { scheduleId: string; performedBy?: string }) {
|
||||
return this.inventoryService.autoUnloadArrivedBookings(dto.scheduleId, dto.performedBy);
|
||||
autoUnloadArrivedBookings(@Body() dto: {
|
||||
scheduleId: string;
|
||||
warehouseId?: string;
|
||||
performedBy?: string;
|
||||
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
|
||||
}) {
|
||||
return this.inventoryService.autoUnloadArrivedBookings(
|
||||
dto.scheduleId,
|
||||
dto.performedBy,
|
||||
dto.warehouseId,
|
||||
dto.assignments,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('import/unloaded-queue')
|
||||
|
||||
@@ -180,6 +180,10 @@ interface LocationRef {
|
||||
zoneId: string;
|
||||
}
|
||||
|
||||
interface BookingUnloadLocation extends LocationRef {
|
||||
bookingId: string;
|
||||
}
|
||||
|
||||
interface LocationNode {
|
||||
capacityWeight?: number | null;
|
||||
capacityContainers?: number | null;
|
||||
@@ -221,6 +225,11 @@ export interface EligibleBookingRow {
|
||||
firstMileDriverPhone: string | null;
|
||||
firstMileDriverLicenseNumber: string | null;
|
||||
firstMileTruckType: string | null;
|
||||
customerTruckPlateNumber: string | null;
|
||||
customerTruckDriverName: string | null;
|
||||
customerTruckType: string | null;
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
}
|
||||
|
||||
export interface BulkReceiveResult {
|
||||
@@ -302,6 +311,11 @@ export interface ImportUnloadedRow {
|
||||
inspectionStatus: string | null;
|
||||
pickupOption: string;
|
||||
lastMileRequested: boolean;
|
||||
customerTruckPlateNumber: string | null;
|
||||
customerTruckDriverName: string | null;
|
||||
customerTruckType: string | null;
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
currentStatus: string;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
@@ -504,8 +518,9 @@ export class WarehouseInventoryService {
|
||||
}));
|
||||
}
|
||||
|
||||
/** First warehouse that has at least one yard + zone (fallback location for auto-unload). */
|
||||
private async pickDefaultLocation(): Promise<DefaultLocation | null> {
|
||||
/** First matching warehouse that has at least one yard + zone (fallback location for auto-unload). */
|
||||
private async pickDefaultLocation(warehouseId?: string): Promise<DefaultLocation | null> {
|
||||
const params = warehouseId ? [warehouseId] : [];
|
||||
const [row]: DefaultLocation[] = await this.dataSource.query(
|
||||
`SELECT wh.id AS "warehouseId", wh.facility_id AS "facilityId",
|
||||
yard.id AS "yardId", zone.id AS "zoneId"
|
||||
@@ -513,8 +528,10 @@ export class WarehouseInventoryService {
|
||||
JOIN freight.warehouse_yards yard ON yard.warehouse_id = wh.id AND yard.deleted_at IS NULL
|
||||
JOIN freight.warehouse_zones zone ON zone.yard_id = yard.id AND zone.deleted_at IS NULL
|
||||
WHERE wh.deleted_at IS NULL
|
||||
${warehouseId ? 'AND wh.id = $1' : ''}
|
||||
ORDER BY wh.created_at ASC
|
||||
LIMIT 1`,
|
||||
LIMIT 1`,
|
||||
params,
|
||||
);
|
||||
return row ?? null;
|
||||
}
|
||||
@@ -592,6 +609,7 @@ export class WarehouseInventoryService {
|
||||
dto.warehouseId && dto.yardId && dto.zoneId
|
||||
? { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, facilityId: dto.facilityId ?? null }
|
||||
: null;
|
||||
if (!location && dto.warehouseId) location = await this.pickDefaultLocation(dto.warehouseId);
|
||||
if (!location) location = await this.pickDefaultLocation();
|
||||
if (!location) {
|
||||
throw new BadRequestException('No warehouse/yard/zone provided or configured for unloading');
|
||||
@@ -704,7 +722,12 @@ export class WarehouseInventoryService {
|
||||
) AS "firstMileDriverName",
|
||||
driver.phone_number AS "firstMileDriverPhone",
|
||||
driver.license_number AS "firstMileDriverLicenseNumber",
|
||||
v.vehicle_type AS "firstMileTruckType"
|
||||
v.vehicle_type AS "firstMileTruckType",
|
||||
b.customer_truck_plate_number AS "customerTruckPlateNumber",
|
||||
b.customer_truck_driver_name AS "customerTruckDriverName",
|
||||
b.customer_truck_type AS "customerTruckType",
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
|
||||
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
|
||||
@@ -796,7 +819,12 @@ export class WarehouseInventoryService {
|
||||
) AS "firstMileDriverName",
|
||||
driver.phone_number AS "firstMileDriverPhone",
|
||||
driver.license_number AS "firstMileDriverLicenseNumber",
|
||||
v.vehicle_type AS "firstMileTruckType"
|
||||
v.vehicle_type AS "firstMileTruckType",
|
||||
b.customer_truck_plate_number AS "customerTruckPlateNumber",
|
||||
b.customer_truck_driver_name AS "customerTruckDriverName",
|
||||
b.customer_truck_type AS "customerTruckType",
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
|
||||
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
|
||||
@@ -1041,9 +1069,16 @@ export class WarehouseInventoryService {
|
||||
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
|
||||
ts.train_number AS "trainSchedule",
|
||||
inv.inspection_status AS "inspectionStatus",
|
||||
CASE WHEN b.last_mile_delivery_address IS NOT NULL
|
||||
CASE WHEN NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)
|
||||
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
|
||||
(b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
|
||||
b.customer_truck_plate_number AS "customerTruckPlateNumber",
|
||||
b.customer_truck_driver_name AS "customerTruckDriverName",
|
||||
b.customer_truck_type AS "customerTruckType",
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
inv.status AS "currentStatus",
|
||||
inv.release_date AS "releaseDate",
|
||||
inv.release_order_reference AS "releaseOrderReference",
|
||||
@@ -1056,6 +1091,7 @@ export class WarehouseInventoryService {
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_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.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
|
||||
@@ -1157,6 +1193,8 @@ export class WarehouseInventoryService {
|
||||
async autoUnloadArrivedBookings(
|
||||
scheduleId: string,
|
||||
performedBy?: string,
|
||||
warehouseId?: string,
|
||||
assignments: BookingUnloadLocation[] = [],
|
||||
): Promise<AutoUnloadArrivedResult> {
|
||||
const result: AutoUnloadArrivedResult = { unloadedCount: 0, skippedCount: 0, failedCount: 0, results: [] };
|
||||
|
||||
@@ -1203,7 +1241,21 @@ export class WarehouseInventoryService {
|
||||
[scheduleId],
|
||||
);
|
||||
|
||||
const fallback = await this.pickDefaultLocation();
|
||||
const requestedLocation = warehouseId ? await this.pickDefaultLocation(warehouseId) : null;
|
||||
if (warehouseId && !requestedLocation) {
|
||||
throw new BadRequestException('Selected warehouse has no yard/zone configured for unloading');
|
||||
}
|
||||
const fallback = requestedLocation ?? (await this.pickDefaultLocation());
|
||||
const assignmentByBooking = new Map(
|
||||
assignments.map((assignment) => [
|
||||
assignment.bookingId,
|
||||
{
|
||||
warehouseId: assignment.warehouseId,
|
||||
yardId: assignment.yardId,
|
||||
zoneId: assignment.zoneId,
|
||||
} satisfies LocationRef,
|
||||
]),
|
||||
);
|
||||
const now = new Date();
|
||||
|
||||
for (const booking of bookings) {
|
||||
@@ -1223,6 +1275,8 @@ export class WarehouseInventoryService {
|
||||
|
||||
try {
|
||||
const existing = (await this.inventoryRepository.findAll({ where: { bookingId: booking.id } }))[0];
|
||||
const assignedLocation = assignmentByBooking.get(booking.id) ?? null;
|
||||
const unloadLocation = assignedLocation ?? requestedLocation;
|
||||
|
||||
// Already unloaded or further along — leave it (do not regress the lifecycle).
|
||||
if (existing && existing.status !== 'RECEIVED') {
|
||||
@@ -1232,6 +1286,13 @@ export class WarehouseInventoryService {
|
||||
|
||||
if (existing) {
|
||||
await this.inventoryRepository.update(existing.id, {
|
||||
...(unloadLocation
|
||||
? {
|
||||
warehouseId: unloadLocation.warehouseId,
|
||||
yardId: unloadLocation.yardId,
|
||||
zoneId: unloadLocation.zoneId,
|
||||
}
|
||||
: {}),
|
||||
status: 'UNLOADED',
|
||||
unloadedAt: now,
|
||||
arrivedAt: existing.arrivedAt ?? now,
|
||||
@@ -1239,7 +1300,7 @@ export class WarehouseInventoryService {
|
||||
await this.activityLog.record({
|
||||
activityType: 'INVENTORY_UNLOADED',
|
||||
inventoryId: existing.id,
|
||||
warehouseId: existing.warehouseId,
|
||||
warehouseId: unloadLocation?.warehouseId ?? existing.warehouseId,
|
||||
description: 'Unloaded from arrived import train',
|
||||
performedBy,
|
||||
});
|
||||
@@ -1254,7 +1315,7 @@ export class WarehouseInventoryService {
|
||||
tradeDirection: booking.tradeDirection,
|
||||
cargoTypeCode: booking.cargoTypeCode,
|
||||
});
|
||||
const location = allocated ?? fallback;
|
||||
const location = assignedLocation ?? requestedLocation ?? allocated ?? fallback;
|
||||
if (!location) {
|
||||
fail('No warehouse/yard/zone configured');
|
||||
continue;
|
||||
@@ -1336,6 +1397,16 @@ export class WarehouseInventoryService {
|
||||
if (!['ARRIVED', 'ARRIVED_AT_DJIBOUTI'].includes(schedule.status)) {
|
||||
throw new BadRequestException(`Train schedule is ${schedule.status}, not ARRIVED`);
|
||||
}
|
||||
const [gatepass] = await this.dataSource.query(
|
||||
`SELECT gatepass_granted_at AS "gatepassSecuredAt"
|
||||
FROM freight.import_djibouti_operations
|
||||
WHERE train_schedule_id = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[scheduleId],
|
||||
);
|
||||
if (!gatepass?.gatepassSecuredAt) {
|
||||
throw new BadRequestException('Djibouti Port entry blocked: gate pass status is NOT_SECURED');
|
||||
}
|
||||
|
||||
const items: Array<{
|
||||
bookingId: string;
|
||||
@@ -1631,13 +1702,17 @@ export class WarehouseInventoryService {
|
||||
if (!bookingId) return;
|
||||
const [booking] = await this.dataSource.query(
|
||||
`SELECT reference,
|
||||
last_mile_delivery_address AS "lastMileDeliveryAddress"
|
||||
FROM freight.bookings
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
last_mile_delivery_address AS "lastMileDeliveryAddress",
|
||||
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!booking?.reference || !booking.lastMileDeliveryAddress) return;
|
||||
const hasLastMile =
|
||||
Boolean(booking?.lastMileDeliveryAddress?.trim?.()) || Boolean(booking?.serviceIncludesLastMile);
|
||||
if (!booking?.reference || !hasLastMile) return;
|
||||
await this.lastMileService.acceptBooking(booking.reference);
|
||||
}
|
||||
|
||||
@@ -1955,11 +2030,19 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
|
||||
if (isTruckLeaving) {
|
||||
await this.invoices.assertClearanceAllowed(id);
|
||||
}
|
||||
const releaseDate = isTruckLeaving
|
||||
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
|
||||
: item.releaseDate ?? null;
|
||||
const reference = dto.reference?.trim() || (await this.generateReleaseReference(item));
|
||||
const exitInspectionNote = this.buildExitInspectionNote(dto);
|
||||
const reference = isTruckLeaving
|
||||
? item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item))
|
||||
: dto.reference?.trim() || (await this.generateReleaseReference(item));
|
||||
const exitInspectionDto = isTruckLeaving
|
||||
? this.preserveTruckArrivalForExit(dto, item.notes)
|
||||
: dto;
|
||||
const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
@@ -1967,6 +2050,17 @@ export class WarehouseInventoryService {
|
||||
releaseOrderReference: reference,
|
||||
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
|
||||
});
|
||||
if (!isTruckLeaving && item.bookingId) {
|
||||
await manager.query(
|
||||
`UPDATE freight.bookings
|
||||
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND customer_truck_assigned_at IS NOT NULL
|
||||
AND deleted_at IS NULL`,
|
||||
[item.bookingId],
|
||||
);
|
||||
}
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_RELEASED',
|
||||
@@ -2034,6 +2128,7 @@ export class WarehouseInventoryService {
|
||||
if (!row.releaseDate) {
|
||||
throw new BadRequestException('A release order must be issued before downloading the exit paper');
|
||||
}
|
||||
await this.invoices.assertClearanceAllowed(id);
|
||||
|
||||
const bookingReference = row?.bookingReference || 'N/A';
|
||||
const reference =
|
||||
@@ -2180,11 +2275,19 @@ export class WarehouseInventoryService {
|
||||
throw new BadRequestException('Please save your signature before approving delivery');
|
||||
}
|
||||
|
||||
const [item]: Array<{ id: string; warehouseId: string | null; notes: string | null }> =
|
||||
const [item]: Array<{
|
||||
id: string;
|
||||
warehouseId: string | null;
|
||||
notes: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
customerTruckArrivedAt: string | null;
|
||||
}> =
|
||||
await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
inv.warehouse_id AS "warehouseId",
|
||||
inv.notes
|
||||
inv.notes,
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
b.customer_truck_arrived_at AS "customerTruckArrivedAt"
|
||||
FROM freight.warehouse_inventory inv
|
||||
JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||
WHERE inv.booking_id = $1
|
||||
@@ -2198,6 +2301,10 @@ export class WarehouseInventoryService {
|
||||
if (!item) {
|
||||
throw new BadRequestException('Delivery can be approved after warehouse inspection has passed');
|
||||
}
|
||||
if (item.customerTruckAssignedAt && !item.customerTruckArrivedAt) {
|
||||
throw new BadRequestException('Customer truck arrival must be recorded before delivery approval');
|
||||
}
|
||||
await this.invoices.assertClearanceAllowed(item.id);
|
||||
|
||||
const approvedAt = new Date();
|
||||
const approval = {
|
||||
@@ -2301,6 +2408,7 @@ export class WarehouseInventoryService {
|
||||
if (!row) {
|
||||
throw new NotFoundException(`Inventory item ${id} not found`);
|
||||
}
|
||||
await this.invoices.assertClearanceAllowed(id);
|
||||
if (row.inspectionStatus !== 'PASSED') {
|
||||
throw new BadRequestException('Handover document is available after inspection has passed');
|
||||
}
|
||||
@@ -3302,8 +3410,13 @@ export class WarehouseInventoryService {
|
||||
if (!truckEntrance.driverPhone?.trim()) {
|
||||
throw new BadRequestException('Driver phone is required for entrance registration');
|
||||
}
|
||||
if (truckEntrance.entranceTareWeightKg === undefined || Number(truckEntrance.entranceTareWeightKg) < 0) {
|
||||
throw new BadRequestException('Entrance tare weight is required for entrance registration');
|
||||
if (truckEntrance.weighingRequired) {
|
||||
if (truckEntrance.grossWeightKg === undefined || Number(truckEntrance.grossWeightKg) < 0) {
|
||||
throw new BadRequestException('Gross weight is required when customer truck weighing is Yes');
|
||||
}
|
||||
if (truckEntrance.exitTareWeightKg === undefined || Number(truckEntrance.exitTareWeightKg) < 0) {
|
||||
throw new BadRequestException('Exit tare weight is required when customer truck weighing is Yes');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3325,6 +3438,11 @@ export class WarehouseInventoryService {
|
||||
firstMileDriverPhone?: string | null;
|
||||
firstMileDriverLicenseNumber?: string | null;
|
||||
firstMileTruckType?: string | null;
|
||||
customerTruckPlateNumber?: string | null;
|
||||
customerTruckDriverName?: string | null;
|
||||
customerTruckType?: string | null;
|
||||
customerTruckContainerNumber?: string | null;
|
||||
customerTruckAssignedAt?: string | null;
|
||||
},
|
||||
): TruckEntranceDto {
|
||||
return {
|
||||
@@ -3340,16 +3458,22 @@ export class WarehouseInventoryService {
|
||||
booking.containerQuantity !== undefined && booking.containerQuantity !== null
|
||||
? Number(booking.containerQuantity)
|
||||
: submitted.unitCount,
|
||||
grossWeightKg:
|
||||
booking.weight !== undefined && booking.weight !== null
|
||||
? Number(booking.weight)
|
||||
: submitted.grossWeightKg,
|
||||
truckPlateNumber: booking.firstMileTruckPlateNumber?.trim() || submitted.truckPlateNumber,
|
||||
grossWeightKg: submitted.grossWeightKg,
|
||||
truckPlateNumber:
|
||||
booking.firstMileTruckPlateNumber?.trim() ||
|
||||
booking.customerTruckPlateNumber?.trim() ||
|
||||
submitted.truckPlateNumber,
|
||||
trailerPlateNumber: booking.firstMileTrailerPlateNumber?.trim() || submitted.trailerPlateNumber,
|
||||
driverName: booking.firstMileDriverName?.trim() || submitted.driverName,
|
||||
driverName:
|
||||
booking.firstMileDriverName?.trim() ||
|
||||
booking.customerTruckDriverName?.trim() ||
|
||||
submitted.driverName,
|
||||
driverPhone: booking.firstMileDriverPhone?.trim() || submitted.driverPhone,
|
||||
driverLicenseNumber: booking.firstMileDriverLicenseNumber?.trim() || submitted.driverLicenseNumber,
|
||||
truckType: booking.firstMileTruckType?.trim() || submitted.truckType,
|
||||
truckType:
|
||||
booking.firstMileTruckType?.trim() ||
|
||||
booking.customerTruckType?.trim() ||
|
||||
submitted.truckType,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3543,6 +3667,24 @@ export class WarehouseInventoryService {
|
||||
return rows.filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
private preserveTruckArrivalForExit(dto: ReleaseOrderDto, notes: string | null | undefined): ReleaseOrderDto {
|
||||
const inspection = this.extractExitInspectionNote(notes);
|
||||
if (!inspection) return dto;
|
||||
|
||||
return {
|
||||
...dto,
|
||||
truckPlateNumber: this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber,
|
||||
trailerPlateNumber: this.extractExitInspectionLine(inspection, 'Trailer Plate') || dto.trailerPlateNumber,
|
||||
driverName: this.extractExitInspectionLine(inspection, 'Driver') || dto.driverName,
|
||||
driverLicense: this.extractExitInspectionLine(inspection, 'Driver License') || dto.driverLicense,
|
||||
driverPhone: this.extractExitInspectionLine(inspection, 'Driver Phone') || dto.driverPhone,
|
||||
truckType: this.extractExitInspectionLine(inspection, 'Truck Type') || dto.truckType,
|
||||
containerNumber: this.extractExitInspectionLine(inspection, 'Container Number') || dto.containerNumber,
|
||||
gateInTime: this.extractExitInspectionLine(inspection, 'Gate In Time') || dto.gateInTime,
|
||||
tareWeight: this.extractExitInspectionNumber(inspection, 'Tare Weight') ?? dto.tareWeight,
|
||||
};
|
||||
}
|
||||
|
||||
private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null {
|
||||
const trimmed = notes?.trim();
|
||||
if (!exitInspectionNote) return trimmed || null;
|
||||
@@ -3564,6 +3706,18 @@ export class WarehouseInventoryService {
|
||||
return notes.slice(index + marker.length).trim() || null;
|
||||
}
|
||||
|
||||
private extractExitInspectionLine(note: string | null | undefined, label: string): string | null {
|
||||
const match = note?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
private extractExitInspectionNumber(note: string | null | undefined, label: string): number | undefined {
|
||||
const value = this.extractExitInspectionLine(note, label)?.replace(/\s*kg$/i, '');
|
||||
if (!value) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
private extractReceiveSummary(notes?: string | null): string | null {
|
||||
if (!notes?.trim()) return null;
|
||||
const withoutExit = notes.split('\n\n[Exit Inspection]')[0] ?? notes;
|
||||
@@ -3622,6 +3776,7 @@ export class WarehouseInventoryService {
|
||||
truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null,
|
||||
truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
|
||||
truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg` : null,
|
||||
truck?.weighingRequired !== undefined ? `Weighing Required: ${truck.weighingRequired ? 'Yes' : 'No'}` : null,
|
||||
truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null,
|
||||
truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
|
||||
truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res }
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
|
||||
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
|
||||
import { WarehouseInvoiceService } from './warehouse-invoice.service';
|
||||
|
||||
@@ -86,4 +87,10 @@ export class WarehouseInvoiceController {
|
||||
pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) {
|
||||
return this.invoiceService.pay(id, dto);
|
||||
}
|
||||
|
||||
@Post('warehouse-fee-invoices/:id/pay-online')
|
||||
@ApiOperation({ summary: 'Initiate Telebirr/Waafi payment for a warehouse fee invoice' })
|
||||
payOnline(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GatewayPayInvoiceDto) {
|
||||
return this.invoiceService.initiatePayment(id, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Freight } from '@edr/types';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
|
||||
import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
|
||||
@@ -171,8 +172,12 @@ export class WarehouseInvoiceService {
|
||||
feeType,
|
||||
description:
|
||||
p.ruleType === 'STORAGE_FEE'
|
||||
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`
|
||||
: `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`,
|
||||
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${
|
||||
p.tiers.length ? ' using tiered tariff' : ` after ${p.freeDays} free`
|
||||
}`
|
||||
: `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${
|
||||
p.tiers.length ? ' using tiered tariff' : ` after ${p.freeDays} free`
|
||||
}`,
|
||||
quantity: p.billableUnits,
|
||||
unitRate: p.ratePerDay,
|
||||
amount: p.amount,
|
||||
@@ -305,6 +310,22 @@ export class WarehouseInvoiceService {
|
||||
return detail;
|
||||
}
|
||||
|
||||
/** Initiate a wallet/gateway payment for the invoice. */
|
||||
async initiatePayment(id: string, dto: GatewayPayInvoiceDto = {}) {
|
||||
const invoice = await this.loadWarehouseInvoice(id);
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
throw new BadRequestException('Invoice is already fully paid.');
|
||||
}
|
||||
|
||||
return this.billing.payInvoice(invoice.source as Freight.InvoiceSource, invoice.sourceId, {
|
||||
method: dto.method ?? (invoice.currency === 'USD' ? 'WAAFI' : 'TELEBIRR'),
|
||||
platform: dto.platform ?? 'web',
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl: dto.returnUrl,
|
||||
failureUrl: dto.failureUrl,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify on online (gateway) settlement — the domain side-effect of a warehouse
|
||||
* fee being paid through billing's payment flow. The counter {@link pay} path
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
import 'reflect-metadata';
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
import { WagonStatus } from '@edr/types';
|
||||
import { In } from 'typeorm';
|
||||
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
|
||||
import { AppDataSource } from '../data-source';
|
||||
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { CompanyProfile, ProfileStatus, ProfileType } from '../modules/companies/entities/company-profile.entity';
|
||||
import { Company, CompanyKind, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity';
|
||||
import { Container } from '../modules/container-management/entities/container.entity';
|
||||
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity';
|
||||
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
|
||||
import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity';
|
||||
import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity';
|
||||
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
|
||||
import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
|
||||
import { Wagon } from '../modules/wagons/entities/wagon.entity';
|
||||
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
|
||||
import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity';
|
||||
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
|
||||
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
|
||||
|
||||
type Direction = 'IMPORT' | 'EXPORT';
|
||||
type TrainStatus = 'SCHEDULED' | 'ARRIVED';
|
||||
|
||||
interface ScenarioTrain {
|
||||
trainNumber: string;
|
||||
direction: Direction;
|
||||
status: TrainStatus;
|
||||
departureOffsetHours: number;
|
||||
arrivalOffsetHours: number;
|
||||
bookings: Array<{
|
||||
reference: string;
|
||||
mileVariant: 'FIRST_MILE' | 'LAST_MILE' | 'TERMINAL';
|
||||
containerNumber: string;
|
||||
weightTons: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
const SCENARIOS: ScenarioTrain[] = [
|
||||
{
|
||||
trainNumber: 'GP-IMP-ARR-01',
|
||||
direction: 'IMPORT',
|
||||
status: 'ARRIVED',
|
||||
departureOffsetHours: -18,
|
||||
arrivalOffsetHours: -6,
|
||||
bookings: [
|
||||
{ reference: 'GP-IMP-ARR-LM-001', mileVariant: 'LAST_MILE', containerNumber: 'GPIM0000001', weightTons: 22 },
|
||||
{ reference: 'GP-IMP-ARR-TM-002', mileVariant: 'TERMINAL', containerNumber: 'GPIM0000002', weightTons: 24 },
|
||||
],
|
||||
},
|
||||
{
|
||||
trainNumber: 'GP-IMP-NARR-01',
|
||||
direction: 'IMPORT',
|
||||
status: 'SCHEDULED',
|
||||
departureOffsetHours: 6,
|
||||
arrivalOffsetHours: 18,
|
||||
bookings: [
|
||||
{ reference: 'GP-IMP-NARR-LM-001', mileVariant: 'LAST_MILE', containerNumber: 'GPIM0000003', weightTons: 21 },
|
||||
{ reference: 'GP-IMP-NARR-TM-002', mileVariant: 'TERMINAL', containerNumber: 'GPIM0000004', weightTons: 23 },
|
||||
],
|
||||
},
|
||||
{
|
||||
trainNumber: 'GP-EXP-ARR-01',
|
||||
direction: 'EXPORT',
|
||||
status: 'ARRIVED',
|
||||
departureOffsetHours: -16,
|
||||
arrivalOffsetHours: -4,
|
||||
bookings: [
|
||||
{ reference: 'GP-EXP-ARR-FM-001', mileVariant: 'FIRST_MILE', containerNumber: 'GPEX0000001', weightTons: 20 },
|
||||
{ reference: 'GP-EXP-ARR-TM-002', mileVariant: 'TERMINAL', containerNumber: 'GPEX0000002', weightTons: 22 },
|
||||
],
|
||||
},
|
||||
{
|
||||
trainNumber: 'GP-EXP-NARR-01',
|
||||
direction: 'EXPORT',
|
||||
status: 'SCHEDULED',
|
||||
departureOffsetHours: 8,
|
||||
arrivalOffsetHours: 20,
|
||||
bookings: [
|
||||
{ reference: 'GP-EXP-NARR-FM-001', mileVariant: 'FIRST_MILE', containerNumber: 'GPEX0000003', weightTons: 19 },
|
||||
{ reference: 'GP-EXP-NARR-TM-002', mileVariant: 'TERMINAL', containerNumber: 'GPEX0000004', weightTons: 21 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const addHours = (date: Date, hours: number): Date => new Date(date.getTime() + hours * 60 * 60 * 1000);
|
||||
|
||||
async function main() {
|
||||
const dataSource = await AppDataSource.initialize();
|
||||
|
||||
try {
|
||||
const seeded = await dataSource.transaction(async (manager) => {
|
||||
if (await isAlreadySeeded(manager)) {
|
||||
return null;
|
||||
}
|
||||
const refs = await ensureReferences(manager);
|
||||
const now = new Date();
|
||||
const result: Array<{ trainNumber: string; bookings: string[] }> = [];
|
||||
|
||||
for (const scenario of SCENARIOS) {
|
||||
const schedule = await seedScenarioTrain(manager, scenario, refs, now);
|
||||
result.push({
|
||||
trainNumber: schedule.trainNumber ?? scenario.trainNumber,
|
||||
bookings: scenario.bookings.map((booking) => booking.reference),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
console.log('Gate-pass train scenario seed complete.');
|
||||
if (seeded) {
|
||||
for (const row of seeded) {
|
||||
console.log(`${row.trainNumber}: ${row.bookings.join(', ')}`);
|
||||
}
|
||||
} else {
|
||||
console.log('Gate-pass train scenarios already seeded; nothing changed.');
|
||||
}
|
||||
} finally {
|
||||
await dataSource.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function isAlreadySeeded(manager: any): Promise<boolean> {
|
||||
const scheduleRepo = manager.getRepository(TrainSchedule);
|
||||
const bookingRepo = manager.getRepository(Booking);
|
||||
const trainNumbers = SCENARIOS.map((scenario) => scenario.trainNumber);
|
||||
const bookingRefs = SCENARIOS.flatMap((scenario) => scenario.bookings.map((booking) => booking.reference));
|
||||
|
||||
const [scheduleCount, bookingCount] = await Promise.all([
|
||||
scheduleRepo.count({ where: { trainNumber: In(trainNumbers) } }),
|
||||
bookingRepo.count({ where: { reference: In(bookingRefs) } }),
|
||||
]);
|
||||
|
||||
return scheduleCount === trainNumbers.length && bookingCount === bookingRefs.length;
|
||||
}
|
||||
|
||||
async function ensureReferences(manager: any) {
|
||||
const yardRepo = manager.getRepository(Yard);
|
||||
const serviceTypeRepo = manager.getRepository(ServiceType);
|
||||
const containerTypeRepo = manager.getRepository(ContainerType);
|
||||
const wagonTypeRepo = manager.getRepository(WagonType);
|
||||
const companyRepo = manager.getRepository(Company);
|
||||
const profileRepo = manager.getRepository(CompanyProfile);
|
||||
const warehouseRepo = manager.getRepository(Warehouse);
|
||||
const warehouseYardRepo = manager.getRepository(WarehouseYard);
|
||||
const warehouseZoneRepo = manager.getRepository(WarehouseZone);
|
||||
|
||||
const djiboutiYard =
|
||||
(await yardRepo.findOne({ where: { code: 'NAGAD' } })) ??
|
||||
(await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Djibouti' } })) ??
|
||||
(await yardRepo.save(
|
||||
yardRepo.create({
|
||||
code: 'NAGAD',
|
||||
label: 'Nagad Port',
|
||||
country: 'Djibouti',
|
||||
isActive: true,
|
||||
displayOrder: 90,
|
||||
}),
|
||||
));
|
||||
|
||||
const ethiopiaYard =
|
||||
(await yardRepo.findOne({ where: { code: 'INDODE' } })) ??
|
||||
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Ethiopia' } })) ??
|
||||
(await yardRepo.save(
|
||||
yardRepo.create({
|
||||
code: 'INDODE',
|
||||
label: 'Indode Dry Port',
|
||||
country: 'Ethiopia',
|
||||
isActive: true,
|
||||
displayOrder: 91,
|
||||
}),
|
||||
));
|
||||
|
||||
const serviceType =
|
||||
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
|
||||
(await serviceTypeRepo.save(
|
||||
serviceTypeRepo.create({
|
||||
code: 'RAIL_CONTAINER',
|
||||
serviceName: 'Rail Container Service',
|
||||
description: 'Rail container service for gate-pass scenario seed',
|
||||
canBeBookedAlone: true,
|
||||
includesFirstMile: false,
|
||||
includesLastMile: false,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 0,
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
}),
|
||||
));
|
||||
|
||||
const containerType =
|
||||
(await containerTypeRepo.findOne({ where: { code: '40FT' } })) ??
|
||||
(await containerTypeRepo.findOne({ where: { isActive: true } })) ??
|
||||
(await containerTypeRepo.save(
|
||||
containerTypeRepo.create({
|
||||
code: '40FT',
|
||||
label: '40FT',
|
||||
sizeFt: 40,
|
||||
wagonsPerUnit: 1,
|
||||
isReefer: false,
|
||||
isOpenTop: false,
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
}),
|
||||
));
|
||||
|
||||
const wagonType =
|
||||
(await wagonTypeRepo.findOne({ where: { code: 'GP-FLAT' } })) ??
|
||||
(await wagonTypeRepo.findOne({ where: { supportsContainer: true } })) ??
|
||||
(await wagonTypeRepo.findOne({ where: { isActive: true } })) ??
|
||||
(await wagonTypeRepo.save(
|
||||
wagonTypeRepo.create({
|
||||
code: 'GP-FLAT',
|
||||
name: 'Gate Pass Demo Flat Wagon',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
maxWagonsPerTrain: 53,
|
||||
supportedLoadTypes: ['CONTAINER'],
|
||||
isActive: true,
|
||||
equatedLengthM: 14,
|
||||
tareWeightTons: 20,
|
||||
supportsContainer: true,
|
||||
maxContainerGrossT: 70,
|
||||
}),
|
||||
));
|
||||
|
||||
const company =
|
||||
(await companyRepo.findOne({ where: { tin: 'GTPASS001' } })) ??
|
||||
(await companyRepo.save(
|
||||
companyRepo.create({
|
||||
name: 'Gate Pass Scenario Customer',
|
||||
type: CompanyType.Customer,
|
||||
kind: CompanyKind.Commercial,
|
||||
status: CompanyStatus.Active,
|
||||
tin: 'GTPASS001',
|
||||
vatNumber: 'GTPASS001',
|
||||
fanNumber: 'GTPASS0000001',
|
||||
country: 'Ethiopia',
|
||||
address: 'Indode Dry Port',
|
||||
phone: '251900000555',
|
||||
email: 'gate-pass-scenarios@edr.local',
|
||||
contactPersonName: 'Gate Pass Tester',
|
||||
contactPersonPhone: '251900000555',
|
||||
}),
|
||||
));
|
||||
|
||||
const importerProfile = await ensureProfile(profileRepo, company.id, ProfileType.importer, 'GP-IMP');
|
||||
const exporterProfile = await ensureProfile(profileRepo, company.id, ProfileType.exporter, 'GP-EXP');
|
||||
|
||||
const warehouse =
|
||||
(await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } })) ??
|
||||
(await warehouseRepo.findOne({ where: {} }));
|
||||
if (!warehouse) {
|
||||
throw new Error('No warehouse found. Run the Indode/warehouse seed before gate-pass scenarios.');
|
||||
}
|
||||
const warehouseYard = await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } });
|
||||
if (!warehouseYard) {
|
||||
throw new Error(`No warehouse yard found for ${warehouse.code ?? warehouse.id}.`);
|
||||
}
|
||||
const warehouseZone = await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } });
|
||||
if (!warehouseZone) {
|
||||
throw new Error(`No warehouse zone found for yard ${warehouseYard.id}.`);
|
||||
}
|
||||
|
||||
return {
|
||||
djiboutiYard,
|
||||
ethiopiaYard,
|
||||
serviceType,
|
||||
containerType,
|
||||
wagonType,
|
||||
company,
|
||||
importerProfile,
|
||||
exporterProfile,
|
||||
warehouse,
|
||||
warehouseYard,
|
||||
warehouseZone,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureProfile(repo: any, companyId: string, type: ProfileType, reference: string): Promise<CompanyProfile> {
|
||||
const existing = await repo.findOne({ where: { companyId, type } });
|
||||
if (existing) return existing;
|
||||
return repo.save(
|
||||
repo.create({
|
||||
companyId,
|
||||
type,
|
||||
reference,
|
||||
status: ProfileStatus.Active,
|
||||
businessLicense: `${reference}-LICENSE`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function seedScenarioTrain(manager: any, scenario: ScenarioTrain, refs: Awaited<ReturnType<typeof ensureReferences>>, now: Date) {
|
||||
const locomotiveRepo = manager.getRepository(Locomotive);
|
||||
const trainSetRepo = manager.getRepository(TrainSet);
|
||||
const scheduleRepo = manager.getRepository(TrainSchedule);
|
||||
const trainSetWagonRepo = manager.getRepository(TrainSetWagon);
|
||||
const wagonRepo = manager.getRepository(Wagon);
|
||||
|
||||
const departure = addHours(now, scenario.departureOffsetHours);
|
||||
const arrival = addHours(now, scenario.arrivalOffsetHours);
|
||||
const isArrived = scenario.status === 'ARRIVED';
|
||||
const originYard = scenario.direction === 'IMPORT' ? refs.djiboutiYard : refs.ethiopiaYard;
|
||||
const destinationYard = scenario.direction === 'IMPORT' ? refs.ethiopiaYard : refs.djiboutiYard;
|
||||
const totalWeightTons = scenario.bookings.reduce((sum, booking) => sum + booking.weightTons, 0);
|
||||
|
||||
const locomotive =
|
||||
(await locomotiveRepo.findOne({ where: { code: 'GP-DEMO-LOCO' } })) ??
|
||||
(await locomotiveRepo.save(
|
||||
locomotiveRepo.create({
|
||||
code: 'GP-DEMO-LOCO',
|
||||
name: 'Gate Pass Scenario Locomotive',
|
||||
locomotiveType: 'DIESEL',
|
||||
maxPullWeightTons: 4200,
|
||||
maxTrainLengthMeters: 760,
|
||||
status: 'AVAILABLE',
|
||||
currentYardId: originYard.id,
|
||||
}),
|
||||
));
|
||||
|
||||
let schedule = await scheduleRepo.findOne({ where: { trainNumber: scenario.trainNumber } });
|
||||
let trainSet: TrainSet | null = schedule?.trainSetId
|
||||
? await trainSetRepo.findOne({ where: { id: schedule.trainSetId } })
|
||||
: null;
|
||||
|
||||
if (!trainSet) {
|
||||
trainSet = await trainSetRepo.save(
|
||||
trainSetRepo.create({
|
||||
locomotiveId: locomotive.id,
|
||||
totalWeightTons,
|
||||
totalLengthMeters: scenario.bookings.length * 14,
|
||||
wagonCount: scenario.bookings.length,
|
||||
status: isArrived ? 'COMPLETED' : 'ASSIGNED',
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
await trainSetRepo.update(trainSet.id, {
|
||||
locomotiveId: locomotive.id,
|
||||
totalWeightTons,
|
||||
totalLengthMeters: scenario.bookings.length * 14,
|
||||
wagonCount: scenario.bookings.length,
|
||||
status: isArrived ? 'COMPLETED' : 'ASSIGNED',
|
||||
});
|
||||
}
|
||||
if (!trainSet) {
|
||||
throw new Error(`Could not create train set for ${scenario.trainNumber}`);
|
||||
}
|
||||
const trainSetId = trainSet.id;
|
||||
|
||||
if (!schedule) {
|
||||
schedule = scheduleRepo.create({ trainNumber: scenario.trainNumber });
|
||||
}
|
||||
Object.assign(schedule, {
|
||||
trainSetId,
|
||||
originStationId: originYard.id,
|
||||
destinationStationId: destinationYard.id,
|
||||
scheduledDepartureDate: departure,
|
||||
scheduledArrivalDate: arrival,
|
||||
actualDepartureAt: isArrived ? departure : null,
|
||||
actualArrivalAt: isArrived ? arrival : null,
|
||||
status: scenario.status,
|
||||
direction: scenario.direction,
|
||||
maxWagons: 53,
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
});
|
||||
schedule = await scheduleRepo.save(schedule);
|
||||
|
||||
for (const [index, bookingSpec] of scenario.bookings.entries()) {
|
||||
const sequenceNo = index + 1;
|
||||
const wagon = await ensureWagon(manager, scenario, sequenceNo, refs.wagonType.id, originYard.id, schedule.id);
|
||||
const trainSetWagon = await ensureTrainSetWagon(
|
||||
trainSetWagonRepo,
|
||||
trainSetId,
|
||||
refs.wagonType.id,
|
||||
wagon.id,
|
||||
sequenceNo,
|
||||
bookingSpec.weightTons,
|
||||
isArrived,
|
||||
);
|
||||
await wagonRepo.update(wagon.id, { trainSetWagonId: trainSetWagon.id });
|
||||
|
||||
const booking = await ensureBooking(manager, scenario, bookingSpec, refs, departure, now, schedule.id);
|
||||
const bookingContainer = await ensureBookingContainer(manager, booking.id, refs.containerType.id, bookingSpec);
|
||||
const allocation = await ensureAllocation(manager, trainSetWagon.id, booking.id, bookingSpec.weightTons, isArrived, now);
|
||||
const container = await ensureContainer(manager, booking.id, bookingContainer.id, allocation.id, wagon.id, sequenceNo, bookingSpec, refs.containerType.id, isArrived);
|
||||
await ensureContainerItem(manager, allocation.id, bookingContainer.id, container.id, refs.containerType.id, sequenceNo, bookingSpec);
|
||||
await ensureScheduleBooking(manager, schedule.id, booking.id);
|
||||
if (scenario.direction === 'EXPORT') {
|
||||
await ensureExportInventory(manager, refs, booking.id, container.id, bookingSpec.weightTons, isArrived, now);
|
||||
}
|
||||
}
|
||||
|
||||
if (scenario.direction === 'IMPORT') {
|
||||
await ensureImportOperation(manager, schedule.id, scenario, departure, isArrived);
|
||||
}
|
||||
|
||||
return schedule;
|
||||
}
|
||||
|
||||
async function ensureWagon(manager: any, scenario: ScenarioTrain, sequenceNo: number, wagonTypeId: string, yardId: string, scheduleId: string): Promise<Wagon> {
|
||||
const repo = manager.getRepository(Wagon);
|
||||
const wagonNumber = `${scenario.trainNumber}-W${String(sequenceNo).padStart(2, '0')}`;
|
||||
const existing = await repo.findOne({ where: { wagonNumber } });
|
||||
const values = {
|
||||
wagonNumber,
|
||||
wagonTypeId,
|
||||
trainId: null,
|
||||
sequenceNumber: sequenceNo,
|
||||
tareWeight: 20,
|
||||
maxPayloadWeight: 70,
|
||||
status: WagonStatus.Assigned,
|
||||
currentYardId: yardId,
|
||||
currentTrainScheduleId: scheduleId,
|
||||
notes: 'Gate-pass scenario seed wagon',
|
||||
};
|
||||
return repo.save(repo.create({ ...(existing ?? {}), ...values }));
|
||||
}
|
||||
|
||||
async function ensureTrainSetWagon(repo: any, trainSetId: string, wagonTypeId: string, wagonId: string, sequenceNo: number, weightTons: number, isArrived: boolean): Promise<TrainSetWagon> {
|
||||
const existing = await repo.findOne({ where: { trainSetId, sequenceNo } });
|
||||
return repo.save(
|
||||
repo.create({
|
||||
...(existing ?? {}),
|
||||
trainSetId,
|
||||
wagonTypeId,
|
||||
physicalWagonId: wagonId,
|
||||
sequenceNo,
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
assignedWeightTons: weightTons,
|
||||
status: isArrived ? 'DEPARTED' : 'LOADED',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureBooking(manager: any, scenario: ScenarioTrain, bookingSpec: ScenarioTrain['bookings'][number], refs: Awaited<ReturnType<typeof ensureReferences>>, departure: Date, now: Date, scheduleId: string): Promise<Booking> {
|
||||
const repo = manager.getRepository(Booking);
|
||||
const originYard = scenario.direction === 'IMPORT' ? refs.djiboutiYard : refs.ethiopiaYard;
|
||||
const destinationYard = scenario.direction === 'IMPORT' ? refs.ethiopiaYard : refs.djiboutiYard;
|
||||
const existing = await repo.findOne({ where: { reference: bookingSpec.reference } });
|
||||
const profile = scenario.direction === 'IMPORT' ? refs.importerProfile : refs.exporterProfile;
|
||||
const hasFirstMile = bookingSpec.mileVariant === 'FIRST_MILE';
|
||||
const hasLastMile = bookingSpec.mileVariant === 'LAST_MILE';
|
||||
|
||||
return repo.save(
|
||||
repo.create({
|
||||
...(existing ?? {}),
|
||||
reference: bookingSpec.reference,
|
||||
companyId: refs.company.id,
|
||||
companyProfileId: profile.id,
|
||||
originYardId: originYard.id,
|
||||
destinationYardId: destinationYard.id,
|
||||
serviceTypeId: refs.serviceType.id,
|
||||
status: scenario.status === 'ARRIVED' ? 'IN_TRANSIT' : 'PAID',
|
||||
paymentStatus: 'PAID',
|
||||
scheduledDate: departure,
|
||||
estimatedShipmentDate: departure,
|
||||
contractType: 'SPOT',
|
||||
equipmentReturn: 'TERMINAL',
|
||||
paymentCurrency: 'ETB',
|
||||
totalAmount: 0,
|
||||
isGovernment: false,
|
||||
tradeDirection: scenario.direction,
|
||||
freightType: 'CONTAINER',
|
||||
cargoTypeId: null,
|
||||
cargoFreeText: `${scenario.direction} gate-pass scenario ${bookingSpec.mileVariant.toLowerCase().replace('_', ' ')}`,
|
||||
cargoTotalWeightVgm: bookingSpec.weightTons * 1000,
|
||||
firstMilePickupAddress: hasFirstMile ? 'Customer factory pickup - Addis Ababa' : null,
|
||||
firstMilePickupLat: hasFirstMile ? 9.03 : null,
|
||||
firstMilePickupLng: hasFirstMile ? 38.74 : null,
|
||||
lastMileDeliveryAddress: hasLastMile ? 'Customer warehouse delivery - Addis Ababa' : null,
|
||||
lastMileDeliveryLat: hasLastMile ? 8.98 : null,
|
||||
lastMileDeliveryLng: hasLastMile ? 38.8 : null,
|
||||
trainScheduleId: scheduleId,
|
||||
schedulingStatus: scenario.status === 'ARRIVED' ? 'DISPATCHED' : 'SCHEDULED',
|
||||
scheduledAt: now,
|
||||
wagonsRequired: 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureBookingContainer(manager: any, bookingId: string, containerTypeId: string, bookingSpec: ScenarioTrain['bookings'][number]): Promise<BookingContainer> {
|
||||
const repo = manager.getRepository(BookingContainer);
|
||||
const existing = await repo.findOne({ where: { bookingId } });
|
||||
return repo.save(
|
||||
repo.create({
|
||||
...(existing ?? {}),
|
||||
bookingId,
|
||||
containerTypeId,
|
||||
containerNumber: bookingSpec.containerNumber,
|
||||
containerSize: '40',
|
||||
quantity: 1,
|
||||
hazardousQuantity: 0,
|
||||
reeferQuantity: 0,
|
||||
vgmPerUnitTons: bookingSpec.weightTons,
|
||||
totalVgmTons: bookingSpec.weightTons,
|
||||
wagonsRequired: 1,
|
||||
weightLimitRuleId: null,
|
||||
isOverweight: false,
|
||||
overweightExcessTons: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureAllocation(manager: any, trainSetWagonId: string, bookingId: string, weightTons: number, isArrived: boolean, now: Date): Promise<WagonBookingAllocation> {
|
||||
const repo = manager.getRepository(WagonBookingAllocation);
|
||||
const existing = await repo.findOne({ where: { trainSetWagonId, bookingId } });
|
||||
return repo.save(
|
||||
repo.create({
|
||||
...(existing ?? {}),
|
||||
trainSetWagonId,
|
||||
bookingId,
|
||||
allocatedWeightTons: weightTons,
|
||||
loadType: 'CONTAINER',
|
||||
status: isArrived ? 'DEPARTED' : 'LOADED',
|
||||
confirmedAt: now,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureContainer(manager: any, bookingId: string, bookingContainerId: string, allocationId: string, wagonId: string, position: number, bookingSpec: ScenarioTrain['bookings'][number], containerTypeId: string, isArrived: boolean): Promise<Container> {
|
||||
const repo = manager.getRepository(Container);
|
||||
const existing = await repo.findOne({ where: { containerNumber: bookingSpec.containerNumber } });
|
||||
return repo.save(
|
||||
repo.create({
|
||||
...(existing ?? {}),
|
||||
containerNumber: bookingSpec.containerNumber,
|
||||
containerTypeId,
|
||||
wagonId,
|
||||
position,
|
||||
tareWeight: 3800,
|
||||
maxGrossWeight: 30480,
|
||||
sealNumber: `SEAL-${bookingSpec.containerNumber}`,
|
||||
status: isArrived ? 'IN_TRANSIT' : 'LOADED',
|
||||
bookingId,
|
||||
wagonBookingAllocationId: allocationId,
|
||||
bookingContainerId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureContainerItem(manager: any, allocationId: string, bookingContainerId: string, containerId: string, containerTypeId: string, position: number, bookingSpec: ScenarioTrain['bookings'][number]): Promise<void> {
|
||||
const repo = manager.getRepository(WagonAllocationContainerItem);
|
||||
await repo.delete({ wagonBookingAllocationId: allocationId });
|
||||
await repo.save(
|
||||
repo.create({
|
||||
wagonBookingAllocationId: allocationId,
|
||||
bookingContainerId,
|
||||
containerId,
|
||||
containerNumber: bookingSpec.containerNumber,
|
||||
containerTypeId,
|
||||
positionOnWagon: position,
|
||||
sealNumber: `SEAL-${bookingSpec.containerNumber}`,
|
||||
chassisNumber: `CHS-${bookingSpec.containerNumber}`,
|
||||
grossWeightTons: bookingSpec.weightTons,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureScheduleBooking(manager: any, scheduleId: string, bookingId: string): Promise<void> {
|
||||
const repo = manager.getRepository(TrainScheduleBooking);
|
||||
const existing = await repo.findOne({ where: { bookingId } });
|
||||
await repo.save(repo.create({ ...(existing ?? {}), trainScheduleId: scheduleId, bookingId }));
|
||||
}
|
||||
|
||||
async function ensureExportInventory(manager: any, refs: Awaited<ReturnType<typeof ensureReferences>>, bookingId: string, containerId: string, weightTons: number, isArrived: boolean, now: Date): Promise<void> {
|
||||
const repo = manager.getRepository(WarehouseInventory);
|
||||
const existing = await repo.findOne({ where: { bookingId } });
|
||||
await repo.save(
|
||||
repo.create({
|
||||
...(existing ?? {}),
|
||||
warehouseId: refs.warehouse.id,
|
||||
yardId: refs.warehouseYard.id,
|
||||
zoneId: refs.warehouseZone.id,
|
||||
bookingId,
|
||||
containerId,
|
||||
quantity: 1,
|
||||
weight: weightTons * 1000,
|
||||
status: 'LOADED',
|
||||
inspectionStatus: 'PASSED',
|
||||
arrivedAt: addHours(now, -24),
|
||||
inspectedAt: addHours(now, -22),
|
||||
readyForLoadingAt: addHours(now, -20),
|
||||
loadedAt: isArrived ? addHours(now, -16) : null,
|
||||
notes: '[GP-SCENARIO] Export train gate-pass scenario inventory',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureImportOperation(manager: any, scheduleId: string, scenario: ScenarioTrain, departure: Date, isArrived: boolean): Promise<void> {
|
||||
const repo = manager.getRepository(ImportDjiboutiOperation);
|
||||
const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } });
|
||||
await repo.save(
|
||||
repo.create({
|
||||
...(existing ?? {}),
|
||||
trainScheduleId: scheduleId,
|
||||
documents: existing?.documents ?? {},
|
||||
gatepassGrantedAt: null,
|
||||
readyForLoadingAt: null,
|
||||
loadedOnTrainAt: null,
|
||||
departedFromDjiboutiAt: isArrived ? departure : null,
|
||||
loadListGeneratedAt: null,
|
||||
performedBy: 'Gate Pass Scenario Seeder',
|
||||
notes: `[GP-SCENARIO] ${scenario.trainNumber}; fill gate-pass dates during testing`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Gate-pass train scenario seed failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
1131
apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts
Normal file
1131
apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts
Normal file
File diff suppressed because it is too large
Load Diff
1
apps/edr-freight-api/src/types/multer-globals.d.ts
vendored
Normal file
1
apps/edr-freight-api/src/types/multer-globals.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="multer" />
|
||||
@@ -72,6 +72,13 @@ function FeeCard({ fee }: { fee: FeePreview }) {
|
||||
<Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} />
|
||||
<Row label="Containers" value={String(fee.containerCount ?? 1)} />
|
||||
<Row label="Billable units" value={`${fee.billableUnits ?? fee.chargeableDays} container-day(s)`} />
|
||||
{(fee.tiers ?? []).map((tier) => (
|
||||
<Row
|
||||
key={`${tier.appliedFromDay}-${tier.appliedToDay}-${tier.ratePerDay}`}
|
||||
label={`Days ${tier.appliedFromDay}-${tier.appliedToDay}`}
|
||||
value={`${tier.days} x ${money(tier.ratePerDay, fee.currency)} = ${money(tier.amount, fee.currency)}`}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -41,7 +41,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/services/api';
|
||||
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useInventoryInquiry } from '@/hooks/useWarehouses';
|
||||
import { useAllWarehouseYards, useAllWarehouseZones, useInventoryInquiry } from '@/hooks/useWarehouses';
|
||||
import { firstMileService } from '@/services/first-mile.service';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import type {
|
||||
@@ -54,7 +54,10 @@ import type {
|
||||
ReadyToLoadRow,
|
||||
ReceiveInventoryPayload,
|
||||
TruckEntrancePayload,
|
||||
Warehouse,
|
||||
WarehouseInventoryItem,
|
||||
WarehouseYard,
|
||||
WarehouseZone,
|
||||
} from '@/types/warehouse';
|
||||
import { BookingSelect } from './BookingSelect';
|
||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||
@@ -70,12 +73,17 @@ import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions }
|
||||
import { openPdfBlob } from './pdf';
|
||||
import '@/components/overview/overview.css';
|
||||
|
||||
type ImportUnloadAssignment = { bookingId: string; warehouseId: string; yardId: string; zoneId: string };
|
||||
type ImportUnloadAssignmentDraft = Partial<Omit<ImportUnloadAssignment, 'bookingId'>>;
|
||||
|
||||
interface ReceiveInventoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/** When supplied the modal locks to a single booking (legacy single-receive). */
|
||||
bookingId?: string;
|
||||
bookingLabel?: string;
|
||||
mode?: 'single' | 'bulk';
|
||||
direction?: WarehouseFlowDirection;
|
||||
onReceived?: () => void;
|
||||
}
|
||||
|
||||
@@ -142,6 +150,7 @@ interface TruckEntranceFormState {
|
||||
packagingType: string;
|
||||
unitCount: number | '';
|
||||
grossWeightKg: number | '';
|
||||
weighingRequired: boolean | null;
|
||||
netWeightKg: number | '';
|
||||
volumeDimensions: string;
|
||||
conditionAtReceipt: string;
|
||||
@@ -163,11 +172,17 @@ interface LockedTruckEntranceFields {
|
||||
tin?: boolean;
|
||||
edrDigitalBookingId?: boolean;
|
||||
customerPhone?: boolean;
|
||||
truckPlateNumber?: boolean;
|
||||
trailerPlateNumber?: boolean;
|
||||
assignedEquipmentNumber?: boolean;
|
||||
itemDescription?: boolean;
|
||||
packagingType?: boolean;
|
||||
unitCount?: boolean;
|
||||
grossWeightKg?: boolean;
|
||||
driverName?: boolean;
|
||||
driverPhone?: boolean;
|
||||
driverLicenseNumber?: boolean;
|
||||
truckType?: boolean;
|
||||
}
|
||||
|
||||
type PackagingFreightType = 'CONTAINER' | 'BULK' | 'MIXED';
|
||||
@@ -190,6 +205,7 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
|
||||
packagingType: '',
|
||||
unitCount: '',
|
||||
grossWeightKg: '',
|
||||
weighingRequired: null,
|
||||
netWeightKg: '',
|
||||
volumeDimensions: '',
|
||||
conditionAtReceipt: '',
|
||||
@@ -206,13 +222,24 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
|
||||
});
|
||||
|
||||
const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayload => ({
|
||||
ownerName: form.ownerName.trim() || undefined,
|
||||
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,
|
||||
customsSealNumber: form.customsSealNumber.trim() || undefined,
|
||||
declarationNumber: form.declarationNumber.trim() || undefined,
|
||||
incoterms: form.incoterms.trim() || undefined,
|
||||
hsCodes: form.hsCodes.trim() || undefined,
|
||||
itemCode: form.itemCode.trim() || undefined,
|
||||
itemDescription: form.itemDescription.trim() || undefined,
|
||||
packagingType: form.packagingType.trim() || undefined,
|
||||
unitCount: form.unitCount === '' ? undefined : Number(form.unitCount),
|
||||
weighingRequired: form.weighingRequired ?? undefined,
|
||||
grossWeightKg: form.weighingRequired && form.grossWeightKg !== '' ? Number(form.grossWeightKg) : undefined,
|
||||
netWeightKg: form.netWeightKg === '' ? undefined : Number(form.netWeightKg),
|
||||
volumeDimensions: form.volumeDimensions.trim() || undefined,
|
||||
conditionAtReceipt: form.conditionAtReceipt.trim() || undefined,
|
||||
@@ -222,8 +249,11 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
|
||||
driverPhone: form.driverPhone.trim(),
|
||||
driverLicenseNumber: form.driverLicenseNumber.trim() || undefined,
|
||||
truckType: form.truckType.trim() || undefined,
|
||||
entranceTareWeightKg: Number(form.entranceTareWeightKg),
|
||||
exitTareWeightKg: form.exitTareWeightKg === '' ? undefined : Number(form.exitTareWeightKg),
|
||||
entranceTareWeightKg:
|
||||
form.entranceTareWeightKg === ''
|
||||
? undefined
|
||||
: Number(form.entranceTareWeightKg),
|
||||
exitTareWeightKg: form.weighingRequired && form.exitTareWeightKg !== '' ? Number(form.exitTareWeightKg) : undefined,
|
||||
driverSignatoryName: form.driverSignatoryName.trim() || undefined,
|
||||
warehouseManagerName: form.warehouseManagerName.trim() || undefined,
|
||||
});
|
||||
@@ -242,22 +272,31 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
|
||||
const tin = commonNonEmptyValue(bookings.map((booking) => booking.customerTin));
|
||||
const customerPhone = commonNonEmptyValue(bookings.map((booking) => booking.customerPhone));
|
||||
const consigneeDetails = commonNonEmptyValue(bookings.map((booking) => booking.customer));
|
||||
const assignedEquipmentNumber = commonNonEmptyValue(bookings.map((booking) => booking.containerNumber));
|
||||
const assignedEquipmentNumber = commonNonEmptyValue(
|
||||
bookings.map((booking) => booking.customerTruckContainerNumber || booking.containerNumber),
|
||||
);
|
||||
const itemDescription = commonNonEmptyValue(bookings.map((booking) => booking.cargoDescription ?? booking.cargo));
|
||||
const packagingType = commonNonEmptyValue(bookings.map((booking) => booking.containerPackagingType));
|
||||
const truckPlateNumber = commonNonEmptyValue(
|
||||
bookings.map((booking) => booking.firstMileTruckPlateNumber || booking.customerTruckPlateNumber),
|
||||
);
|
||||
const trailerPlateNumber = commonNonEmptyValue(bookings.map((booking) => booking.firstMileTrailerPlateNumber));
|
||||
const driverName = commonNonEmptyValue(
|
||||
bookings.map((booking) => booking.firstMileDriverName || booking.customerTruckDriverName),
|
||||
);
|
||||
const driverPhone = commonNonEmptyValue(bookings.map((booking) => booking.firstMileDriverPhone));
|
||||
const driverLicenseNumber = commonNonEmptyValue(bookings.map((booking) => booking.firstMileDriverLicenseNumber));
|
||||
const truckType = commonNonEmptyValue(
|
||||
bookings.map((booking) => booking.firstMileTruckType || booking.customerTruckType),
|
||||
);
|
||||
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 unitCount =
|
||||
bookings.length === 1 && bookings[0]?.containerQuantity != null
|
||||
? Number(bookings[0].containerQuantity)
|
||||
: '';
|
||||
const grossWeightKg =
|
||||
bookings.length === 1 && bookings[0]?.weight != null
|
||||
? Number(bookings[0].weight)
|
||||
: '';
|
||||
const freightTypes = [...new Set(bookings.map((booking) => booking.freightType).filter(Boolean))];
|
||||
const packagingFreightType =
|
||||
freightTypes.length === 1 && freightTypes[0] === 'CONTAINER'
|
||||
@@ -278,13 +317,13 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
|
||||
itemDescription,
|
||||
packagingType,
|
||||
unitCount,
|
||||
grossWeightKg,
|
||||
truckPlateNumber: firstMileBooking?.firstMileTruckPlateNumber ?? '',
|
||||
trailerPlateNumber: firstMileBooking?.firstMileTrailerPlateNumber ?? '',
|
||||
driverName: firstMileBooking?.firstMileDriverName ?? '',
|
||||
driverPhone: firstMileBooking?.firstMileDriverPhone ?? '',
|
||||
driverLicenseNumber: firstMileBooking?.firstMileDriverLicenseNumber ?? '',
|
||||
truckType: firstMileBooking?.firstMileTruckType ?? '',
|
||||
grossWeightKg: '',
|
||||
truckPlateNumber,
|
||||
trailerPlateNumber,
|
||||
driverName,
|
||||
driverPhone,
|
||||
driverLicenseNumber,
|
||||
truckType,
|
||||
},
|
||||
lockedFields: {
|
||||
ownerName: Boolean(ownerName),
|
||||
@@ -296,7 +335,13 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
|
||||
itemDescription: Boolean(itemDescription),
|
||||
packagingType: Boolean(packagingType),
|
||||
unitCount: unitCount !== '',
|
||||
grossWeightKg: grossWeightKg !== '',
|
||||
grossWeightKg: false,
|
||||
truckPlateNumber: Boolean(truckPlateNumber),
|
||||
trailerPlateNumber: Boolean(trailerPlateNumber),
|
||||
driverName: Boolean(driverName),
|
||||
driverPhone: Boolean(driverPhone),
|
||||
driverLicenseNumber: Boolean(driverLicenseNumber),
|
||||
truckType: Boolean(truckType),
|
||||
},
|
||||
packagingFreightType,
|
||||
};
|
||||
@@ -338,11 +383,13 @@ function TruckEntranceFields({
|
||||
onChange,
|
||||
lockedFields,
|
||||
packagingFreightType = 'MIXED',
|
||||
allowTruckWeighing = true,
|
||||
}: {
|
||||
value: TruckEntranceFormState;
|
||||
onChange: (next: TruckEntranceFormState) => void;
|
||||
lockedFields?: LockedTruckEntranceFields;
|
||||
packagingFreightType?: PackagingFreightType;
|
||||
allowTruckWeighing?: boolean;
|
||||
}) {
|
||||
const packagingOptions = packagingOptionsFor(packagingFreightType);
|
||||
const quantityLabel =
|
||||
@@ -396,11 +443,13 @@ function TruckEntranceFields({
|
||||
label="Truck plate number"
|
||||
required
|
||||
value={value.truckPlateNumber}
|
||||
readOnly={lockedFields?.truckPlateNumber}
|
||||
onChange={(e) => onChange({ ...value, truckPlateNumber: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Trailer plate number"
|
||||
value={value.trailerPlateNumber}
|
||||
readOnly={lockedFields?.trailerPlateNumber}
|
||||
onChange={(e) => onChange({ ...value, trailerPlateNumber: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
@@ -422,12 +471,14 @@ function TruckEntranceFields({
|
||||
label="Driver name"
|
||||
required
|
||||
value={value.driverName}
|
||||
readOnly={lockedFields?.driverName}
|
||||
onChange={(e) => onChange({ ...value, driverName: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Driver phone"
|
||||
required
|
||||
value={value.driverPhone}
|
||||
readOnly={lockedFields?.driverPhone}
|
||||
onChange={(e) => onChange({ ...value, driverPhone: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
@@ -435,29 +486,61 @@ function TruckEntranceFields({
|
||||
<TextInput
|
||||
label="Driver license number"
|
||||
value={value.driverLicenseNumber}
|
||||
readOnly={lockedFields?.driverLicenseNumber}
|
||||
onChange={(e) => onChange({ ...value, driverLicenseNumber: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Truck type"
|
||||
value={value.truckType}
|
||||
readOnly={lockedFields?.truckType}
|
||||
onChange={(e) => onChange({ ...value, truckType: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Entrance tare weight (kg)"
|
||||
required
|
||||
min={0}
|
||||
value={value.entranceTareWeightKg}
|
||||
onChange={(v) => onChange({ ...value, entranceTareWeightKg: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Exit tare weight (kg)"
|
||||
min={0}
|
||||
value={value.exitTareWeightKg}
|
||||
onChange={(v) => onChange({ ...value, exitTareWeightKg: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
</Group>
|
||||
{allowTruckWeighing ? (
|
||||
<>
|
||||
<Select
|
||||
label="Weighing"
|
||||
required
|
||||
data={[
|
||||
{ value: 'YES', label: 'Yes' },
|
||||
{ value: 'NO', label: 'No' },
|
||||
]}
|
||||
value={value.weighingRequired == null ? null : value.weighingRequired ? 'YES' : 'NO'}
|
||||
onChange={(next) =>
|
||||
onChange({
|
||||
...value,
|
||||
weighingRequired: next === 'YES' ? true : next === 'NO' ? false : null,
|
||||
grossWeightKg: next === 'YES' ? value.grossWeightKg : '',
|
||||
exitTareWeightKg: next === 'YES' ? value.exitTareWeightKg : '',
|
||||
})
|
||||
}
|
||||
/>
|
||||
{value.weighingRequired && (
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Gross weight (kg)"
|
||||
required
|
||||
min={0}
|
||||
value={value.grossWeightKg}
|
||||
onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Exit tare weight (kg)"
|
||||
required
|
||||
min={0}
|
||||
value={value.exitTareWeightKg}
|
||||
onChange={(v) => onChange({ ...value, exitTareWeightKg: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Alert icon={<Info size={16} />} color="green" variant="light">
|
||||
<Text size="sm">
|
||||
Truck weighing is not required for a received first-mile arrival. The GRN uses the booking weight.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Text size="sm" fw={600} mt="xs">Customs and compliance</Text>
|
||||
<Group grow>
|
||||
@@ -510,21 +593,12 @@ function TruckEntranceFields({
|
||||
onChange={(v) => onChange({ ...value, unitCount: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Gross weight (kg)"
|
||||
min={0}
|
||||
value={value.grossWeightKg}
|
||||
readOnly={lockedFields?.grossWeightKg}
|
||||
onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Net weight (kg)"
|
||||
min={0}
|
||||
value={value.netWeightKg}
|
||||
onChange={(v) => onChange({ ...value, netWeightKg: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
</Group>
|
||||
<NumberInput
|
||||
label="Net weight (kg)"
|
||||
min={0}
|
||||
value={value.netWeightKg}
|
||||
onChange={(v) => onChange({ ...value, netWeightKg: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Volume / dimensions"
|
||||
value={value.volumeDimensions}
|
||||
@@ -650,11 +724,15 @@ function EligibleTab({
|
||||
location,
|
||||
enabled,
|
||||
onChanged,
|
||||
focusedBookingId,
|
||||
focusedBookingLabel,
|
||||
}: {
|
||||
direction: 'IMPORT' | 'EXPORT';
|
||||
location: Location;
|
||||
enabled: boolean;
|
||||
onChanged?: () => void;
|
||||
focusedBookingId?: string;
|
||||
focusedBookingLabel?: string;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
@@ -664,7 +742,15 @@ function EligibleTab({
|
||||
enabled,
|
||||
}),
|
||||
);
|
||||
const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]);
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
allRows.filter(
|
||||
(r) =>
|
||||
r.direction === direction &&
|
||||
(!focusedBookingId || r.id === focusedBookingId),
|
||||
),
|
||||
[allRows, direction, focusedBookingId],
|
||||
);
|
||||
const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions());
|
||||
const requestFirstMile = useMutation({
|
||||
mutationFn: (reference: string) => firstMileService.accept(reference),
|
||||
@@ -766,6 +852,8 @@ function EligibleTab({
|
||||
[pendingReceiveIds, rows],
|
||||
);
|
||||
const pendingHasFirstMile = pendingReceiveRows.some((row) => row.hasFirstMile);
|
||||
const pendingUsesFirstMile =
|
||||
pendingReceiveRows.length > 0 && pendingReceiveRows.every((row) => row.hasFirstMile);
|
||||
|
||||
const toggleAll = () =>
|
||||
setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id)));
|
||||
@@ -788,6 +876,18 @@ function EligibleTab({
|
||||
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
|
||||
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined),
|
||||
});
|
||||
const firstGrn = r.results.find((item) => item.inventoryId && item.grnNumber);
|
||||
if (focusedBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) {
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const response = await warehouseService.downloadGrnDocument(firstGrn.inventoryId);
|
||||
const opened = openPdfBlob(response.data, `grn-${firstGrn.grnNumber}.pdf`, pdfWindow);
|
||||
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
setSelected(new Set());
|
||||
setTruckOpen(false);
|
||||
setPendingReceiveIds([]);
|
||||
@@ -822,32 +922,65 @@ function EligibleTab({
|
||||
void receiveBookings(filteredIds);
|
||||
return;
|
||||
}
|
||||
const hasFirstMileRows = selectedRows.some((row) => row.hasFirstMile);
|
||||
const hasCustomerTruckRows = selectedRows.some((row) => !row.hasFirstMile);
|
||||
if (hasFirstMileRows && hasCustomerTruckRows) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Receive separately',
|
||||
description: 'First-mile arrivals and customer-truck arrivals use different truck evidence. Select one group at a time.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
|
||||
const totalContainerQuantity = selectedRows.reduce(
|
||||
(sum, row) => sum + Number(row.containerQuantity ?? 0),
|
||||
0,
|
||||
);
|
||||
const usesFirstMile = selectedRows.length > 0 && selectedRows.every((row) => row.hasFirstMile);
|
||||
const usesCustomerAssignedTruck =
|
||||
selectedRows.length > 0 &&
|
||||
selectedRows.every((row) => !row.hasFirstMile && Boolean(row.customerTruckAssignedAt));
|
||||
const normalizedForm =
|
||||
nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0
|
||||
? {
|
||||
...form,
|
||||
unitCount: totalContainerQuantity,
|
||||
}
|
||||
: form;
|
||||
: {
|
||||
...form,
|
||||
};
|
||||
setPendingReceiveIds(filteredIds);
|
||||
setReceivedAt(new Date().toISOString());
|
||||
setTruckForm(normalizedForm);
|
||||
setLockedTruckFields({
|
||||
...lockedFields,
|
||||
unitCount: nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0,
|
||||
assignedEquipmentNumber: usesCustomerAssignedTruck
|
||||
? lockedFields.assignedEquipmentNumber
|
||||
: lockedFields.assignedEquipmentNumber,
|
||||
truckPlateNumber: (usesFirstMile || usesCustomerAssignedTruck) && lockedFields.truckPlateNumber,
|
||||
trailerPlateNumber: usesFirstMile && lockedFields.trailerPlateNumber,
|
||||
driverName: (usesFirstMile || usesCustomerAssignedTruck) && lockedFields.driverName,
|
||||
driverPhone: usesFirstMile && lockedFields.driverPhone,
|
||||
driverLicenseNumber: usesFirstMile && lockedFields.driverLicenseNumber,
|
||||
truckType: (usesFirstMile || usesCustomerAssignedTruck) && lockedFields.truckType,
|
||||
});
|
||||
setPackagingFreightType(nextPackagingFreightType);
|
||||
setTruckOpen(true);
|
||||
};
|
||||
|
||||
const receive = async () => {
|
||||
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') {
|
||||
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
|
||||
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim()) {
|
||||
toast({ variant: 'destructive', title: 'Truck and driver information are required' });
|
||||
return;
|
||||
}
|
||||
if (!pendingUsesFirstMile && truckForm.weighingRequired == null) {
|
||||
toast({ variant: 'destructive', title: 'Select whether customer truck weighing is required' });
|
||||
return;
|
||||
}
|
||||
if (truckForm.weighingRequired && (truckForm.grossWeightKg === '' || truckForm.exitTareWeightKg === '')) {
|
||||
toast({ variant: 'destructive', title: 'Gross weight and exit tare weight are required when weighing is Yes' });
|
||||
return;
|
||||
}
|
||||
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
|
||||
@@ -906,7 +1039,9 @@ function EligibleTab({
|
||||
</Group>
|
||||
) : statusFilteredRows.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="lg" size="sm">
|
||||
No eligible PAID {direction.toLowerCase()} bookings to receive.
|
||||
{focusedBookingLabel
|
||||
? `${focusedBookingLabel} is not eligible for warehouse receiving yet.`
|
||||
: `No eligible PAID ${direction.toLowerCase()} bookings to receive.`}
|
||||
</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1700}>
|
||||
@@ -1053,8 +1188,8 @@ function EligibleTab({
|
||||
<Stack gap="md">
|
||||
<Alert icon={<Truck size={16} />} color={pendingHasFirstMile ? 'green' : 'blue'} variant="light">
|
||||
<Text size="sm">
|
||||
{pendingHasFirstMile
|
||||
? 'Assigned first-mile truck and driver details are prefilled. Confirm or update the actual arrival details before GRN.'
|
||||
{pendingUsesFirstMile
|
||||
? 'Received first-mile truck and driver details are pulled from the first-mile record. GRN uses booking cargo, quantity and weight.'
|
||||
: 'Register the customer or third-party truck and driver before export receiving and GRN.'}
|
||||
</Text>
|
||||
</Alert>
|
||||
@@ -1109,6 +1244,7 @@ function EligibleTab({
|
||||
onChange={setTruckForm}
|
||||
lockedFields={lockedTruckFields}
|
||||
packagingFreightType={packagingFreightType}
|
||||
allowTruckWeighing={!pendingUsesFirstMile}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
|
||||
@@ -1611,14 +1747,59 @@ function LoadedExportTab({
|
||||
);
|
||||
}
|
||||
|
||||
/** Assigned bookings/items for an arrived import train (read-only detail view). */
|
||||
function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
|
||||
const importLocationTypesForFreight = (freightType: string | null | undefined) => {
|
||||
const normalized = (freightType ?? '').toUpperCase();
|
||||
if (normalized === 'CONTAINER') {
|
||||
return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] };
|
||||
}
|
||||
return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] };
|
||||
};
|
||||
|
||||
const isImportContainerFreight = (freightType: string | null | undefined) =>
|
||||
(freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
|
||||
const isImportUnloadPending = (item: ImportTrainItem) =>
|
||||
!item.currentStatus || item.currentStatus === 'RECEIVED';
|
||||
|
||||
/** Assigned bookings/items for an arrived import train with per-booking unload locations. */
|
||||
function ImportTrainDetailTable({
|
||||
train,
|
||||
warehouses,
|
||||
yards,
|
||||
zones,
|
||||
assignments,
|
||||
onAssignmentChange,
|
||||
onReadyChange,
|
||||
}: {
|
||||
train: ImportTrain;
|
||||
warehouses: Warehouse[];
|
||||
yards: WarehouseYard[];
|
||||
zones: WarehouseZone[];
|
||||
assignments: Record<string, ImportUnloadAssignmentDraft>;
|
||||
onAssignmentChange: (bookingId: string, draft: ImportUnloadAssignmentDraft) => void;
|
||||
onReadyChange: (ready: boolean) => void;
|
||||
}) {
|
||||
const { data: items = [], isLoading } = useQuery(
|
||||
api.warehouses.importTrainItems.queryOptions({
|
||||
input: { scheduleId: train.scheduleId },
|
||||
enabled: Boolean(train.scheduleId),
|
||||
}),
|
||||
);
|
||||
const warehouseOptions = useMemo(
|
||||
() => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
||||
[warehouses],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = items.filter(isImportUnloadPending);
|
||||
onReadyChange(
|
||||
pending.length > 0 &&
|
||||
pending.every((item) => {
|
||||
const draft = assignments[item.bookingId];
|
||||
return Boolean(draft?.warehouseId && draft.yardId && draft.zoneId);
|
||||
}),
|
||||
);
|
||||
}, [assignments, items]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -1648,6 +1829,9 @@ function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
|
||||
<Table.Th>Cargo Type</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Arrival</Table.Th>
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
<Table.Th>Zone</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
<Table.Th>Current Status</Table.Th>
|
||||
<Table.Th>Last Mile</Table.Th>
|
||||
@@ -1655,7 +1839,18 @@ function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((it: ImportTrainItem) => (
|
||||
{items.map((it: ImportTrainItem) => {
|
||||
const draft = assignments[it.bookingId] ?? {};
|
||||
const { yardTypes, zoneTypes } = importLocationTypesForFreight(it.freightType);
|
||||
const yardOptions = yards
|
||||
.filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type))
|
||||
.map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
|
||||
const zoneOptions = zones
|
||||
.filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type))
|
||||
.map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` }));
|
||||
const pending = isImportUnloadPending(it);
|
||||
|
||||
return (
|
||||
<Table.Tr key={`${it.bookingId}-${it.sequenceNo ?? 'wagon'}`}>
|
||||
<Table.Td>
|
||||
<Text size="xs" fw={600}>
|
||||
@@ -1676,6 +1871,41 @@ function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
|
||||
<Table.Td>{it.cargoType ?? '—'}</Table.Td>
|
||||
<Table.Td>{formatNumber(Number(it.weight))}</Table.Td>
|
||||
<Table.Td>{formatDate(it.arrivalTime)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder="Warehouse"
|
||||
data={warehouseOptions}
|
||||
value={draft.warehouseId ?? null}
|
||||
onChange={(value) => onAssignmentChange(it.bookingId, { warehouseId: value ?? undefined })}
|
||||
searchable
|
||||
disabled={!pending}
|
||||
w={210}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder={isImportContainerFreight(it.freightType) ? 'Container yard' : 'Bulk yard'}
|
||||
data={yardOptions}
|
||||
value={draft.yardId ?? null}
|
||||
onChange={(value) =>
|
||||
onAssignmentChange(it.bookingId, { ...draft, yardId: value ?? undefined, zoneId: undefined })
|
||||
}
|
||||
searchable
|
||||
disabled={!pending || !draft.warehouseId}
|
||||
w={190}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder={isImportContainerFreight(it.freightType) ? 'Container zone' : 'Bulk zone'}
|
||||
data={zoneOptions}
|
||||
value={draft.zoneId ?? null}
|
||||
onChange={(value) => onAssignmentChange(it.bookingId, { ...draft, zoneId: value ?? undefined })}
|
||||
searchable
|
||||
disabled={!pending || !draft.yardId}
|
||||
w={190}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color={it.inspectionStatus === 'PASSED' ? 'green' : 'gray'}>
|
||||
{it.inspectionStatus ?? 'Not inspected'}
|
||||
@@ -1691,7 +1921,8 @@ function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
|
||||
</Table.Td>
|
||||
<Table.Td>{it.pickupOption}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
@@ -1715,13 +1946,42 @@ function ImportArriveQueueTab({
|
||||
const { data: trains = [], isLoading } = useQuery(
|
||||
api.warehouses.importArriveQueue.queryOptions({ enabled }),
|
||||
);
|
||||
const { data: warehouses = [], isLoading: warehousesLoading } = useQuery(
|
||||
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } }, enabled }),
|
||||
);
|
||||
const { data: yards = [] } = useAllWarehouseYards();
|
||||
const { data: zones = [] } = useAllWarehouseZones();
|
||||
const autoUnloadMutation = useMutation(
|
||||
api.warehouses.autoUnloadArrivedBookings.mutationOptions(),
|
||||
);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [assignmentsBySchedule, setAssignmentsBySchedule] = useState<
|
||||
Record<string, Record<string, ImportUnloadAssignmentDraft>>
|
||||
>({});
|
||||
const [readyBySchedule, setReadyBySchedule] = useState<Record<string, boolean>>({});
|
||||
|
||||
const autoUnload = async (train: ImportTrain) => {
|
||||
const assignments = Object.entries(assignmentsBySchedule[train.scheduleId] ?? {})
|
||||
.filter((entry): entry is [string, Required<ImportUnloadAssignmentDraft>] =>
|
||||
Boolean(entry[1].warehouseId && entry[1].yardId && entry[1].zoneId),
|
||||
)
|
||||
.map(([bookingId, draft]) => ({
|
||||
bookingId,
|
||||
warehouseId: draft.warehouseId,
|
||||
yardId: draft.yardId,
|
||||
zoneId: draft.zoneId,
|
||||
}));
|
||||
|
||||
if (!readyBySchedule[train.scheduleId] || assignments.length === 0) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Assign locations',
|
||||
description: 'Select warehouse, yard and zone for each pending booking before unloading.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFullyUnloaded(train)) {
|
||||
toast({
|
||||
title: 'Already unloaded',
|
||||
@@ -1732,7 +1992,7 @@ function ImportArriveQueueTab({
|
||||
|
||||
setBusyId(train.scheduleId);
|
||||
try {
|
||||
const r = await autoUnloadMutation.mutateAsync(train.scheduleId);
|
||||
const r = await autoUnloadMutation.mutateAsync({ scheduleId: train.scheduleId, assignments });
|
||||
const alreadyUnloaded = r.unloadedCount === 0 && r.skippedCount > 0 && r.failedCount === 0;
|
||||
const firstReason = r.results.find((item) => item.reason)?.reason;
|
||||
const extra = [
|
||||
@@ -1831,7 +2091,7 @@ function ImportArriveQueueTab({
|
||||
color={fullyUnloaded ? 'gray' : 'indigo'}
|
||||
leftSection={<Truck size={14} />}
|
||||
loading={busyId === t.scheduleId}
|
||||
disabled={fullyUnloaded || t.totalBookings === 0}
|
||||
disabled={fullyUnloaded || t.totalBookings === 0 || !readyBySchedule[t.scheduleId] || warehousesLoading}
|
||||
onClick={() => autoUnload(t)}
|
||||
>
|
||||
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload Arrived Bookings'}
|
||||
@@ -1842,7 +2102,25 @@ function ImportArriveQueueTab({
|
||||
{isOpen && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={11} bg="var(--mantine-color-gray-0)">
|
||||
<ImportTrainDetailTable train={t} />
|
||||
<ImportTrainDetailTable
|
||||
train={t}
|
||||
warehouses={warehouses}
|
||||
yards={yards}
|
||||
zones={zones}
|
||||
assignments={assignmentsBySchedule[t.scheduleId] ?? {}}
|
||||
onAssignmentChange={(bookingId, draft) =>
|
||||
setAssignmentsBySchedule((current) => ({
|
||||
...current,
|
||||
[t.scheduleId]: {
|
||||
...(current[t.scheduleId] ?? {}),
|
||||
[bookingId]: draft.warehouseId ? draft : {},
|
||||
},
|
||||
}))
|
||||
}
|
||||
onReadyChange={(ready) =>
|
||||
setReadyBySchedule((current) => ({ ...current, [t.scheduleId]: ready }))
|
||||
}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
@@ -1934,6 +2212,11 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
reference: row.bookingReference ?? row.bookingId,
|
||||
tradeDirection: 'IMPORT',
|
||||
lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null,
|
||||
customerTruckPlateNumber: row.customerTruckPlateNumber,
|
||||
customerTruckDriverName: row.customerTruckDriverName,
|
||||
customerTruckType: row.customerTruckType,
|
||||
customerTruckContainerNumber: row.customerTruckContainerNumber,
|
||||
customerTruckAssignedAt: row.customerTruckAssignedAt,
|
||||
}
|
||||
: null,
|
||||
}) as unknown as WarehouseInventoryItem;
|
||||
@@ -2262,6 +2545,8 @@ interface WarehouseFlowWorkbenchProps {
|
||||
direction?: WarehouseFlowDirection;
|
||||
enabled?: boolean;
|
||||
onChanged?: () => void;
|
||||
focusedBookingId?: string;
|
||||
focusedBookingLabel?: string;
|
||||
}
|
||||
|
||||
function WarehouseQueueTabs<TValue extends string>({
|
||||
@@ -2481,10 +2766,14 @@ function ExportWarehouseTabs({
|
||||
enabled,
|
||||
location,
|
||||
onChanged,
|
||||
focusedBookingId,
|
||||
focusedBookingLabel,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
location: Location;
|
||||
onChanged?: () => void;
|
||||
focusedBookingId?: string;
|
||||
focusedBookingLabel?: string;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<ExportWarehouseTab>('receive-queue');
|
||||
const { data: eligibleRows = [] } = useQuery(
|
||||
@@ -2543,7 +2832,14 @@ function ExportWarehouseTabs({
|
||||
<WarehouseQueueTabs value={activeTab} onChange={setActiveTab} tabs={tabs} />
|
||||
|
||||
{activeTab === 'receive-queue' && (
|
||||
<EligibleTab direction="EXPORT" location={location} enabled={enabled} onChanged={onChanged} />
|
||||
<EligibleTab
|
||||
direction="EXPORT"
|
||||
location={location}
|
||||
enabled={enabled}
|
||||
onChanged={onChanged}
|
||||
focusedBookingId={focusedBookingId}
|
||||
focusedBookingLabel={focusedBookingLabel}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'received' && (
|
||||
<ExportReceivedTab enabled={enabled} onChanged={onChanged} />
|
||||
@@ -2568,6 +2864,8 @@ export function WarehouseFlowWorkbench({
|
||||
direction = 'BOTH',
|
||||
enabled = true,
|
||||
onChanged,
|
||||
focusedBookingId,
|
||||
focusedBookingLabel,
|
||||
}: WarehouseFlowWorkbenchProps) {
|
||||
const [location, setLocation] = useState<Location>({ warehouseId: '', yardId: '', zoneId: '' });
|
||||
const [tab, setTab] = useState<Exclude<WarehouseFlowDirection, 'BOTH'>>(
|
||||
@@ -2600,24 +2898,42 @@ export function WarehouseFlowWorkbench({
|
||||
<ImportWarehouseTabs enabled={enabled} onChanged={onChanged} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="EXPORT">
|
||||
<ExportWarehouseTabs enabled={enabled} location={location} onChanged={onChanged} />
|
||||
<ExportWarehouseTabs
|
||||
enabled={enabled}
|
||||
location={location}
|
||||
onChanged={onChanged}
|
||||
focusedBookingId={focusedBookingId}
|
||||
focusedBookingLabel={focusedBookingLabel}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
) : activeDirection === 'IMPORT' ? (
|
||||
<ImportWarehouseTabs enabled={enabled} onChanged={onChanged} />
|
||||
) : (
|
||||
<ExportWarehouseTabs enabled={enabled} location={location} onChanged={onChanged} />
|
||||
<ExportWarehouseTabs
|
||||
enabled={enabled}
|
||||
location={location}
|
||||
onChanged={onChanged}
|
||||
focusedBookingId={focusedBookingId}
|
||||
focusedBookingLabel={focusedBookingLabel}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** New bulk Receive: Import / Export tabs with eligible PAID bookings. */
|
||||
function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) {
|
||||
function BulkReceiveModal({ opened, onClose, onReceived, bookingId, bookingLabel, direction = 'BOTH' }: ReceiveInventoryModalProps) {
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Receive at warehouse" centered size="80rem">
|
||||
<Stack gap="md">
|
||||
<WarehouseFlowWorkbench enabled={opened} direction="BOTH" onChanged={onReceived} />
|
||||
<WarehouseFlowWorkbench
|
||||
enabled={opened}
|
||||
direction={direction}
|
||||
onChanged={onReceived}
|
||||
focusedBookingId={bookingId}
|
||||
focusedBookingLabel={bookingLabel}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
@@ -2763,5 +3079,5 @@ function SingleBookingReceiveModal({
|
||||
|
||||
export function ReceiveInventoryModal(props: ReceiveInventoryModalProps) {
|
||||
// Locked to a booking → legacy single receive; otherwise the Import/Export bulk flow.
|
||||
return props.bookingId ? <SingleBookingReceiveModal {...props} /> : <BulkReceiveModal {...props} />;
|
||||
return props.bookingId && props.mode !== 'bulk' ? <SingleBookingReceiveModal {...props} /> : <BulkReceiveModal {...props} />;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,17 @@ interface ReleaseOrderModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
item: WarehouseInventoryItem | null;
|
||||
truckPrefill?: ReleaseOrderTruckPrefill | null;
|
||||
}
|
||||
|
||||
export interface ReleaseOrderTruckPrefill {
|
||||
truckPlateNumber?: string | null;
|
||||
trailerPlateNumber?: string | null;
|
||||
driverName?: string | null;
|
||||
driverLicense?: string | null;
|
||||
driverPhone?: string | null;
|
||||
truckType?: string | null;
|
||||
containerNumber?: string | null;
|
||||
}
|
||||
|
||||
const REGISTERED_FIRST_LAST_MILE_TRUCKS = [
|
||||
@@ -82,6 +93,9 @@ const splitContainerNumbers = (value: string | null | undefined) =>
|
||||
const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
|
||||
(item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? '';
|
||||
|
||||
const assignedTruckValue = (item: WarehouseInventoryItem | null, key: keyof NonNullable<WarehouseInventoryItem['booking']>) =>
|
||||
item?.booking?.[key] == null ? '' : String(item.booking[key]);
|
||||
|
||||
const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => {
|
||||
const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
|
||||
?.booking?.freightType;
|
||||
@@ -117,7 +131,7 @@ const parseInspectionNote = (notes: string | null | undefined) => {
|
||||
};
|
||||
};
|
||||
|
||||
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
|
||||
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
|
||||
const { toast } = useToast();
|
||||
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
||||
const [reference, setReference] = useState('');
|
||||
@@ -138,25 +152,34 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
const inspection = parseInspectionNote(item?.notes);
|
||||
const assignedTruckPlate = assignedTruckValue(item, 'customerTruckPlateNumber');
|
||||
const assignedDriverName = assignedTruckValue(item, 'customerTruckDriverName');
|
||||
const assignedTruckType = assignedTruckValue(item, 'customerTruckType');
|
||||
const assignedContainerNumber = assignedTruckValue(item, 'customerTruckContainerNumber');
|
||||
const prefillContainerNumber = truckPrefill?.containerNumber ?? '';
|
||||
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
|
||||
setTruckPlateNumber(inspection.truckPlateNumber);
|
||||
setTrailerPlateNumber(inspection.trailerPlateNumber);
|
||||
setDriverName(inspection.driverName);
|
||||
setDriverLicense(inspection.driverLicense);
|
||||
setDriverPhone(inspection.driverPhone);
|
||||
setTruckType(inspection.truckType);
|
||||
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber));
|
||||
setTruckPlateNumber(inspection.truckPlateNumber || truckPrefill?.truckPlateNumber || assignedTruckPlate || '');
|
||||
setTrailerPlateNumber(inspection.trailerPlateNumber || truckPrefill?.trailerPlateNumber || '');
|
||||
setDriverName(inspection.driverName || truckPrefill?.driverName || assignedDriverName || '');
|
||||
setDriverLicense(inspection.driverLicense || truckPrefill?.driverLicense || '');
|
||||
setDriverPhone(inspection.driverPhone || truckPrefill?.driverPhone || '');
|
||||
setTruckType(inspection.truckType || truckPrefill?.truckType || assignedTruckType || '');
|
||||
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber));
|
||||
setGateInTime(inspection.gateInTime);
|
||||
setTareWeight(inspection.tareWeight);
|
||||
setGrossWeight(inspection.grossWeight);
|
||||
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
|
||||
setGateOutTime(inspection.gateOutTime);
|
||||
}
|
||||
}, [opened, item]);
|
||||
}, [opened, item, truckPrefill]);
|
||||
|
||||
const savedInspection = parseInspectionNote(item?.notes);
|
||||
const isExitStep = savedInspection.tareWeight !== '';
|
||||
const isEntranceLocked = isExitStep;
|
||||
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
||||
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
|
||||
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
|
||||
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
|
||||
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
|
||||
const computedNetWeight =
|
||||
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
||||
@@ -269,7 +292,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
searchable
|
||||
clearable
|
||||
data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
|
||||
disabled={isEntranceLocked}
|
||||
disabled={isTruckIdentityLocked}
|
||||
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||||
onChange={(value) => {
|
||||
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value);
|
||||
@@ -283,7 +306,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
required
|
||||
value={truckPlateNumber}
|
||||
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
|
||||
readOnly={isEntranceLocked}
|
||||
readOnly={isTruckIdentityLocked}
|
||||
/>
|
||||
<TextInput
|
||||
label="Trailer plate number"
|
||||
@@ -293,12 +316,12 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isDriverNameLocked} />
|
||||
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Stack gap={6}>
|
||||
@@ -313,7 +336,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
|
||||
)
|
||||
}
|
||||
readOnly={isEntranceLocked}
|
||||
readOnly={isTruckIdentityLocked}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -479,6 +479,7 @@ export const URL_CONSTANTS = {
|
||||
RECEIPT: (id: string) => `/warehouse-fee-invoices/${id}/receipt`,
|
||||
CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`,
|
||||
PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`,
|
||||
PAY_ONLINE: (id: string) => `/warehouse-fee-invoices/${id}/pay-online`,
|
||||
GENERATE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/generate-fee-invoice`,
|
||||
FOR_INVENTORY: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-invoices`,
|
||||
FOR_BOOKING: (bookingId: string) => `/bookings/${bookingId}/warehouse-fee-invoices`,
|
||||
|
||||
@@ -280,7 +280,13 @@ export function useImportTrainItems(scheduleId?: string) {
|
||||
|
||||
/** Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED). */
|
||||
export const useAutoUnloadArrivedBookings = () =>
|
||||
useInventoryMutation((scheduleId: string) => warehouseService.autoUnloadArrivedBookings(scheduleId));
|
||||
useInventoryMutation((payload: {
|
||||
scheduleId: string;
|
||||
warehouseId?: string;
|
||||
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
|
||||
}) =>
|
||||
warehouseService.autoUnloadArrivedBookings(payload),
|
||||
);
|
||||
|
||||
/** Arrived EXPORT trains at Djibouti-side ports. Read-only. */
|
||||
export function useExportDjiboutiArrivalQueue(enabled = true) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ChevronRight,
|
||||
Eye,
|
||||
MoreHorizontal,
|
||||
PackageCheck,
|
||||
Printer,
|
||||
RefreshCw,
|
||||
Ruler,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { FirstMileContainerAllocationTable } from "@/components/FirstMileContainerAllocationTable";
|
||||
import { ReceiveInventoryModal } from "@/components/warehouses/ReceiveInventoryModal";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
@@ -338,6 +340,8 @@ const FirstMilePage = () => {
|
||||
const [distanceValue, setDistanceValue] = useState("");
|
||||
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
||||
const [invoiceRecord, setInvoiceRecord] = useState<FirstMileRecord | null>(null);
|
||||
const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false);
|
||||
const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState<FirstMileRecord | null>(null);
|
||||
|
||||
const [containerAllocationOpen, setContainerAllocationOpen] = useState(false);
|
||||
const [containerAllocationFirstMileId, setContainerAllocationFirstMileId] = useState<string | null>(null);
|
||||
@@ -527,6 +531,16 @@ const FirstMilePage = () => {
|
||||
setInvoiceRecord(null);
|
||||
};
|
||||
|
||||
const openWarehouseReceive = (record: FirstMileRecord) => {
|
||||
setWarehouseReceiveRecord(record);
|
||||
setWarehouseReceiveOpen(true);
|
||||
};
|
||||
|
||||
const closeWarehouseReceive = () => {
|
||||
setWarehouseReceiveOpen(false);
|
||||
setWarehouseReceiveRecord(null);
|
||||
};
|
||||
|
||||
const openContainerAllocation = (firstMileId: string) => {
|
||||
setContainerAllocationFirstMileId(firstMileId);
|
||||
setContainerAllocationOpen(true);
|
||||
@@ -829,6 +843,7 @@ const FirstMilePage = () => {
|
||||
const assigned = isAssigned(row.original);
|
||||
const nextStatus = NEXT_STATUS[row.original.status];
|
||||
const canPrint = row.original.status !== "PAYMENT_PENDING";
|
||||
const canReceiveToWarehouse = row.original.status === "RECEIVED_TO_PORT";
|
||||
return (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<Menu position="bottom-end" width={200} withinPortal>
|
||||
@@ -867,6 +882,13 @@ const FirstMilePage = () => {
|
||||
>
|
||||
View detail
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<PackageCheck size={15} />}
|
||||
disabled={!canReceiveToWarehouse}
|
||||
onClick={() => openWarehouseReceive(row.original)}
|
||||
>
|
||||
Receive to warehouse
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Ruler size={15} />}
|
||||
onClick={() => openDistance(row.original.id)}
|
||||
@@ -1055,6 +1077,19 @@ const FirstMilePage = () => {
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<ReceiveInventoryModal
|
||||
opened={warehouseReceiveOpen}
|
||||
onClose={closeWarehouseReceive}
|
||||
mode="bulk"
|
||||
direction="EXPORT"
|
||||
bookingId={warehouseReceiveRecord?.bookingId}
|
||||
bookingLabel={warehouseReceiveRecord ? bookingRef(warehouseReceiveRecord) : undefined}
|
||||
onReceived={() => {
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
|
||||
closeWarehouseReceive();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Accept Booking modal — step 1: booking list, step 2: details + vehicle */}
|
||||
<Modal
|
||||
opened={acceptOpen}
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
TextInput,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import type { ArrivalQueueItem } from "@/types/warehouse";
|
||||
import type { ArrivalQueueItem, ImportUnloadedItem, WarehouseInventoryItem } from "@/types/warehouse";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
@@ -44,8 +44,10 @@ import {
|
||||
lastMileService,
|
||||
} from "@/services/last-mile.service";
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { driversService, type Driver } from "@/services/drivers.service";
|
||||
import { ratesService } from "@/services/rates.service";
|
||||
import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable";
|
||||
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
||||
import { api } from "@/auth/http";
|
||||
|
||||
const formatPrice = (amount: number) =>
|
||||
@@ -110,6 +112,59 @@ const requestedDate = (r: LastMileRecord) => {
|
||||
const serviceTypeName = (r: LastMileRecord) =>
|
||||
r.booking?.serviceType?.label ?? r.booking?.serviceType?.name ?? "—";
|
||||
|
||||
const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem =>
|
||||
({
|
||||
id: row.id,
|
||||
bookingId: row.bookingId,
|
||||
quantity: 1,
|
||||
weight: Number(row.weight) || 0,
|
||||
grnNumber: row.grnNumber,
|
||||
status: row.currentStatus,
|
||||
arrivedAt: row.arrivalTime,
|
||||
unloadedAt: row.arrivalTime,
|
||||
inspectionStatus: row.inspectionStatus,
|
||||
releaseDate: row.releaseDate,
|
||||
releaseOrderReference: row.releaseOrderReference,
|
||||
handoverDocumentReference: row.handoverDocumentReference,
|
||||
handoverDocumentDate: row.handoverDocumentDate,
|
||||
deliveredAt: row.deliveredAt,
|
||||
booking: row.bookingId
|
||||
? {
|
||||
id: row.bookingId,
|
||||
reference: row.bookingReference ?? row.bookingId,
|
||||
tradeDirection: "IMPORT",
|
||||
lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null,
|
||||
customerTruckPlateNumber: row.customerTruckPlateNumber,
|
||||
customerTruckDriverName: row.customerTruckDriverName,
|
||||
customerTruckType: row.customerTruckType,
|
||||
customerTruckContainerNumber: row.customerTruckContainerNumber,
|
||||
customerTruckAssignedAt: row.customerTruckAssignedAt,
|
||||
}
|
||||
: null,
|
||||
}) as unknown as WarehouseInventoryItem;
|
||||
|
||||
const releasePrefillFromLastMile = (
|
||||
record: LastMileRecord,
|
||||
row?: ImportUnloadedItem | null,
|
||||
driversById?: Map<string, Driver>,
|
||||
): ReleaseOrderTruckPrefill => {
|
||||
const vehicle = record.vehicle;
|
||||
const truckType = [vehicle?.manufacturer, vehicle?.model].filter(Boolean).join(" ").trim();
|
||||
const assignedDriver = vehicle?.assignedDriverId ? driversById?.get(vehicle.assignedDriverId) : undefined;
|
||||
const assignedDriverName = assignedDriver
|
||||
? `${assignedDriver.firstName ?? ""} ${assignedDriver.lastName ?? ""}`.trim()
|
||||
: "";
|
||||
return {
|
||||
truckPlateNumber: vehicle?.powerPlateNo || vehicle?.plateNumber || null,
|
||||
trailerPlateNumber: vehicle?.trailerPlateNo || null,
|
||||
driverName: vehicle?.assignedDriverName || assignedDriverName || null,
|
||||
driverLicense: assignedDriver?.licenseNumber || null,
|
||||
driverPhone: assignedDriver?.phoneNumber || null,
|
||||
truckType: vehicle?.vehicleType || truckType || null,
|
||||
containerNumber: row?.containerNumber ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const InfoRow = ({ label, value }: { label: string; value: string }) => (
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
|
||||
@@ -325,6 +380,8 @@ const LastMilePage = () => {
|
||||
|
||||
const [allocationOpen, setAllocationOpen] = useState(false);
|
||||
const [allocationContainers, setAllocationContainers] = useState<LastMileContainerRow[]>([]);
|
||||
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [releaseTruckPrefill, setReleaseTruckPrefill] = useState<ReleaseOrderTruckPrefill | null>(null);
|
||||
|
||||
const { data: listData, isLoading } = useQuery({
|
||||
queryKey: QUERY_KEYS.LAST_MILE.list(),
|
||||
@@ -352,6 +409,27 @@ const LastMilePage = () => {
|
||||
|
||||
const records = listData?.data ?? [];
|
||||
|
||||
const needsDriverLookup = records.some((record) => record.vehicle?.assignedDriverId);
|
||||
|
||||
const { data: driversData } = useQuery({
|
||||
queryKey: ["drivers", "list", "ACTIVE"],
|
||||
queryFn: async () => {
|
||||
const res = await driversService.getAll({ status: "ACTIVE" });
|
||||
return res.data;
|
||||
},
|
||||
enabled: needsDriverLookup,
|
||||
});
|
||||
|
||||
const driversById = useMemo(
|
||||
() => new Map((driversData ?? []).map((driver) => [driver.id, driver])),
|
||||
[driversData],
|
||||
);
|
||||
|
||||
const { data: pickupReadyRows = [] } = useQuery({
|
||||
queryKey: ["warehouse-inventory", "import-pickup-ready-queue"],
|
||||
queryFn: () => warehouseService.importPickupReadyQueue().then((r) => r.data),
|
||||
});
|
||||
|
||||
const vehicleOptions = useMemo(
|
||||
() =>
|
||||
(Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => {
|
||||
@@ -533,6 +611,15 @@ const LastMilePage = () => {
|
||||
[records, activeId],
|
||||
);
|
||||
|
||||
const pickupReadyByBooking = useMemo(() => {
|
||||
const map = new Map<string, ImportUnloadedItem>();
|
||||
for (const row of pickupReadyRows) {
|
||||
if (row.bookingId) map.set(row.bookingId, row);
|
||||
if (row.bookingReference) map.set(row.bookingReference, row);
|
||||
}
|
||||
return map;
|
||||
}, [pickupReadyRows]);
|
||||
|
||||
const selectedIds = useMemo(
|
||||
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
|
||||
[rowSelection],
|
||||
@@ -569,6 +656,7 @@ const LastMilePage = () => {
|
||||
const term = search.trim().toLowerCase();
|
||||
return records.filter((r) => {
|
||||
if (!matchesFilter(r)) return false;
|
||||
if (filterPostPaymentPending && !(r.remainingPayment > 0)) return false;
|
||||
if (!term) return true;
|
||||
return [bookingRef(r), customerName(r), deliveryLocation(r), cargoDesc(r)]
|
||||
.join(" ")
|
||||
@@ -649,6 +737,37 @@ const LastMilePage = () => {
|
||||
setTripSlipOpen(true);
|
||||
};
|
||||
|
||||
const openTruckArrival = (record: LastMileRecord) => {
|
||||
if (!isAssigned(record)) {
|
||||
toast({
|
||||
title: "Assign a truck first",
|
||||
description: "Truck arrival opens after a last-mile vehicle is assigned.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const row = pickupReadyByBooking.get(record.bookingId) ?? pickupReadyByBooking.get(bookingRef(record));
|
||||
if (!row) {
|
||||
toast({
|
||||
title: "Import inventory is not pickup-ready",
|
||||
description: `${bookingRef(record)} must be unloaded and pass inspection before truck arrival.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setReleaseTruckPrefill(releasePrefillFromLastMile(record, row, driversById));
|
||||
setReleaseItem(toReleaseInventoryItem(row));
|
||||
};
|
||||
|
||||
const closeTruckArrival = () => {
|
||||
setReleaseItem(null);
|
||||
setReleaseTruckPrefill(null);
|
||||
void qc.invalidateQueries({ queryKey: ["warehouse-inventory"] });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
|
||||
};
|
||||
|
||||
const printTripSlip = () => {
|
||||
if (!tripSlipRecord) return;
|
||||
const win = window.open("", "_blank", "width=820,height=920");
|
||||
@@ -809,6 +928,9 @@ const LastMilePage = () => {
|
||||
const assigned = isAssigned(row.original);
|
||||
const nextStatus = NEXT_STATUS[row.original.status];
|
||||
const canPrint = row.original.status !== "PAYMENT_PENDING";
|
||||
const releaseRow =
|
||||
pickupReadyByBooking.get(row.original.bookingId) ?? pickupReadyByBooking.get(bookingRef(row.original));
|
||||
const truckArrivalLabel = releaseRow?.releaseOrderReference ? "Truck Leaving" : "Truck Arrival";
|
||||
return (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<Menu position="bottom-end" width={200} withinPortal>
|
||||
@@ -840,6 +962,13 @@ const LastMilePage = () => {
|
||||
>
|
||||
Reassign
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Truck size={15} />}
|
||||
disabled={!assigned}
|
||||
onClick={() => openTruckArrival(row.original)}
|
||||
>
|
||||
{truckArrivalLabel}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<Eye size={15} />}
|
||||
@@ -869,7 +998,7 @@ const LastMilePage = () => {
|
||||
},
|
||||
];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [vehicleOptions]);
|
||||
}, [vehicleOptions, pickupReadyByBooking]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
@@ -1358,6 +1487,13 @@ const LastMilePage = () => {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<ReleaseOrderModal
|
||||
opened={Boolean(releaseItem)}
|
||||
onClose={closeTruckArrival}
|
||||
item={releaseItem}
|
||||
truckPrefill={releaseTruckPrefill}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
@@ -88,6 +90,10 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
|
||||
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
||||
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
|
||||
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
|
||||
const [gatepassReference, setGatepassReference] = useState("");
|
||||
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
|
||||
const [gatepassNotes, setGatepassNotes] = useState("");
|
||||
const autoPreviewedRef = useRef(false);
|
||||
|
||||
const detailQuery = useQuery(
|
||||
@@ -98,6 +104,42 @@ export default function TrainScheduleV2DetailPage() {
|
||||
);
|
||||
const schedule = detailQuery.data;
|
||||
const freightType: FreightType | undefined = schedule?.freightType as FreightType | undefined;
|
||||
const isDjiboutiPort = (value?: string | null) =>
|
||||
["DJIBOUTI", "DORALEH", "DMP", "DCT", "NAGAD"].some((token) =>
|
||||
(value ?? "").toUpperCase().includes(token),
|
||||
);
|
||||
const gatepassApplies = Boolean(
|
||||
schedule &&
|
||||
((schedule.direction === "IMPORT" &&
|
||||
isDjiboutiPort(`${schedule.originStation?.code ?? ""} ${schedule.originStation?.label ?? ""}`)) ||
|
||||
(schedule.direction === "EXPORT" &&
|
||||
isDjiboutiPort(`${schedule.destinationStation?.code ?? ""} ${schedule.destinationStation?.label ?? ""}`))),
|
||||
);
|
||||
const gatepassQuery = useQuery({
|
||||
queryKey: ["train-scheduling", "gatepass", scheduleId],
|
||||
queryFn: () => trainSchedulingService.getImportDjiboutiOperation(scheduleId as string),
|
||||
enabled: Boolean(scheduleId && gatepassApplies),
|
||||
});
|
||||
const secureGatepass = useMutation({
|
||||
mutationFn: () =>
|
||||
trainSchedulingService.grantImportDjiboutiGatepass(scheduleId as string, {
|
||||
securedAt: gatepassSecuredAt ? new Date(gatepassSecuredAt).toISOString() : undefined,
|
||||
reference: gatepassReference.trim() || undefined,
|
||||
fileUrl: gatepassFileUrl.trim() || undefined,
|
||||
notes: gatepassNotes.trim() || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast({ title: "Gate pass secured" });
|
||||
void gatepassQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Gate pass failed",
|
||||
description: parseError(error, "Could not secure gate pass"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const eligibleFilters = useMemo(
|
||||
() =>
|
||||
@@ -133,6 +175,16 @@ export default function TrainScheduleV2DetailPage() {
|
||||
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const operation = gatepassQuery.data;
|
||||
if (!operation) return;
|
||||
const secured = operation.gatepassSecuredAt ?? operation.gatepassGrantedAt;
|
||||
setGatepassSecuredAt(secured ? new Date(secured).toISOString().slice(0, 16) : "");
|
||||
setGatepassReference(operation.documents?.GATE_PASS?.reference ?? "");
|
||||
setGatepassFileUrl(operation.documents?.GATE_PASS?.fileUrl ?? "");
|
||||
setGatepassNotes(operation.documents?.GATE_PASS?.notes ?? operation.notes ?? "");
|
||||
}, [gatepassQuery.data]);
|
||||
|
||||
const assignedIds = useMemo(
|
||||
() => (schedule?.bookings ?? []).map((b) => b.id),
|
||||
[schedule?.bookings],
|
||||
@@ -899,6 +951,83 @@ export default function TrainScheduleV2DetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
{gatepassApplies ? (
|
||||
<Paper radius="xl" p="lg" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={44}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color={gatepassQuery.data?.gatepassStatus === "SECURED" ? "edr-green" : "orange"}
|
||||
>
|
||||
<FileText size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={4}>
|
||||
<Group gap="sm">
|
||||
<Title order={4} fw={700}>
|
||||
Djibouti Port gate pass
|
||||
</Title>
|
||||
<Badge
|
||||
color={gatepassQuery.data?.gatepassStatus === "SECURED" ? "green" : "orange"}
|
||||
variant="light"
|
||||
>
|
||||
{gatepassQuery.data?.gatepassStatus ?? "NOT_SECURED"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{schedule.direction === "IMPORT"
|
||||
? "Secure before dispatch from Djibouti."
|
||||
: "Secure after dispatch before Djibouti Port entry / unloading."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
{gatepassQuery.isLoading ? <Loader size="sm" /> : null}
|
||||
</Group>
|
||||
|
||||
<Group align="flex-end" grow>
|
||||
<TextInput
|
||||
label="Secured date"
|
||||
type="datetime-local"
|
||||
value={gatepassSecuredAt}
|
||||
onChange={(event) => setGatepassSecuredAt(event.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Document reference"
|
||||
placeholder="Optional"
|
||||
value={gatepassReference}
|
||||
onChange={(event) => setGatepassReference(event.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Document URL"
|
||||
placeholder="Optional upload/link"
|
||||
value={gatepassFileUrl}
|
||||
onChange={(event) => setGatepassFileUrl(event.currentTarget.value)}
|
||||
/>
|
||||
</Group>
|
||||
<Textarea
|
||||
label="Notes"
|
||||
placeholder="Optional"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={gatepassNotes}
|
||||
onChange={(event) => setGatepassNotes(event.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
loading={secureGatepass.isPending}
|
||||
onClick={() => secureGatepass.mutate()}
|
||||
>
|
||||
Save as Secured
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Paper radius="xl" p="lg">
|
||||
<Stack gap="lg">
|
||||
{/* Workflow header with ring progress */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
@@ -21,11 +22,17 @@ import {
|
||||
} from '@/components/warehouses';
|
||||
import {
|
||||
useAutoUnloadArrivedBookings,
|
||||
useAllWarehouseYards,
|
||||
useAllWarehouseZones,
|
||||
useImportArriveQueue,
|
||||
useImportTrainItems,
|
||||
useWarehouses,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { AutoUnloadArrivedResult, ImportTrain, ImportTrainItem } from '@/types/warehouse';
|
||||
import type { AutoUnloadArrivedResult, ImportTrain, ImportTrainItem, Warehouse, WarehouseYard, WarehouseZone } from '@/types/warehouse';
|
||||
|
||||
type UnloadAssignment = { bookingId: string; warehouseId: string; yardId: string; zoneId: string };
|
||||
type AssignmentDraft = Partial<Omit<UnloadAssignment, 'bookingId'>>;
|
||||
|
||||
const getErrorMessage = (error: unknown) => {
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
@@ -43,8 +50,54 @@ const getPendingUnloadBookings = (train: ImportTrain) =>
|
||||
const isFullyUnloaded = (train: ImportTrain) =>
|
||||
Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0);
|
||||
|
||||
function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
const locationTypesForFreight = (freightType: string | null | undefined) => {
|
||||
const normalized = (freightType ?? '').toUpperCase();
|
||||
if (normalized === 'CONTAINER') {
|
||||
return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] };
|
||||
}
|
||||
return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] };
|
||||
};
|
||||
|
||||
const isContainerFreight = (freightType: string | null | undefined) =>
|
||||
(freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
|
||||
function isUnloadPending(item: ImportTrainItem) {
|
||||
return !item.currentStatus || item.currentStatus === 'RECEIVED';
|
||||
}
|
||||
|
||||
function ImportTrainDetailRows({
|
||||
scheduleId,
|
||||
warehouses,
|
||||
yards,
|
||||
zones,
|
||||
assignments,
|
||||
onAssignmentChange,
|
||||
onReadyChange,
|
||||
}: {
|
||||
scheduleId: string;
|
||||
warehouses: Warehouse[];
|
||||
yards: WarehouseYard[];
|
||||
zones: WarehouseZone[];
|
||||
assignments: Record<string, AssignmentDraft>;
|
||||
onAssignmentChange: (bookingId: string, draft: AssignmentDraft) => void;
|
||||
onReadyChange: (ready: boolean) => void;
|
||||
}) {
|
||||
const { data: items = [], isLoading } = useImportTrainItems(scheduleId);
|
||||
const warehouseOptions = useMemo(
|
||||
() => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
||||
[warehouses],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = items.filter(isUnloadPending);
|
||||
onReadyChange(
|
||||
pending.length > 0 &&
|
||||
pending.every((item) => {
|
||||
const draft = assignments[item.bookingId];
|
||||
return Boolean(draft?.warehouseId && draft.yardId && draft.zoneId);
|
||||
}),
|
||||
);
|
||||
}, [assignments, items]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -73,12 +126,26 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Arrival</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
<Table.Th>Zone</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
<Table.Th>Pickup</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((item: ImportTrainItem) => (
|
||||
{items.map((item: ImportTrainItem) => {
|
||||
const draft = assignments[item.bookingId] ?? {};
|
||||
const { yardTypes, zoneTypes } = locationTypesForFreight(item.freightType);
|
||||
const yardOptions = yards
|
||||
.filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type))
|
||||
.map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
|
||||
const zoneOptions = zones
|
||||
.filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type))
|
||||
.map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` }));
|
||||
const pending = isUnloadPending(item);
|
||||
|
||||
return (
|
||||
<Table.Tr key={item.bookingId}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
@@ -95,6 +162,41 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
{item.currentStatus ?? 'PENDING'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder="Warehouse"
|
||||
data={warehouseOptions}
|
||||
value={draft.warehouseId ?? null}
|
||||
onChange={(value) => onAssignmentChange(item.bookingId, { warehouseId: value ?? undefined })}
|
||||
searchable
|
||||
disabled={!pending}
|
||||
w={210}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder={isContainerFreight(item.freightType) ? 'Container yard' : 'Bulk yard'}
|
||||
data={yardOptions}
|
||||
value={draft.yardId ?? null}
|
||||
onChange={(value) =>
|
||||
onAssignmentChange(item.bookingId, { ...draft, yardId: value ?? undefined, zoneId: undefined })
|
||||
}
|
||||
searchable
|
||||
disabled={!pending || !draft.warehouseId}
|
||||
w={190}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder={isContainerFreight(item.freightType) ? 'Container zone' : 'Bulk zone'}
|
||||
data={zoneOptions}
|
||||
value={draft.zoneId ?? null}
|
||||
onChange={(value) => onAssignmentChange(item.bookingId, { ...draft, zoneId: value ?? undefined })}
|
||||
searchable
|
||||
disabled={!pending || !draft.yardId}
|
||||
w={190}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color={item.inspectionStatus === 'PASSED' ? 'green' : 'gray'} size="sm">
|
||||
{item.inspectionStatus ?? 'Not inspected'}
|
||||
@@ -102,7 +204,8 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
</Table.Td>
|
||||
<Table.Td>{item.pickupOption.replace(/_/g, ' ')}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
@@ -112,11 +215,36 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
export default function ArrivalQueuePage() {
|
||||
const { toast } = useToast();
|
||||
const { data: trains = [], isLoading } = useImportArriveQueue();
|
||||
const { data: warehouses = [], isLoading: warehousesLoading } = useWarehouses({ status: 'ACTIVE' });
|
||||
const { data: yards = [] } = useAllWarehouseYards();
|
||||
const { data: zones = [] } = useAllWarehouseZones();
|
||||
const autoUnload = useAutoUnloadArrivedBookings();
|
||||
const [openScheduleId, setOpenScheduleId] = useState<string | null>(null);
|
||||
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
|
||||
const [assignmentsBySchedule, setAssignmentsBySchedule] = useState<Record<string, Record<string, AssignmentDraft>>>({});
|
||||
const [readyBySchedule, setReadyBySchedule] = useState<Record<string, boolean>>({});
|
||||
|
||||
const unloadTrain = async (train: ImportTrain) => {
|
||||
const assignments = Object.entries(assignmentsBySchedule[train.scheduleId] ?? {})
|
||||
.filter((entry): entry is [string, Required<AssignmentDraft>] =>
|
||||
Boolean(entry[1].warehouseId && entry[1].yardId && entry[1].zoneId),
|
||||
)
|
||||
.map(([bookingId, draft]) => ({
|
||||
bookingId,
|
||||
warehouseId: draft.warehouseId,
|
||||
yardId: draft.yardId,
|
||||
zoneId: draft.zoneId,
|
||||
}));
|
||||
|
||||
if (!readyBySchedule[train.scheduleId] || assignments.length === 0) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Assign locations',
|
||||
description: 'Select warehouse, yard and zone for each pending booking before unloading.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFullyUnloaded(train)) {
|
||||
toast({
|
||||
title: 'Already unloaded',
|
||||
@@ -127,7 +255,9 @@ export default function ArrivalQueuePage() {
|
||||
|
||||
setBusyScheduleId(train.scheduleId);
|
||||
try {
|
||||
const res = (await autoUnload.mutateAsync(train.scheduleId)) as { data: AutoUnloadArrivedResult };
|
||||
const res = (await autoUnload.mutateAsync({ scheduleId: train.scheduleId, assignments })) as {
|
||||
data: AutoUnloadArrivedResult;
|
||||
};
|
||||
const result = res.data;
|
||||
const alreadyUnloaded = result.unloadedCount === 0 && result.skippedCount > 0 && result.failedCount === 0;
|
||||
const firstReason = result.results.find((item) => item.reason)?.reason;
|
||||
@@ -169,10 +299,12 @@ export default function ArrivalQueuePage() {
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={600}>{trains.length} arrived import train(s)</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Open a train to review assigned bookings, then auto unload it.
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600}>{trains.length} arrived import train(s)</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Open a train, assign each booking to a warehouse yard and zone, then unload it.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -254,7 +386,7 @@ export default function ArrivalQueuePage() {
|
||||
color={fullyUnloaded ? 'gray' : 'orange'}
|
||||
leftSection={busyScheduleId === train.scheduleId ? <PackageOpen size={14} /> : <Truck size={14} />}
|
||||
loading={busyScheduleId === train.scheduleId}
|
||||
disabled={fullyUnloaded || train.totalBookings === 0}
|
||||
disabled={fullyUnloaded || train.totalBookings === 0 || !readyBySchedule[train.scheduleId] || warehousesLoading}
|
||||
onClick={() => unloadTrain(train)}
|
||||
>
|
||||
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload'}
|
||||
@@ -265,7 +397,27 @@ export default function ArrivalQueuePage() {
|
||||
{isOpen && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={10} bg="var(--mantine-color-gray-0)">
|
||||
<ImportTrainDetailRows scheduleId={train.scheduleId} />
|
||||
<ImportTrainDetailRows
|
||||
scheduleId={train.scheduleId}
|
||||
warehouses={warehouses}
|
||||
yards={yards}
|
||||
zones={zones}
|
||||
assignments={assignmentsBySchedule[train.scheduleId] ?? {}}
|
||||
onAssignmentChange={(bookingId, draft) =>
|
||||
setAssignmentsBySchedule((current) => ({
|
||||
...current,
|
||||
[train.scheduleId]: {
|
||||
...(current[train.scheduleId] ?? {}),
|
||||
[bookingId]: draft.warehouseId
|
||||
? draft
|
||||
: {},
|
||||
},
|
||||
}))
|
||||
}
|
||||
onReadyChange={(ready) =>
|
||||
setReadyBySchedule((current) => ({ ...current, [train.scheduleId]: ready }))
|
||||
}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
@@ -29,6 +29,7 @@ import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
WAREHOUSE_INVOICE_STATUSES,
|
||||
type WarehouseFeeInvoice,
|
||||
type WarehouseGatewayPaymentMethod,
|
||||
type WarehouseInvoiceStatus,
|
||||
} from '@/types/warehouse';
|
||||
import { openPdfBlob } from '@/components/warehouses/pdf';
|
||||
@@ -164,15 +165,23 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
}),
|
||||
);
|
||||
const pay = useMutation(api.warehouses.payInvoice.mutationOptions());
|
||||
const payOnline = useMutation(api.warehouses.payInvoiceOnline.mutationOptions());
|
||||
const cancel = useMutation(api.warehouses.cancelInvoice.mutationOptions());
|
||||
const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions());
|
||||
const [payAmount, setPayAmount] = useState<number | ''>('');
|
||||
const [driverName, setDriverName] = useState('');
|
||||
const [driverPhone, setDriverPhone] = useState('');
|
||||
const [gatewayMethod, setGatewayMethod] = useState<WarehouseGatewayPaymentMethod>('TELEBIRR');
|
||||
const [payerAccount, setPayerAccount] = useState('');
|
||||
|
||||
const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
|
||||
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
|
||||
|
||||
useEffect(() => {
|
||||
setGatewayMethod(inv?.currency === 'USD' ? 'WAAFI' : 'TELEBIRR');
|
||||
setPayerAccount('');
|
||||
}, [inv?.id, inv?.currency]);
|
||||
|
||||
const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => {
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
@@ -302,6 +311,34 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnlinePay = async () => {
|
||||
if (!inv) return;
|
||||
try {
|
||||
const currentUrl = window.location.href;
|
||||
const result = await payOnline.mutateAsync({
|
||||
id: inv.id,
|
||||
payload: {
|
||||
method: gatewayMethod,
|
||||
platform: 'web',
|
||||
payerAccount: payerAccount.trim() || undefined,
|
||||
returnUrl: currentUrl,
|
||||
failureUrl: currentUrl,
|
||||
},
|
||||
});
|
||||
const url = result.clientAction?.url;
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: 'Payment initiated',
|
||||
description: 'No redirect URL was returned by the payment provider.',
|
||||
});
|
||||
} catch (e) {
|
||||
toast({ variant: 'destructive', title: 'Payment failed', description: extractErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!inv) return;
|
||||
try {
|
||||
@@ -359,7 +396,31 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
|
||||
{canPay && (
|
||||
<>
|
||||
<Divider label="Record payment" labelPosition="left" />
|
||||
<Divider label="Online payment" labelPosition="left" />
|
||||
<Group align="flex-end">
|
||||
<Select
|
||||
label="Provider"
|
||||
value={gatewayMethod}
|
||||
onChange={(v) => setGatewayMethod((v as WarehouseGatewayPaymentMethod) ?? 'TELEBIRR')}
|
||||
data={[
|
||||
{ value: 'TELEBIRR', label: 'Telebirr' },
|
||||
{ value: 'WAAFI', label: 'Waafi' },
|
||||
]}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Wallet phone / account"
|
||||
value={payerAccount}
|
||||
onChange={(e) => setPayerAccount(e.currentTarget.value)}
|
||||
placeholder="Optional"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button leftSection={<CreditCard size={16} />} loading={payOnline.isPending} onClick={handleOnlinePay}>
|
||||
Pay with {gatewayMethod === 'WAAFI' ? 'Waafi' : 'Telebirr'}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Divider label="Record manual payment" labelPosition="left" />
|
||||
<Group align="flex-end">
|
||||
<NumberInput
|
||||
label="Amount"
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { Info, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
@@ -29,7 +31,9 @@ import {
|
||||
useDeleteFeeRule,
|
||||
useFeeRules,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { api } from '@/services/api';
|
||||
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from '@/components/warehouses/options';
|
||||
|
||||
const FREIGHT = [
|
||||
{ value: 'CONTAINER', label: 'Container' },
|
||||
@@ -38,6 +42,7 @@ const FREIGHT = [
|
||||
const TRADE = [
|
||||
{ value: 'IMPORT', label: 'Import' },
|
||||
{ value: 'EXPORT', label: 'Export' },
|
||||
{ value: 'BOTH', label: 'Import & Export' },
|
||||
{ value: 'DOMESTIC', label: 'Domestic' },
|
||||
];
|
||||
const CURRENCIES = [
|
||||
@@ -53,6 +58,23 @@ const numberValue = (value: string | number, fallback = 0) => {
|
||||
};
|
||||
const anyLabel = (value: string, label: string) => value.trim() || `Any ${label}`;
|
||||
const dash = '-';
|
||||
type CodeOptionSource = {
|
||||
id?: string;
|
||||
code?: string;
|
||||
cargoTypeName?: string;
|
||||
label?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
const codeOptions = (rows: unknown[]) =>
|
||||
(rows as CodeOptionSource[])
|
||||
.filter((row) => row.code)
|
||||
.map((row) => ({
|
||||
value: row.code as string,
|
||||
label: `${row.cargoTypeName ?? row.label ?? row.name ?? row.code} (${row.code})`,
|
||||
}));
|
||||
|
||||
const isUnknownTiersError = (error: unknown) => extractErrorMessage(error).includes('property tiers should not exist');
|
||||
|
||||
export default function WarehouseRulesPage() {
|
||||
return (
|
||||
@@ -319,6 +341,12 @@ function AllocationRules() {
|
||||
function FeeRules() {
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading } = useFeeRules();
|
||||
const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useQuery(
|
||||
api.cargoTypes.list.queryOptions({ staleTime: Infinity }),
|
||||
);
|
||||
const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery(
|
||||
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
|
||||
);
|
||||
const create = useCreateFeeRule();
|
||||
const remove = useDeleteFeeRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -328,11 +356,56 @@ function FeeRules() {
|
||||
freightType: '',
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
containerType: '',
|
||||
freeDays: 3,
|
||||
ratePerDay: 0,
|
||||
tiers: [] as Array<{ fromDay: number; toDay: number | null; ratePerDay: number }>,
|
||||
currency: 'USD',
|
||||
});
|
||||
const rules = data ?? [];
|
||||
const cargoTypeOptions = codeOptions(cargoTypes);
|
||||
const containerTypeOptions = codeOptions(containerTypes);
|
||||
const isBulkRule = form.freightType === 'BULK';
|
||||
const isContainerRule = form.freightType === 'CONTAINER';
|
||||
|
||||
const resetForm = () =>
|
||||
setForm({
|
||||
name: '',
|
||||
ruleType: 'DEMURRAGE_FEE',
|
||||
freightType: '',
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
containerType: '',
|
||||
freeDays: 3,
|
||||
ratePerDay: 0,
|
||||
tiers: [],
|
||||
currency: 'USD',
|
||||
});
|
||||
|
||||
const addTier = () =>
|
||||
setForm((f) => {
|
||||
const last = f.tiers[f.tiers.length - 1];
|
||||
const fromDay = last?.toDay ? last.toDay + 1 : f.tiers.length ? last.fromDay + 1 : f.freeDays + 1;
|
||||
return {
|
||||
...f,
|
||||
tiers: [...f.tiers, { fromDay, toDay: fromDay, ratePerDay: f.ratePerDay || 0 }],
|
||||
};
|
||||
});
|
||||
|
||||
const updateTier = (
|
||||
index: number,
|
||||
patch: Partial<{ fromDay: number; toDay: number | null; ratePerDay: number }>,
|
||||
) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
tiers: f.tiers.map((tier, i) => (i === index ? { ...tier, ...patch } : tier)),
|
||||
}));
|
||||
|
||||
const removeTier = (index: number) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
tiers: f.tiers.filter((_, i) => i !== index),
|
||||
}));
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim()) {
|
||||
@@ -340,18 +413,68 @@ function FeeRules() {
|
||||
return;
|
||||
}
|
||||
|
||||
await create.mutateAsync({
|
||||
const tiers = form.tiers.map((tier) => ({
|
||||
fromDay: tier.fromDay,
|
||||
toDay: tier.toDay || null,
|
||||
ratePerDay: tier.ratePerDay,
|
||||
}));
|
||||
for (const [index, tier] of tiers.entries()) {
|
||||
if (!Number.isInteger(tier.fromDay) || tier.fromDay < 1) {
|
||||
toast({ variant: 'destructive', title: `Tier ${index + 1}: from day must be at least 1` });
|
||||
return;
|
||||
}
|
||||
if (tier.toDay != null && tier.toDay < tier.fromDay) {
|
||||
toast({ variant: 'destructive', title: `Tier ${index + 1}: to day must be after from day` });
|
||||
return;
|
||||
}
|
||||
if (tier.ratePerDay < 0) {
|
||||
toast({ variant: 'destructive', title: `Tier ${index + 1}: amount must be zero or greater` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
ruleType: form.ruleType,
|
||||
freightType: clean(form.freightType) ?? null,
|
||||
tradeDirection: clean(form.tradeDirection) ?? null,
|
||||
cargoTypeCode: clean(form.cargoTypeCode) ?? null,
|
||||
cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
|
||||
containerType: isContainerRule ? (clean(form.containerType) ?? null) : null,
|
||||
freeDays: form.freeDays,
|
||||
ratePerDay: form.ratePerDay,
|
||||
currency: form.currency || 'USD',
|
||||
} as never);
|
||||
toast({ title: 'Fee rule created' });
|
||||
setOpen(false);
|
||||
...(tiers.length ? { tiers } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
await create.mutateAsync(payload as never);
|
||||
toast({ title: 'Fee rule created' });
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
if (tiers.length && isUnknownTiersError(error)) {
|
||||
const legacyPayload: Omit<typeof payload, 'tiers'> = {
|
||||
name: payload.name,
|
||||
ruleType: payload.ruleType,
|
||||
freightType: payload.freightType,
|
||||
tradeDirection: payload.tradeDirection,
|
||||
cargoTypeCode: payload.cargoTypeCode,
|
||||
containerType: payload.containerType,
|
||||
freeDays: payload.freeDays,
|
||||
ratePerDay: payload.ratePerDay,
|
||||
currency: payload.currency,
|
||||
};
|
||||
await create.mutateAsync(legacyPayload as never);
|
||||
toast({
|
||||
title: 'Fee rule created without tiers',
|
||||
description: 'The connected API does not support progressive tiers yet. Deploy the warehouse fee tier migration/API to save tier rows.',
|
||||
});
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
toast({ variant: 'destructive', title: 'Create failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -370,7 +493,7 @@ function FeeRules() {
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table.ScrollContainer minWidth={1100}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -378,6 +501,9 @@ function FeeRules() {
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Freight</Table.Th>
|
||||
<Table.Th>Trade</Table.Th>
|
||||
<Table.Th>Cargo</Table.Th>
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th>Location scope</Table.Th>
|
||||
<Table.Th>Free days</Table.Th>
|
||||
<Table.Th>Rate / day</Table.Th>
|
||||
<Table.Th>Active</Table.Th>
|
||||
@@ -395,6 +521,20 @@ function FeeRules() {
|
||||
<Table.Td>{rule.name}</Table.Td>
|
||||
<Table.Td>{rule.freightType ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.tradeDirection ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.cargoTypeCode ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.containerType ?? dash}</Table.Td>
|
||||
<Table.Td>
|
||||
{[rule.facilityId, rule.warehouseId, rule.yardId, rule.zoneId].some(Boolean) ? (
|
||||
<Stack gap={2}>
|
||||
{rule.facilityId && <Text size="xs">Facility: {rule.facilityId}</Text>}
|
||||
{rule.warehouseId && <Text size="xs">Warehouse: {rule.warehouseId}</Text>}
|
||||
{rule.yardId && <Text size="xs">Yard: {rule.yardId}</Text>}
|
||||
{rule.zoneId && <Text size="xs">Zone: {rule.zoneId}</Text>}
|
||||
</Stack>
|
||||
) : (
|
||||
dash
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{rule.freeDays}</Table.Td>
|
||||
<Table.Td>
|
||||
{Number(rule.ratePerDay).toLocaleString()} {rule.currency}
|
||||
@@ -454,7 +594,14 @@ function FeeRules() {
|
||||
label="Freight type"
|
||||
data={FREIGHT}
|
||||
value={form.freightType || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, freightType: selectValue(value) }))}
|
||||
onChange={(value) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
freightType: selectValue(value),
|
||||
cargoTypeCode: value === 'BULK' ? f.cargoTypeCode : '',
|
||||
containerType: value === 'CONTAINER' ? f.containerType : '',
|
||||
}))
|
||||
}
|
||||
clearable
|
||||
/>
|
||||
<Select
|
||||
@@ -464,15 +611,31 @@ function FeeRules() {
|
||||
onChange={(value) => setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))}
|
||||
clearable
|
||||
/>
|
||||
<TextInput
|
||||
label="Cargo type code"
|
||||
value={form.cargoTypeCode}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setForm((f) => ({ ...f, cargoTypeCode: value }));
|
||||
}}
|
||||
/>
|
||||
{isBulkRule && (
|
||||
<Select
|
||||
label="Cargo type"
|
||||
placeholder={cargoTypesLoading ? 'Loading cargo types...' : 'Any cargo'}
|
||||
data={cargoTypeOptions}
|
||||
value={form.cargoTypeCode || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, cargoTypeCode: selectValue(value) }))}
|
||||
searchable
|
||||
clearable
|
||||
disabled={cargoTypesLoading}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
{isContainerRule && (
|
||||
<Select
|
||||
label="Container type"
|
||||
placeholder={containerTypesLoading ? 'Loading container types...' : 'Any container'}
|
||||
data={containerTypeOptions}
|
||||
value={form.containerType || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, containerType: selectValue(value) }))}
|
||||
searchable
|
||||
clearable
|
||||
disabled={containerTypesLoading}
|
||||
/>
|
||||
)}
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Free days"
|
||||
@@ -494,6 +657,51 @@ function FeeRules() {
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
Progressive tariff tiers
|
||||
</Text>
|
||||
<Button variant="light" size="xs" leftSection={<Plus size={14} />} onClick={addTier}>
|
||||
Add tier
|
||||
</Button>
|
||||
</Group>
|
||||
{form.tiers.map((tier, index) => (
|
||||
<Group key={index} grow align="end">
|
||||
<NumberInput
|
||||
label="From day"
|
||||
min={1}
|
||||
value={tier.fromDay}
|
||||
onChange={(value) => updateTier(index, { fromDay: numberValue(value, 1) || 1 })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="To day"
|
||||
min={tier.fromDay}
|
||||
value={tier.toDay ?? ''}
|
||||
placeholder="Open"
|
||||
onChange={(value) =>
|
||||
updateTier(index, {
|
||||
toDay: value === '' ? null : numberValue(value, tier.fromDay),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Amount / day"
|
||||
min={0}
|
||||
value={tier.ratePerDay}
|
||||
onChange={(value) => updateTier(index, { ratePerDay: numberValue(value) })}
|
||||
/>
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => removeTier(index)} title="Remove tier">
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
{form.tiers.length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
No stepped tiers. The flat rate per day is used after the free days.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
|
||||
@@ -84,6 +84,7 @@ import type {
|
||||
InventoryInquiryFilter,
|
||||
InventoryInquiryResult,
|
||||
InventoryMovement,
|
||||
InitiateWarehouseInvoicePaymentPayload,
|
||||
LoadableWagon,
|
||||
LoadInventoryPayload,
|
||||
LoadPassedExportResult,
|
||||
@@ -102,6 +103,7 @@ import type {
|
||||
WarehouseActivityLog,
|
||||
WarehouseDashboard,
|
||||
WarehouseFeeInvoice,
|
||||
WarehouseInvoicePaymentResponse,
|
||||
WarehouseFilter,
|
||||
WarehouseInventoryItem,
|
||||
WarehouseInvoiceFilter,
|
||||
@@ -943,12 +945,19 @@ export const api = {
|
||||
() => INVENTORY_INVALIDATIONS,
|
||||
),
|
||||
|
||||
autoUnloadArrivedBookings: endpoint<string, AutoUnloadArrivedResult>(
|
||||
autoUnloadArrivedBookings: endpoint<
|
||||
{
|
||||
scheduleId: string;
|
||||
warehouseId?: string;
|
||||
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
|
||||
},
|
||||
AutoUnloadArrivedResult
|
||||
>(
|
||||
"warehouse-inventory",
|
||||
"auto-unload-arrived-bookings",
|
||||
(scheduleId) =>
|
||||
({ scheduleId, warehouseId, assignments }) =>
|
||||
warehouseService
|
||||
.autoUnloadArrivedBookings(scheduleId)
|
||||
.autoUnloadArrivedBookings({ scheduleId, warehouseId, assignments })
|
||||
.then((r) => r.data),
|
||||
undefined,
|
||||
() => INVENTORY_INVALIDATIONS,
|
||||
@@ -1111,6 +1120,18 @@ export const api = {
|
||||
() => [["warehouse-fee-invoices"], ["warehouse-inventory"]],
|
||||
),
|
||||
|
||||
payInvoiceOnline: endpoint<
|
||||
{ id: string; payload: InitiateWarehouseInvoicePaymentPayload },
|
||||
WarehouseInvoicePaymentResponse
|
||||
>(
|
||||
"warehouse-fee-invoices",
|
||||
"pay-online",
|
||||
({ id, payload }) =>
|
||||
warehouseService.payInvoiceOnline(id, payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => [["warehouse-fee-invoices"], ["warehouse-inventory"]],
|
||||
),
|
||||
|
||||
gateClearance: endpoint<string, WarehouseInventoryItem>(
|
||||
"warehouse-fee-invoices",
|
||||
"gate-clearance",
|
||||
|
||||
@@ -29,9 +29,12 @@ export interface LastMileVehicle {
|
||||
plateNumber: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
vehicleType?: string | null;
|
||||
code?: string | null;
|
||||
powerPlateNo?: string | null;
|
||||
trailerPlateNo?: string | null;
|
||||
assignedDriverId?: string | null;
|
||||
assignedDriverName?: string | null;
|
||||
}
|
||||
|
||||
export interface LastMileRecord {
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface Vehicle {
|
||||
capacity: number;
|
||||
status: VehicleStatus;
|
||||
description?: string | null;
|
||||
assignedDriverId?: string | null;
|
||||
assignedDriverName?: string | null;
|
||||
code?: string | null;
|
||||
powerPlateNo?: string | null;
|
||||
trailerPlateNo?: string | null;
|
||||
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
WarehouseFeeInvoice,
|
||||
WarehouseInvoiceFilter,
|
||||
PayInvoicePayload,
|
||||
InitiateWarehouseInvoicePaymentPayload,
|
||||
WarehouseInvoicePaymentResponse,
|
||||
BookingScheduleView,
|
||||
InventoryFilter,
|
||||
InventoryInquiryFilter,
|
||||
@@ -171,10 +173,14 @@ export const warehouseService = {
|
||||
apiClient.get<ImportTrain[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_ARRIVE_QUEUE),
|
||||
importTrainItems: (scheduleId: string) =>
|
||||
apiClient.get<ImportTrainItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_TRAIN_ITEMS(scheduleId)),
|
||||
autoUnloadArrivedBookings: (scheduleId: string) =>
|
||||
autoUnloadArrivedBookings: (payload: {
|
||||
scheduleId: string;
|
||||
warehouseId?: string;
|
||||
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
|
||||
}) =>
|
||||
apiClient.post<AutoUnloadArrivedResult>(
|
||||
URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_AUTO_UNLOAD_ARRIVED,
|
||||
{ scheduleId },
|
||||
payload,
|
||||
),
|
||||
importUnloadedQueue: () =>
|
||||
apiClient.get<ImportUnloadedItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_UNLOADED_QUEUE),
|
||||
@@ -294,6 +300,8 @@ export const warehouseService = {
|
||||
apiClient.patch<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.CANCEL(id)),
|
||||
payInvoice: (id: string, payload: PayInvoicePayload) =>
|
||||
apiClient.post<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.PAY(id), payload),
|
||||
payInvoiceOnline: (id: string, payload: InitiateWarehouseInvoicePaymentPayload) =>
|
||||
apiClient.post<WarehouseInvoicePaymentResponse>(URL_CONSTANTS.WAREHOUSE_INVOICES.PAY_ONLINE(id), payload),
|
||||
gateClearance: (inventoryId: string) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVOICES.GATE_CLEARANCE(inventoryId), {}),
|
||||
};
|
||||
|
||||
@@ -400,6 +400,7 @@ export interface TrainScheduleDetail {
|
||||
}
|
||||
|
||||
export type ImportDjiboutiDocumentType =
|
||||
| "GATE_PASS"
|
||||
| "DELIVERY_ORDER"
|
||||
| "PORT_INVOICE"
|
||||
| "DJIBOUTI_T1"
|
||||
@@ -422,6 +423,7 @@ export interface ImportDjiboutiOperation {
|
||||
status: {
|
||||
documentsComplete: boolean;
|
||||
missingDocuments: ImportDjiboutiDocumentType[];
|
||||
gatepassStatus: "SECURED" | "NOT_SECURED";
|
||||
gatepassGranted: boolean;
|
||||
readyForLoading: boolean;
|
||||
loadedOnTrain: boolean;
|
||||
@@ -430,6 +432,8 @@ export interface ImportDjiboutiOperation {
|
||||
};
|
||||
documents: Partial<Record<ImportDjiboutiDocumentType, ImportDjiboutiDocumentRecord>>;
|
||||
gatepassGrantedAt: string | null;
|
||||
gatepassSecuredAt?: string | null;
|
||||
gatepassStatus: "SECURED" | "NOT_SECURED";
|
||||
readyForLoadingAt: string | null;
|
||||
loadedOnTrainAt: string | null;
|
||||
departedFromDjiboutiAt: string | null;
|
||||
@@ -448,6 +452,10 @@ export interface UploadImportDjiboutiDocumentPayload {
|
||||
}
|
||||
|
||||
export interface ImportDjiboutiActionPayload {
|
||||
securedAt?: string;
|
||||
fileId?: string;
|
||||
fileUrl?: string;
|
||||
reference?: string;
|
||||
notes?: string;
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
@@ -223,6 +223,11 @@ export interface InventoryBookingRef {
|
||||
tradeDirection?: string | null;
|
||||
// Present when the customer requested last-mile (door) delivery — gates the Last Mile action.
|
||||
lastMileDeliveryAddress?: string | null;
|
||||
customerTruckPlateNumber?: string | null;
|
||||
customerTruckDriverName?: string | null;
|
||||
customerTruckType?: string | null;
|
||||
customerTruckContainerNumber?: string | null;
|
||||
customerTruckAssignedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface InventoryMovement {
|
||||
@@ -398,6 +403,11 @@ export interface EligibleBooking {
|
||||
firstMileDriverPhone: string | null;
|
||||
firstMileDriverLicenseNumber: string | null;
|
||||
firstMileTruckType: string | null;
|
||||
customerTruckPlateNumber: string | null;
|
||||
customerTruckDriverName: string | null;
|
||||
customerTruckType: string | null;
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
}
|
||||
|
||||
export interface BulkReceivePayload {
|
||||
@@ -433,6 +443,7 @@ export interface TruckEntrancePayload {
|
||||
packagingType?: string;
|
||||
unitCount?: number;
|
||||
grossWeightKg?: number;
|
||||
weighingRequired?: boolean;
|
||||
netWeightKg?: number;
|
||||
volumeDimensions?: string;
|
||||
conditionAtReceipt?: string;
|
||||
@@ -442,7 +453,7 @@ export interface TruckEntrancePayload {
|
||||
driverPhone: string;
|
||||
driverLicenseNumber?: string;
|
||||
truckType?: string;
|
||||
entranceTareWeightKg: number;
|
||||
entranceTareWeightKg?: number;
|
||||
exitTareWeightKg?: number;
|
||||
driverSignatoryName?: string;
|
||||
warehouseManagerName?: string;
|
||||
@@ -573,6 +584,11 @@ export interface ImportUnloadedItem {
|
||||
inspectionStatus: string | null;
|
||||
pickupOption: string;
|
||||
lastMileRequested: boolean;
|
||||
customerTruckPlateNumber: string | null;
|
||||
customerTruckDriverName: string | null;
|
||||
customerTruckType: string | null;
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
currentStatus: string;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
@@ -589,6 +605,7 @@ export interface ImportTrainItem {
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
allocatedWeightTons: number | null;
|
||||
freightType: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
weight: number | null;
|
||||
@@ -738,11 +755,25 @@ export interface FeeRule {
|
||||
zoneId?: string | null;
|
||||
freeDays: number;
|
||||
ratePerDay: number;
|
||||
tiers?: FeeRuleTier[];
|
||||
currency: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
export type SaveFeeRulePayload = Omit<FeeRule, 'id'>;
|
||||
|
||||
export interface FeeRuleTier {
|
||||
fromDay: number;
|
||||
toDay: number | null;
|
||||
ratePerDay: number;
|
||||
}
|
||||
|
||||
export interface FeePreviewTier extends FeeRuleTier {
|
||||
appliedFromDay: number;
|
||||
appliedToDay: number;
|
||||
days: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface FeePreview {
|
||||
ruleType: FeeRuleType;
|
||||
ruleId: string | null;
|
||||
@@ -760,6 +791,7 @@ export interface FeePreview {
|
||||
containerCount: number;
|
||||
billableUnits: number;
|
||||
amount: number;
|
||||
tiers?: FeePreviewTier[];
|
||||
}
|
||||
|
||||
export interface AllocationPreviewResult {
|
||||
@@ -868,6 +900,27 @@ export interface PayInvoicePayload {
|
||||
driverPhone?: string;
|
||||
}
|
||||
|
||||
export type WarehouseGatewayPaymentMethod = 'TELEBIRR' | 'WAAFI';
|
||||
|
||||
export interface InitiateWarehouseInvoicePaymentPayload {
|
||||
method: WarehouseGatewayPaymentMethod;
|
||||
platform?: 'web' | 'mobile';
|
||||
payerAccount?: string;
|
||||
returnUrl?: string;
|
||||
failureUrl?: string;
|
||||
}
|
||||
|
||||
export interface WarehouseInvoicePaymentResponse {
|
||||
intentId: string;
|
||||
status?: string;
|
||||
merchantOrderId?: string;
|
||||
clientAction?: {
|
||||
type?: string;
|
||||
url?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Payloads ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SaveWarehousePayload {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
@@ -65,6 +67,7 @@ function MetaItem({ label, value }: { label: string; value: string }) {
|
||||
export default function InvoiceDetailPage() {
|
||||
const { id = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [paymentMethod, setPaymentMethod] = useState<"TELEBIRR" | "WAAFI">("TELEBIRR");
|
||||
|
||||
const {
|
||||
data: invoice,
|
||||
@@ -89,6 +92,11 @@ export default function InvoiceDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!invoice) return;
|
||||
setPaymentMethod(invoice.currency === "USD" ? "WAAFI" : "TELEBIRR");
|
||||
}, [invoice?.currency]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py={80}>
|
||||
@@ -119,8 +127,14 @@ export default function InvoiceDetailPage() {
|
||||
|
||||
const payable = isPayable(invoice.status);
|
||||
const lines = invoice.lines ?? [];
|
||||
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
|
||||
|
||||
const handlePay = () => {
|
||||
<<<<<<< HEAD
|
||||
const returnUrl = `${window.location.origin}/payment/success`;
|
||||
const failureUrl = `${window.location.origin}/payment/failure`;
|
||||
payMutation.mutate({ id, payload: { method: paymentMethod, returnUrl, failureUrl } });
|
||||
=======
|
||||
setPayModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -170,6 +184,7 @@ export default function InvoiceDetailPage() {
|
||||
}
|
||||
toast.error("This invoice's source isn't linked to a booking.");
|
||||
}
|
||||
>>>>>>> 03740ee719f22f9617379a652b26f13b6870f671
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -199,6 +214,20 @@ export default function InvoiceDetailPage() {
|
||||
</Title>
|
||||
<InvoiceStatusBadge status={invoice.status} />
|
||||
</Group>
|
||||
<<<<<<< HEAD
|
||||
{payable && (
|
||||
<Group align="flex-end" gap="sm">
|
||||
<Select
|
||||
label="Payment provider"
|
||||
value={paymentMethod}
|
||||
onChange={(value) => setPaymentMethod((value as "TELEBIRR" | "WAAFI") ?? "TELEBIRR")}
|
||||
data={[
|
||||
{ value: "TELEBIRR", label: "Telebirr" },
|
||||
{ value: "WAAFI", label: "Waafi" },
|
||||
]}
|
||||
w={170}
|
||||
/>
|
||||
=======
|
||||
<Group gap={8} wrap="wrap">
|
||||
{canViewSource && (
|
||||
<Button
|
||||
@@ -242,6 +271,7 @@ export default function InvoiceDetailPage() {
|
||||
</Button>
|
||||
)}
|
||||
{payable && (
|
||||
>>>>>>> 03740ee719f22f9617379a652b26f13b6870f671
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
@@ -249,6 +279,14 @@ export default function InvoiceDetailPage() {
|
||||
leftSection={<CreditCard size={16} />}
|
||||
loading={payMutation.isPending}
|
||||
onClick={handlePay}
|
||||
<<<<<<< HEAD
|
||||
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
|
||||
>
|
||||
Pay {formatCurrency(amountDue, invoice.currency)}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
=======
|
||||
styles={{
|
||||
root: { fontWeight: 600, height: 42, paddingInline: 18 },
|
||||
}}
|
||||
@@ -258,6 +296,7 @@ export default function InvoiceDetailPage() {
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
>>>>>>> 03740ee719f22f9617379a652b26f13b6870f671
|
||||
</Group>
|
||||
|
||||
{/* Summary */}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ActivityCard } from "./components/ActivityCard";
|
||||
import { ClearanceCard } from "./components/ClearanceCard";
|
||||
import { ContainersCard } from "./components/ContainersCard";
|
||||
import { ContractCard } from "./components/ContractCard";
|
||||
import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard";
|
||||
import { DocRow, IconSquare } from "./components/Documents";
|
||||
import { KeyFactsStrip } from "./components/KeyFactsStrip";
|
||||
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
|
||||
@@ -38,7 +39,13 @@ import { SupportCard } from "./components/SupportCard";
|
||||
import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||
|
||||
export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||
export function ReadonlyBookingView({
|
||||
booking,
|
||||
onBookingUpdated,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
onBookingUpdated?: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
|
||||
useScrollToHash();
|
||||
@@ -102,7 +109,19 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
(isGeneralContract
|
||||
? status === "FULLY_EXECUTED"
|
||||
: status === "SELECTED_FOR_BATCH");
|
||||
const canApproveDelivery = status === "COMPLETED";
|
||||
const canApproveDelivery =
|
||||
status === "COMPLETED" ||
|
||||
(status === "TRUCK_ASSIGNED" && Boolean(booking.customerTruckArrivedAt));
|
||||
const usesCustomerTruck =
|
||||
booking.tradeDirection === "IMPORT"
|
||||
? !booking.lastMileDeliveryAddress
|
||||
: booking.tradeDirection === "EXPORT"
|
||||
? !booking.firstMilePickupAddress
|
||||
: !booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress;
|
||||
const canAssignCustomerTruck =
|
||||
booking.paymentStatus === "PAID" &&
|
||||
usesCustomerTruck &&
|
||||
["PAID", "IN_TRANSIT", "COMPLETED", "TRUCK_ASSIGNED"].includes(status);
|
||||
const showCountdown = canPay && !!booking.paymentDeadline;
|
||||
const isExpired = status === "EXPIRED";
|
||||
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
||||
@@ -193,6 +212,12 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
|
||||
<ShipmentTrackingCard bookingId={booking.id} />
|
||||
|
||||
{canAssignCustomerTruck && (
|
||||
<CustomerTruckAssignmentCard
|
||||
booking={booking}
|
||||
onAssigned={onBookingUpdated ?? (() => {})}
|
||||
/>
|
||||
)}
|
||||
<WarehousePaymentsSection bookingId={booking.id} />
|
||||
|
||||
{booking.files && booking.files.length > 0 && (
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Alert, Button, Group, Select, SimpleGrid, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { Download, Lock, Truck } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
|
||||
const ISO_CONTAINER_PATTERN = /^[A-Z]{4}\d{7}$/;
|
||||
|
||||
const downloadBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export function CustomerTruckAssignmentCard({
|
||||
booking,
|
||||
onAssigned,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
onAssigned: () => void;
|
||||
}) {
|
||||
const assigned = Boolean(booking.customerTruckAssignedAt);
|
||||
const [truckPlateNumber, setTruckPlateNumber] = useState(booking.customerTruckPlateNumber ?? "");
|
||||
const [driverName, setDriverName] = useState(booking.customerTruckDriverName ?? "");
|
||||
const [truckType, setTruckType] = useState(booking.customerTruckType ?? "");
|
||||
const [containerNumberToLoad, setContainerNumberToLoad] = useState(
|
||||
booking.customerTruckContainerNumber ?? "",
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const assignMutation = useMutation(api.bookings.assignCustomerTruck.mutationOptions());
|
||||
const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions());
|
||||
|
||||
const submit = async () => {
|
||||
const payload = {
|
||||
truckPlateNumber: truckPlateNumber.trim().toUpperCase(),
|
||||
driverName: driverName.trim(),
|
||||
truckType: truckType.trim(),
|
||||
containerNumberToLoad: containerNumberToLoad.trim().toUpperCase(),
|
||||
};
|
||||
if (!payload.truckPlateNumber || !payload.driverName || !payload.truckType || !payload.containerNumberToLoad) {
|
||||
setError("All truck assignment fields are required.");
|
||||
return;
|
||||
}
|
||||
if (!ISO_CONTAINER_PATTERN.test(payload.containerNumberToLoad)) {
|
||||
setError("Container number must match ISO format, e.g. ABCD1234567.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
await assignMutation.mutateAsync({ id: booking.id, payload });
|
||||
onAssigned();
|
||||
};
|
||||
|
||||
const downloadFreightOrder = async () => {
|
||||
const blob = await downloadMutation.mutateAsync({ id: booking.id });
|
||||
downloadBlob(blob, `freight-order-${booking.reference}.pdf`);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionCard>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap={10}>
|
||||
<Truck size={18} color="#0a9f6a" />
|
||||
<CardTitle>External Truck Assignment</CardTitle>
|
||||
</Group>
|
||||
{assigned && (
|
||||
<Group gap={6} c="#0a9f6a">
|
||||
<Lock size={14} />
|
||||
<Text size="sm" fw={700}>
|
||||
Truck Assigned
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{error && (
|
||||
<Alert color="red" variant="light">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{assignMutation.isError && (
|
||||
<Alert color="red" variant="light">
|
||||
{assignMutation.error instanceof Error
|
||||
? assignMutation.error.message
|
||||
: "Truck assignment failed."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<TextInput
|
||||
label="Truck Plate Number"
|
||||
required
|
||||
value={truckPlateNumber}
|
||||
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
|
||||
readOnly={assigned}
|
||||
/>
|
||||
<TextInput
|
||||
label="Driver Name"
|
||||
required
|
||||
value={driverName}
|
||||
onChange={(e) => setDriverName(e.currentTarget.value)}
|
||||
readOnly={assigned}
|
||||
/>
|
||||
<Select
|
||||
label="Truck Type"
|
||||
required
|
||||
data={TRUCK_TYPES}
|
||||
value={truckType || null}
|
||||
onChange={(value) => setTruckType(value ?? "")}
|
||||
disabled={assigned}
|
||||
/>
|
||||
<TextInput
|
||||
label="Container Number to Load"
|
||||
required
|
||||
value={containerNumberToLoad}
|
||||
onChange={(e) => setContainerNumberToLoad(e.currentTarget.value.toUpperCase())}
|
||||
readOnly={assigned}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Group justify="flex-end">
|
||||
{assigned ? (
|
||||
<Button
|
||||
leftSection={<Download size={16} />}
|
||||
color="edr-green"
|
||||
onClick={downloadFreightOrder}
|
||||
loading={downloadMutation.isPending}
|
||||
>
|
||||
Generate Freight Order Copies
|
||||
</Button>
|
||||
) : (
|
||||
<Button color="edr-green" onClick={submit} loading={assignMutation.isPending}>
|
||||
Verify & Submit Assignment
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -94,5 +94,5 @@ export default function BookingDetailPage() {
|
||||
<DraftBookingView booking={booking} onBookingUpdated={refetchBooking} />
|
||||
);
|
||||
}
|
||||
return <ReadonlyBookingView booking={booking} />;
|
||||
return <ReadonlyBookingView booking={booking} onBookingUpdated={refetchBooking} />;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,17 @@ const errorMessage = (error: unknown) => {
|
||||
return error instanceof Error ? error.message : "Could not approve delivery";
|
||||
};
|
||||
|
||||
const downloadBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export function ApproveDeliveryButton({
|
||||
bookingId,
|
||||
stopPropagation,
|
||||
@@ -32,10 +43,19 @@ export function ApproveDeliveryButton({
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handoverMutation = useMutation(api.bookings.downloadHandoverDocument.mutationOptions());
|
||||
|
||||
const mutation = useMutation({
|
||||
...api.bookings.approveDelivery.mutationOptions(),
|
||||
onSuccess: async () => {
|
||||
toast.success("Delivery approved and handover signed");
|
||||
onSuccess: async (result) => {
|
||||
try {
|
||||
const blob = await handoverMutation.mutateAsync({ inventoryId: result.inventoryId });
|
||||
downloadBlob(blob, `handover-${bookingId}.pdf`);
|
||||
toast.success("Delivery approved and signed handover downloaded");
|
||||
} catch {
|
||||
toast.success("Delivery approved and handover signed");
|
||||
toast.error("Signed handover document could not be downloaded");
|
||||
}
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: bookingId }) }),
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
|
||||
@@ -48,6 +68,8 @@ export function ApproveDeliveryButton({
|
||||
toast.error(message);
|
||||
if (message.toLowerCase().includes("save your signature")) {
|
||||
navigate("/signature");
|
||||
} else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
|
||||
navigate("/billing");
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -64,7 +86,7 @@ export function ApproveDeliveryButton({
|
||||
variant={variant}
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
loading={mutation.isPending}
|
||||
loading={mutation.isPending || handoverMutation.isPending}
|
||||
onClick={handleClick}
|
||||
>
|
||||
Approve delivery
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type ApproveDeliveryResponse,
|
||||
BookingListFilter,
|
||||
CreateBookingPayload,
|
||||
type CustomerTruckAssignmentPayload,
|
||||
GeneratePriceResponse,
|
||||
SubmitBookingResponse,
|
||||
} from "./bookings.service";
|
||||
@@ -210,6 +211,25 @@ export const api = {
|
||||
({ id }) => bookingsService.tracking(id),
|
||||
),
|
||||
|
||||
assignCustomerTruck: endpoint<
|
||||
{ id: string; payload: CustomerTruckAssignmentPayload },
|
||||
Freight.IBooking
|
||||
>("bookings", "assignCustomerTruck", ({ id, payload }) =>
|
||||
bookingsService.assignCustomerTruck(id, payload),
|
||||
),
|
||||
|
||||
downloadCustomerTruckFreightOrder: endpoint<{ id: string }, Blob>(
|
||||
"bookings",
|
||||
"downloadCustomerTruckFreightOrder",
|
||||
({ id }) => bookingsService.downloadCustomerTruckFreightOrder(id),
|
||||
),
|
||||
|
||||
downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>(
|
||||
"bookings",
|
||||
"downloadHandoverDocument",
|
||||
({ inventoryId }) => bookingsService.downloadHandoverDocument(inventoryId),
|
||||
),
|
||||
|
||||
create: endpoint<
|
||||
{ payload: CreateBookingPayload; documents?: BookingDocuments },
|
||||
Freight.IBooking
|
||||
|
||||
@@ -79,6 +79,13 @@ export interface ApproveDeliveryResponse {
|
||||
signerDisplayName: string;
|
||||
}
|
||||
|
||||
export interface CustomerTruckAssignmentPayload {
|
||||
truckPlateNumber: string;
|
||||
driverName: string;
|
||||
truckType: string;
|
||||
containerNumberToLoad: string;
|
||||
}
|
||||
|
||||
export interface BookingListFilter {
|
||||
status?: string;
|
||||
/** Comma-separated statuses (overrides `status` when set). */
|
||||
@@ -111,6 +118,30 @@ export const bookingsService = {
|
||||
const { data } = await client.get(`/api/bookings/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
assignCustomerTruck: async (
|
||||
id: string,
|
||||
payload: CustomerTruckAssignmentPayload,
|
||||
): Promise<Freight.IBooking> => {
|
||||
const { data } = await client.post(
|
||||
`/api/bookings/${id}/customer-truck-assignment`,
|
||||
payload,
|
||||
);
|
||||
return data.data;
|
||||
},
|
||||
downloadCustomerTruckFreightOrder: async (id: string): Promise<Blob> => {
|
||||
const { data } = await client.get(
|
||||
`/api/bookings/${id}/customer-truck-assignment/freight-order`,
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
downloadHandoverDocument: async (inventoryId: string): Promise<Blob> => {
|
||||
const { data } = await client.get(
|
||||
`/api/warehouse-inventory/${inventoryId}/handover-document`,
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
tracking: async (id: string): Promise<Freight.IBookingTracking> => {
|
||||
const { data } = await client.get(`/api/bookings/${id}/tracking`);
|
||||
return data.data;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { LogIn, UserPlus, ChevronLeft } from 'lucide-react';
|
||||
|
||||
function Tooltip({ children, content }: { children: React.ReactNode; content: string[] }) {
|
||||
@@ -41,6 +42,23 @@ function Tooltip({ children, content }: { children: React.ReactNode; content: st
|
||||
export default function AuthCheckPage() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, initialize } = useAuthStore();
|
||||
const searchCriteria = useBookingStore((s) => s.searchCriteria);
|
||||
|
||||
const buildResultsUrl = () => {
|
||||
if (!searchCriteria) return '/booking/results';
|
||||
const params = new URLSearchParams({
|
||||
tripType: searchCriteria.tripType,
|
||||
origin: searchCriteria.originStationId,
|
||||
destination: searchCriteria.destinationStationId,
|
||||
date: searchCriteria.departureDate,
|
||||
adults: searchCriteria.adultCount.toString(),
|
||||
children: searchCriteria.childCount.toString(),
|
||||
nationality: searchCriteria.nationality,
|
||||
});
|
||||
if (searchCriteria.returnDate) params.set('returnDate', searchCriteria.returnDate);
|
||||
if (searchCriteria.promoCode) params.set('promoCode', searchCriteria.promoCode);
|
||||
return `/booking/results?${params}`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
initialize();
|
||||
@@ -94,7 +112,7 @@ export default function AuthCheckPage() {
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<button
|
||||
onClick={() => router.push('/booking/results')}
|
||||
onClick={() => router.push(buildResultsUrl())}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Wallet
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { formatTime, getTimePeriod } from '@/utils/format';
|
||||
import QRCode from 'qrcode.react';
|
||||
|
||||
function BookingDetailContent() {
|
||||
@@ -234,10 +235,10 @@ function BookingDetailContent() {
|
||||
{/* Origin */}
|
||||
<div className="pb-8">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'HH:mm') : '--:--'}
|
||||
{booking.schedule?.departureAt ? formatTime(booking.schedule.departureAt) : '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'EEE, MMM d') : 'N/A'}
|
||||
{booking.schedule?.departureAt ? `${format(new Date(booking.schedule.departureAt), 'EEE, MMM d')} · ${getTimePeriod(booking.schedule.departureAt)}` : 'N/A'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{booking.schedule?.origin?.name}
|
||||
@@ -267,10 +268,10 @@ function BookingDetailContent() {
|
||||
{/* Destination */}
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'HH:mm') : '--:--'}
|
||||
{booking.schedule?.arrivalAt ? formatTime(booking.schedule.arrivalAt) : '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'EEE, MMM d') : 'N/A'}
|
||||
{booking.schedule?.arrivalAt ? `${format(new Date(booking.schedule.arrivalAt), 'EEE, MMM d')} · ${getTimePeriod(booking.schedule.arrivalAt)}` : 'N/A'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{booking.schedule?.destination?.name}
|
||||
@@ -493,10 +494,10 @@ function BookingDetailContent() {
|
||||
{/* Origin */}
|
||||
<div className="pb-8">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'HH:mm') : '--:--'}
|
||||
{booking.schedule?.departureAt ? formatTime(booking.schedule.departureAt) : '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'EEE, MMM d') : 'N/A'}
|
||||
{booking.schedule?.departureAt ? `${format(new Date(booking.schedule.departureAt), 'EEE, MMM d')} · ${getTimePeriod(booking.schedule.departureAt)}` : 'N/A'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{booking.schedule?.origin?.name}
|
||||
@@ -526,10 +527,10 @@ function BookingDetailContent() {
|
||||
{/* Destination */}
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'HH:mm') : '--:--'}
|
||||
{booking.schedule?.arrivalAt ? formatTime(booking.schedule.arrivalAt) : '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'EEE, MMM d') : 'N/A'}
|
||||
{booking.schedule?.arrivalAt ? `${format(new Date(booking.schedule.arrivalAt), 'EEE, MMM d')} · ${getTimePeriod(booking.schedule.arrivalAt)}` : 'N/A'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{booking.schedule?.destination?.name}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useBookingStore } from '@/lib/booking-store';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { CheckCircle, ExternalLink, Loader2, CalendarDays, X, Globe, ChevronLeft } from 'lucide-react';
|
||||
import { CheckCircle, ExternalLink, Loader2, CalendarDays, X, Globe, ChevronLeft, AlertCircle } from 'lucide-react';
|
||||
import { gregorianToEthiopian, ethiopianToGregorian, ETHIOPIAN_MONTHS, getDaysInEthiopianMonth } from '@/lib/ethiopian-calendar';
|
||||
|
||||
const GC_MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||
@@ -17,24 +17,73 @@ function daysInGCMonth(y: number, m: number) {
|
||||
return new Date(y, m, 0).getDate();
|
||||
}
|
||||
|
||||
const ADULT_MIN_AGE = 6; // adults must be older than 5 years
|
||||
const CHILD_MAX_AGE = 5; // children must be 5 years old or younger
|
||||
const ADULT_MAX_AGE_SPAN = 110;
|
||||
const ADULT_DEFAULT_YEAR = 2000;
|
||||
|
||||
// ─── fayda passenger tracking ───────────────────────────────────────────────
|
||||
// Fayda's OAuth redirect can land back on this route either inside the popup
|
||||
// window we opened, or — on browsers/mobile contexts that refuse to open a
|
||||
// popup — as a full-page navigation of this same tab. React state doesn't
|
||||
// survive that reload, so we track which passenger triggered the verification
|
||||
// in sessionStorage, which does survive same-tab navigation.
|
||||
const FAYDA_PENDING_INDEX_KEY = 'edr_fayda_pending_passenger_index';
|
||||
|
||||
function getPendingFaydaIndex(): number | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const raw = window.sessionStorage.getItem(FAYDA_PENDING_INDEX_KEY);
|
||||
if (raw === null) return null;
|
||||
const parsed = parseInt(raw, 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
function setPendingFaydaIndex(index: number) {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.sessionStorage.setItem(FAYDA_PENDING_INDEX_KEY, String(index));
|
||||
}
|
||||
|
||||
function clearPendingFaydaIndex() {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.sessionStorage.removeItem(FAYDA_PENDING_INDEX_KEY);
|
||||
}
|
||||
|
||||
// Fayda may return gender as "MALE"/"M" etc — normalize to the form's expected values
|
||||
function normalizeFaydaGender(raw: unknown): 'Male' | 'Female' | '' {
|
||||
const g = String(raw || '').trim().toUpperCase();
|
||||
if (g === 'MALE' || g === 'M') return 'Male';
|
||||
if (g === 'FEMALE' || g === 'F') return 'Female';
|
||||
return '';
|
||||
}
|
||||
|
||||
function DobPickerModal({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
passengerType = 'ADULT',
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (iso: string) => void;
|
||||
error?: string;
|
||||
passengerType?: 'ADULT' | 'CHILD';
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [manualMode, setManualMode] = useState(false);
|
||||
const [calType, setCalType] = useState<'gregorian' | 'ethiopian'>('gregorian');
|
||||
const isChild = passengerType === 'CHILD';
|
||||
const currentYear = new Date().getFullYear();
|
||||
const currentEthYear = gregorianToEthiopian(new Date()).year;
|
||||
|
||||
// Selectable year bounds, scoped to passenger type so the picker can't produce an invalid age
|
||||
const minGCYear = isChild ? currentYear - CHILD_MAX_AGE : currentYear - ADULT_MAX_AGE_SPAN;
|
||||
const maxGCYear = isChild ? currentYear : currentYear - ADULT_MIN_AGE;
|
||||
const minEthYear = isChild ? currentEthYear - CHILD_MAX_AGE : currentEthYear - ADULT_MAX_AGE_SPAN;
|
||||
const maxEthYear = isChild ? currentEthYear : currentEthYear - ADULT_MIN_AGE;
|
||||
|
||||
// Parse stored ISO value (always Gregorian)
|
||||
const parsed = value ? value.split('-') : [];
|
||||
const initGCYear = parsed[0] ? parseInt(parsed[0]) : currentYear - 25;
|
||||
const defaultGCYear = isChild ? maxGCYear : Math.min(Math.max(ADULT_DEFAULT_YEAR, minGCYear), maxGCYear);
|
||||
const initGCYear = parsed[0] ? parseInt(parsed[0]) : defaultGCYear;
|
||||
const initGCMonth = parsed[1] ? parseInt(parsed[1]) : 1;
|
||||
const initGCDay = parsed[2] ? parseInt(parsed[2]) : 1;
|
||||
|
||||
@@ -44,7 +93,7 @@ function DobPickerModal({
|
||||
const [selGCDay, setSelGCDay] = useState(initGCDay);
|
||||
|
||||
// Ethiopian drum state — initialise from parsed value if present
|
||||
const initEth = value ? gregorianToEthiopian(new Date(initGCYear, initGCMonth - 1, initGCDay)) : { year: currentEthYear - 25, month: 1, day: 1 };
|
||||
const initEth = value ? gregorianToEthiopian(new Date(initGCYear, initGCMonth - 1, initGCDay)) : { year: isChild ? maxEthYear : Math.min(Math.max(ADULT_DEFAULT_YEAR - 8, minEthYear), maxEthYear), month: 1, day: 1 };
|
||||
const [selEthYear, setSelEthYear] = useState(initEth.year);
|
||||
const [selEthMonth, setSelEthMonth] = useState(initEth.month);
|
||||
const [selEthDay, setSelEthDay] = useState(initEth.day);
|
||||
@@ -60,8 +109,8 @@ function DobPickerModal({
|
||||
const gcSafeDay = Math.min(selGCDay, gcMaxDay);
|
||||
const ethSafeDay = Math.min(selEthDay, ethMaxDay);
|
||||
|
||||
const gcYears = Array.from({ length: 100 }, (_, i) => currentYear - i);
|
||||
const ethYears = Array.from({ length: 100 }, (_, i) => currentEthYear - i);
|
||||
const gcYears = Array.from({ length: maxGCYear - minGCYear + 1 }, (_, i) => maxGCYear - i);
|
||||
const ethYears = Array.from({ length: maxEthYear - minEthYear + 1 }, (_, i) => maxEthYear - i);
|
||||
const gcMonths = GC_MONTHS.map((m, i) => ({ label: m, value: i + 1 }));
|
||||
const ethMonths = ETHIOPIAN_MONTHS.map((m, i) => ({ label: m, value: i + 1 }));
|
||||
const gcDays = Array.from({ length: gcMaxDay }, (_, i) => i + 1);
|
||||
@@ -115,11 +164,11 @@ function DobPickerModal({
|
||||
|
||||
const gcDayHandler = useRef(makeScrollHandler(dayRef, setSelGCDay, () => Array.from({ length: daysInGCMonth(selGCYear, selGCMonth) }, (_, i) => i + 1))).current;
|
||||
const gcMonthHandler = useRef(makeScrollHandler(monthRef, setSelGCMonth, () => GC_MONTHS.map((_, i) => i + 1))).current;
|
||||
const gcYearHandler = useRef(makeScrollHandler(yearRef, setSelGCYear, () => Array.from({ length: 100 }, (_, i) => new Date().getFullYear() - i))).current;
|
||||
const gcYearHandler = useRef(makeScrollHandler(yearRef, setSelGCYear, () => gcYears)).current;
|
||||
|
||||
const ethDayHandler = useRef(makeScrollHandler(dayRef, setSelEthDay, () => Array.from({ length: getDaysInEthiopianMonth(selEthYear, selEthMonth) }, (_, i) => i + 1))).current;
|
||||
const ethMonthHandler = useRef(makeScrollHandler(monthRef, setSelEthMonth, () => ETHIOPIAN_MONTHS.map((_, i) => i + 1))).current;
|
||||
const ethYearHandler = useRef(makeScrollHandler(yearRef, setSelEthYear, () => Array.from({ length: 100 }, (_, i) => gregorianToEthiopian(new Date()).year - i))).current;
|
||||
const ethYearHandler = useRef(makeScrollHandler(yearRef, setSelEthYear, () => ethYears)).current;
|
||||
|
||||
const confirm = () => {
|
||||
let gregDate: Date;
|
||||
@@ -137,16 +186,20 @@ function DobPickerModal({
|
||||
|
||||
const confirmManual = () => {
|
||||
const d = parseInt(manDay), m = parseInt(manMonth), y = parseInt(manYear);
|
||||
if (!d || !m || !y || m < 1 || m > 12 || d < 1 || d > daysInGCMonth(y, m) || y < currentYear - 110 || y > currentYear) return;
|
||||
if (!d || !m || !y || m < 1 || m > 12 || d < 1 || d > daysInGCMonth(y, m) || y < minGCYear || y > maxGCYear) return;
|
||||
onChange(`${y}-${String(m).padStart(2,'0')}-${String(d).padStart(2,'0')}`);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const manualValid = (() => {
|
||||
const d = parseInt(manDay), m = parseInt(manMonth), y = parseInt(manYear);
|
||||
return d >= 1 && m >= 1 && m <= 12 && y >= currentYear - 110 && y <= currentYear && d <= daysInGCMonth(y, m);
|
||||
return d >= 1 && m >= 1 && m <= 12 && y >= minGCYear && y <= maxGCYear && d <= daysInGCMonth(y, m);
|
||||
})();
|
||||
|
||||
const manualRangeMessage = isChild
|
||||
? `Child's date of birth must fall between ${minGCYear} and ${maxGCYear} (age 5 or younger)`
|
||||
: `Adult's date of birth must fall between ${minGCYear} and ${maxGCYear} (age older than 5)`;
|
||||
|
||||
// Display: always show Gregorian ISO as human-readable, with calendar label
|
||||
const displayValue = (() => {
|
||||
if (!value) return '';
|
||||
@@ -276,11 +329,12 @@ function DobPickerModal({
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Year</label>
|
||||
<input type="number" min={currentYear - 110} max={currentYear} value={manYear} onChange={(e) => setManYear(e.target.value)} placeholder="YYYY" className="input-field text-center text-lg font-semibold" />
|
||||
<input type="number" min={minGCYear} max={maxGCYear} value={manYear} onChange={(e) => setManYear(e.target.value)} placeholder="YYYY" className="input-field text-center text-lg font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">Valid years: {minGCYear} – {maxGCYear}</p>
|
||||
{manDay && manMonth && manYear && !manualValid && (
|
||||
<p className="text-red-500 text-xs">Please enter a valid Gregorian date</p>
|
||||
<p className="text-red-500 text-xs">{manualRangeMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -453,6 +507,21 @@ function PhoneInput({
|
||||
);
|
||||
}
|
||||
|
||||
// ─── age calculation ─────────────────────────────────────────────────────────
|
||||
|
||||
function calculateAge(dateOfBirth: string): number | null {
|
||||
if (!dateOfBirth) return null;
|
||||
const dob = new Date(dateOfBirth);
|
||||
if (isNaN(dob.getTime())) return null;
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - dob.getFullYear();
|
||||
const monthDiff = today.getMonth() - dob.getMonth();
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
|
||||
age--;
|
||||
}
|
||||
return age;
|
||||
}
|
||||
|
||||
// ─── passenger zod schema ──────────────────────────────────────────────────────
|
||||
|
||||
const passengerSchema = z.object({
|
||||
@@ -460,8 +529,6 @@ const passengerSchema = z.object({
|
||||
dateOfBirth: z.string().min(1, 'Date of birth is required'),
|
||||
gender: z.string().min(1, 'Gender is required'),
|
||||
nationality: z.string().min(1, 'Nationality is required'),
|
||||
phone: z.string(),
|
||||
email: z.string().optional(),
|
||||
nationalId: z.string().optional(),
|
||||
passportNumber: z.string().optional(),
|
||||
passportCountry: z.string().optional(),
|
||||
@@ -475,16 +542,6 @@ const passengerSchema = z.object({
|
||||
if (data.gender !== 'Male' && data.gender !== 'Female') {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Gender is required', path: ['gender'] });
|
||||
}
|
||||
if (data.email && data.email.trim().length > 0) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(data.email)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['email'] });
|
||||
}
|
||||
}
|
||||
const phoneError = validatePhone(data.phone, data.nationality);
|
||||
if (phoneError) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: phoneError, path: ['phone'] });
|
||||
}
|
||||
const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian';
|
||||
if (isNonEthiopian) {
|
||||
if (!data.passportNumber || data.passportNumber.trim().length === 0) {
|
||||
@@ -496,12 +553,41 @@ const passengerSchema = z.object({
|
||||
}
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
passengers: z.array(passengerSchema),
|
||||
createAccount: z.boolean(),
|
||||
});
|
||||
function createFormSchema(adultCount: number) {
|
||||
return z.object({
|
||||
passengers: z.array(passengerSchema),
|
||||
createAccount: z.boolean(),
|
||||
contactPhone: z.string(),
|
||||
contactEmail: z.string(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (!data.contactEmail || data.contactEmail.trim().length === 0) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Contact email is required', path: ['contactEmail'] });
|
||||
} else {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(data.contactEmail)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['contactEmail'] });
|
||||
}
|
||||
}
|
||||
const contactNationality = data.passengers[0]?.nationality || 'ETHIOPIAN';
|
||||
const phoneError = validatePhone(data.contactPhone, contactNationality);
|
||||
if (phoneError) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: phoneError, path: ['contactPhone'] });
|
||||
}
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
data.passengers.forEach((p, i) => {
|
||||
const age = calculateAge(p.dateOfBirth);
|
||||
if (age === null) return;
|
||||
const isAdult = i < adultCount;
|
||||
if (isAdult && age <= 5) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Adult passengers must be older than 5 years', path: ['passengers', i, 'dateOfBirth'] });
|
||||
} else if (!isAdult && age > 5) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Child passengers must be 5 years old or younger', path: ['passengers', i, 'dateOfBirth'] });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
type FormData = z.infer<ReturnType<typeof createFormSchema>>;
|
||||
|
||||
export default function PassengersPage() {
|
||||
const router = useRouter();
|
||||
@@ -509,17 +595,24 @@ export default function PassengersPage() {
|
||||
const { user, isAuthenticated, updateUser } = useAuthStore();
|
||||
const isInitialized = useAuthStore((s) => s.isInitialized);
|
||||
const [faydaEnabled, setFaydaEnabled] = useState(true);
|
||||
const [verificationStatus, setVerificationStatus] = useState<Record<number, 'success' | 'error'>>({});
|
||||
const [verificationStatus, setVerificationStatus] = useState<Record<number, 'pending' | 'success' | 'error'>>({});
|
||||
const [faydaErrors, setFaydaErrors] = useState<Record<number, string>>({});
|
||||
// The passenger currently mid-verification (popup open / awaiting callback). Only one
|
||||
// passenger can verify at a time so a stray callback can never be misapplied to the
|
||||
// wrong passenger's form.
|
||||
const [verifyingIndex, setVerifyingIndex] = useState<number | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [formInitialized, setFormInitialized] = useState(false);
|
||||
const [faydaParams, setFaydaParams] = useState<{ code: string; state: string } | null>(null);
|
||||
const [faydaCompleting, setFaydaCompleting] = useState(false);
|
||||
|
||||
|
||||
const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0);
|
||||
const adultCount = searchCriteria?.adultCount || 1;
|
||||
|
||||
const { register, control, handleSubmit, setValue, watch, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema as any),
|
||||
resolver: zodResolver(createFormSchema(adultCount) as any),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
passengers: Array.from({ length: totalPassengers }, (_, i) => {
|
||||
@@ -530,8 +623,6 @@ export default function PassengersPage() {
|
||||
dateOfBirth: stored.dateOfBirth || '',
|
||||
gender: (stored.gender as any) || undefined,
|
||||
nationality: stored.nationality || searchCriteria?.nationality || 'ETHIOPIAN',
|
||||
phone: stored.phone || '',
|
||||
email: stored.email || '',
|
||||
nationalId: stored.nationalId || '',
|
||||
passportNumber: stored.passportNumber || '',
|
||||
passportCountry: stored.passportCountry || '',
|
||||
@@ -547,8 +638,6 @@ export default function PassengersPage() {
|
||||
dateOfBirth: '',
|
||||
gender: undefined,
|
||||
nationality: searchCriteria?.nationality || 'ETHIOPIAN',
|
||||
phone: '',
|
||||
email: '',
|
||||
nationalId: '',
|
||||
passportNumber: '',
|
||||
passportCountry: '',
|
||||
@@ -556,10 +645,12 @@ export default function PassengersPage() {
|
||||
passportExpiryDate: '',
|
||||
passportIssuingAuthority: '',
|
||||
faydaVerified: false,
|
||||
formExpanded: false,
|
||||
formExpanded: i >= adultCount,
|
||||
};
|
||||
}),
|
||||
createAccount: false,
|
||||
contactPhone: storedPassengers[0]?.phone || '',
|
||||
contactEmail: storedPassengers[0]?.email || '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -584,15 +675,24 @@ export default function PassengersPage() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
if (code && state) setFaydaParams({ code, state });
|
||||
if (code && state) {
|
||||
setFaydaParams({ code, state });
|
||||
const pendingIndex = getPendingFaydaIndex();
|
||||
if (pendingIndex !== null) setVerifyingIndex(pendingIndex);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Complete Fayda verification once the form is ready and callback params are present
|
||||
// Complete Fayda verification once the form is ready and callback params are present.
|
||||
// This effect runs whenever this route reloads with ?code&state — which happens either
|
||||
// inside the verification popup, or, if the browser refused to open a popup, as a full
|
||||
// navigation of this same tab. Either way, the index of the passenger who started the
|
||||
// verification was stashed in sessionStorage before redirecting, so it survives the reload.
|
||||
useEffect(() => {
|
||||
if (!faydaParams || !formInitialized) return;
|
||||
|
||||
const complete = async () => {
|
||||
setFaydaCompleting(true);
|
||||
const targetIndex = getPendingFaydaIndex() ?? 0;
|
||||
try {
|
||||
const response: any = await apiClient.get(
|
||||
`/fayda/verification/complete?code=${encodeURIComponent(faydaParams.code)}&state=${encodeURIComponent(faydaParams.state)}`
|
||||
@@ -600,25 +700,50 @@ export default function PassengersPage() {
|
||||
|
||||
if (response?.success && response?.data?.verified) {
|
||||
const d = response.data;
|
||||
// Convert "1980/12/01" → "1980-12-01"
|
||||
const dob = d.birthdate ? (d.birthdate as string).replace(/\//g, '-') : '';
|
||||
const faydaSub: string | undefined = d.sub || d.faydaSub || d.fin;
|
||||
|
||||
setValue('passengers.0.name', d.fullName || '', { shouldValidate: true });
|
||||
if (dob) setValue('passengers.0.dateOfBirth', dob, { shouldValidate: true });
|
||||
if (d.email) setValue('passengers.0.email', d.email, { shouldValidate: true });
|
||||
if (d.phoneNumber) setValue('passengers.0.phone', d.phoneNumber, { shouldValidate: true });
|
||||
setValue('passengers.0.faydaVerified', true);
|
||||
setValue('passengers.0.formExpanded', true);
|
||||
setVerificationStatus((prev) => ({ ...prev, 0: 'success' }));
|
||||
// A single Fayda identity can't be reused across two different passengers.
|
||||
const usedByOther = faydaSub && passengers.some(
|
||||
(p, i) => i !== targetIndex && (p as any).faydaSub === faydaSub,
|
||||
);
|
||||
|
||||
// Remove code/state from the URL so a refresh doesn't re-trigger
|
||||
router.replace('/booking/passengers');
|
||||
if (usedByOther) {
|
||||
setFaydaErrors((prev) => ({ ...prev, [targetIndex]: 'This Fayda identity is already linked to another passenger on this booking.' }));
|
||||
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'error' }));
|
||||
} else {
|
||||
// Convert "1980/12/01" → "1980-12-01"
|
||||
const dob = d.birthdate ? (d.birthdate as string).replace(/\//g, '-') : '';
|
||||
|
||||
setValue(`passengers.${targetIndex}.name`, d.fullName || '', { shouldValidate: true });
|
||||
if (dob) setValue(`passengers.${targetIndex}.dateOfBirth`, dob, { shouldValidate: true });
|
||||
const normalizedGender = normalizeFaydaGender(d.gender);
|
||||
if (normalizedGender) setValue(`passengers.${targetIndex}.gender`, normalizedGender, { shouldValidate: true });
|
||||
if (faydaSub) setValue(`passengers.${targetIndex}.faydaSub`, faydaSub);
|
||||
// Contact info is shared across all passengers — only fill it in if nobody has
|
||||
// entered it yet, so verifying passenger 2 can't clobber passenger 1's contact.
|
||||
if (d.email && !watch('contactEmail')) setValue('contactEmail', d.email, { shouldValidate: true });
|
||||
if (d.phoneNumber && !watch('contactPhone')) setValue('contactPhone', d.phoneNumber, { shouldValidate: true });
|
||||
setValue(`passengers.${targetIndex}.faydaVerified`, true);
|
||||
setValue(`passengers.${targetIndex}.formExpanded`, true);
|
||||
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'success' }));
|
||||
setFaydaErrors((prev) => { const next = { ...prev }; delete next[targetIndex]; return next; });
|
||||
}
|
||||
} else {
|
||||
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'error' }));
|
||||
setFaydaErrors((prev) => ({ ...prev, [targetIndex]: 'Fayda verification could not be completed. Please try again or enter details manually.' }));
|
||||
}
|
||||
|
||||
// Remove code/state from the URL so a refresh doesn't re-trigger
|
||||
router.replace('/booking/passengers');
|
||||
} catch (error) {
|
||||
console.error('Failed to complete Fayda verification:', error);
|
||||
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'error' }));
|
||||
setFaydaErrors((prev) => ({ ...prev, [targetIndex]: 'Fayda verification failed. Please try again or enter details manually.' }));
|
||||
} finally {
|
||||
setFaydaCompleting(false);
|
||||
setFaydaParams(null);
|
||||
setVerifyingIndex(null);
|
||||
clearPendingFaydaIndex();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -656,8 +781,8 @@ export default function PassengersPage() {
|
||||
setValue('passengers.0.dateOfBirth', passengerData?.dateOfBirth || user.dateOfBirth || '');
|
||||
if (passengerData?.gender || user.gender) setValue('passengers.0.gender', (passengerData?.gender || user.gender) as any);
|
||||
setValue('passengers.0.nationality', passengerData?.nationality || user.nationality || 'ETHIOPIAN');
|
||||
if (passengerData?.phone || user.phone) setValue('passengers.0.phone', passengerData?.phone || user.phone || '');
|
||||
if (passengerData?.email || user.email) setValue('passengers.0.email', passengerData?.email || user.email || '');
|
||||
if (passengerData?.phone || user.phone) setValue('contactPhone', passengerData?.phone || user.phone || '');
|
||||
if (passengerData?.email || user.email) setValue('contactEmail', passengerData?.email || user.email || '');
|
||||
if (passengerData?.passportNumber) setValue('passengers.0.passportNumber', passengerData.passportNumber);
|
||||
if (passengerData?.passportCountry) setValue('passengers.0.passportCountry', passengerData.passportCountry);
|
||||
if (passengerData?.passportIssueDate) setValue('passengers.0.passportIssueDate', passengerData.passportIssueDate);
|
||||
@@ -678,6 +803,16 @@ export default function PassengersPage() {
|
||||
|
||||
const openFaydaVerification = async (index: number) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
// Only one passenger can verify at a time — this keeps the status poll below
|
||||
// (which has no passenger identifier of its own) unambiguous about who it belongs to.
|
||||
if (verifyingIndex !== null) return;
|
||||
|
||||
setVerifyingIndex(index);
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'pending' }));
|
||||
setFaydaErrors((prev) => { const next = { ...prev }; delete next[index]; return next; });
|
||||
// Persist which passenger this is for so it survives a full-page redirect/reload
|
||||
// if the browser can't open a popup (e.g. some mobile browsers).
|
||||
setPendingFaydaIndex(index);
|
||||
|
||||
try {
|
||||
const response: any = await apiClient.post('/fayda/verification/start', {
|
||||
@@ -698,33 +833,67 @@ export default function PassengersPage() {
|
||||
`width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes`
|
||||
);
|
||||
|
||||
if (!popup) {
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
|
||||
setFaydaErrors((prev) => ({ ...prev, [index]: 'Unable to open the Fayda verification window. Please allow pop-ups and try again.' }));
|
||||
setVerifyingIndex(null);
|
||||
clearPendingFaydaIndex();
|
||||
return;
|
||||
}
|
||||
|
||||
const checkPopup = setInterval(async () => {
|
||||
if (popup?.closed) {
|
||||
if (popup.closed) {
|
||||
clearInterval(checkPopup);
|
||||
try {
|
||||
const statusResponse: any = await apiClient.get('/fayda/verification/status');
|
||||
if (statusResponse.verified) {
|
||||
setValue(`passengers.${index}.name`, statusResponse.fullName || '');
|
||||
setValue(`passengers.${index}.faydaVerified`, true);
|
||||
setValue(`passengers.${index}.formExpanded`, true);
|
||||
setVerificationStatus({ ...verificationStatus, [index]: 'success' });
|
||||
const faydaSub: string | undefined = statusResponse.sub || statusResponse.faydaSub || statusResponse.fin;
|
||||
const usedByOther = faydaSub && passengers.some(
|
||||
(p, i) => i !== index && (p as any).faydaSub === faydaSub,
|
||||
);
|
||||
|
||||
if (index === 0 && isAuthenticated) {
|
||||
updateUser({
|
||||
fullName: statusResponse.fullName,
|
||||
faydaVerified: true,
|
||||
faydaVerifiedAt: statusResponse.verifiedAt,
|
||||
});
|
||||
if (usedByOther) {
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
|
||||
setFaydaErrors((prev) => ({ ...prev, [index]: 'This Fayda identity is already linked to another passenger on this booking.' }));
|
||||
} else {
|
||||
setValue(`passengers.${index}.name`, statusResponse.fullName || '', { shouldValidate: true });
|
||||
if (statusResponse.dateOfBirth) setValue(`passengers.${index}.dateOfBirth`, statusResponse.dateOfBirth, { shouldValidate: true });
|
||||
const normalizedGender = normalizeFaydaGender(statusResponse.gender);
|
||||
if (normalizedGender) setValue(`passengers.${index}.gender`, normalizedGender, { shouldValidate: true });
|
||||
if (faydaSub) setValue(`passengers.${index}.faydaSub`, faydaSub);
|
||||
setValue(`passengers.${index}.faydaVerified`, true);
|
||||
setValue(`passengers.${index}.formExpanded`, true);
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'success' }));
|
||||
setFaydaErrors((prev) => { const next = { ...prev }; delete next[index]; return next; });
|
||||
|
||||
if (index === 0 && isAuthenticated) {
|
||||
updateUser({
|
||||
fullName: statusResponse.fullName,
|
||||
faydaVerified: true,
|
||||
faydaVerifiedAt: statusResponse.verifiedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
|
||||
setFaydaErrors((prev) => ({ ...prev, [index]: 'Fayda verification was not completed. Please try again or enter details manually.' }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get verification status:', error);
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
|
||||
setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to confirm verification status. Please try again or enter details manually.' }));
|
||||
} finally {
|
||||
setVerifyingIndex(null);
|
||||
clearPendingFaydaIndex();
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Failed to start Fayda verification:', error);
|
||||
alert('Failed to start verification. Please try again.');
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
|
||||
setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to start verification. Please try again.' }));
|
||||
setVerifyingIndex(null);
|
||||
clearPendingFaydaIndex();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -732,8 +901,15 @@ export default function PassengersPage() {
|
||||
setValue(`passengers.${index}.formExpanded`, !passengers[index].formExpanded);
|
||||
};
|
||||
|
||||
const onInvalid = () => {
|
||||
// Sections still behind the Fayda verify screen stay collapsed here — they only expand
|
||||
// when the user explicitly clicks "Skip for now" / "Enter details manually".
|
||||
setSubmitError('Please fix the highlighted errors before continuing.');
|
||||
};
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
setSaving(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
let passengerId = '';
|
||||
|
||||
@@ -759,8 +935,8 @@ export default function PassengersPage() {
|
||||
passportIssueDate: p.passportIssueDate,
|
||||
passportExpiryDate: p.passportExpiryDate,
|
||||
passportIssuingAuthority: p.passportIssuingAuthority,
|
||||
phone: p.phone || '',
|
||||
email: p.email || '',
|
||||
phone: data.contactPhone,
|
||||
email: data.contactEmail,
|
||||
isPrimaryPassenger: i === 0,
|
||||
passengerId: i === 0 && passengerId ? passengerId : undefined,
|
||||
}))
|
||||
@@ -809,7 +985,9 @@ export default function PassengersPage() {
|
||||
<div className="max-w-lg mx-auto text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin mx-auto text-blue-600" />
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-4">
|
||||
{faydaCompleting ? 'Completing Fayda verification...' : 'Loading passenger details...'}
|
||||
{faydaCompleting
|
||||
? `Completing Fayda verification for Passenger ${(verifyingIndex ?? 0) + 1}...`
|
||||
: 'Loading passenger details...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -823,22 +1001,26 @@ export default function PassengersPage() {
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Passenger details</h1>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<form onSubmit={handleSubmit(onSubmit, onInvalid)} className="space-y-6">
|
||||
{fields.map((field, index) => {
|
||||
const isEthiopian = passengers[index]?.nationality === 'ETHIOPIAN';
|
||||
const isFormExpanded = passengers[index]?.formExpanded;
|
||||
const status = verificationStatus[index];
|
||||
const isPrimaryPassenger = index === 0;
|
||||
const isChildPassenger = index >= adultCount;
|
||||
const isLoggedInAndVerified = isPrimaryPassenger && isAuthenticated && user?.faydaVerified;
|
||||
const isLoggedInNotVerified = isPrimaryPassenger && isAuthenticated && !user?.faydaVerified;
|
||||
const showVerifyButton = isEthiopian && faydaEnabled && !isFormExpanded && !isLoggedInAndVerified;
|
||||
const showManualEntryLink = isEthiopian && !faydaEnabled && !isFormExpanded;
|
||||
const showVerifyButton = isEthiopian && faydaEnabled && !isFormExpanded && !isLoggedInAndVerified && !isChildPassenger;
|
||||
const showManualEntryLink = isEthiopian && !faydaEnabled && !isFormExpanded && !isChildPassenger;
|
||||
const isVerifyingThis = verifyingIndex === index;
|
||||
const isVerifyingOther = verifyingIndex !== null && verifyingIndex !== index;
|
||||
const faydaError = faydaErrors[index];
|
||||
|
||||
return (
|
||||
<div key={field.id} className="card">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
||||
Passenger {index + 1} {index === 0 && '(Primary)'}
|
||||
{index < (searchCriteria.adultCount || 1) ? ' - Adult' : ' - Child'}
|
||||
{isChildPassenger ? ' - Child' : ' - Adult'}
|
||||
<span className="ml-2 text-sm font-normal text-gray-600 dark:text-gray-400">
|
||||
({isEthiopian ? 'Ethiopian' : searchCriteria.nationality === 'DJIBOUTIAN' ? 'Djiboutian' : 'Other'})
|
||||
</span>
|
||||
@@ -853,18 +1035,40 @@ export default function PassengersPage() {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{faydaError && (
|
||||
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" />
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{faydaError}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openFaydaVerification(index)}
|
||||
className="btn-primary flex items-center justify-center gap-2 mx-auto"
|
||||
disabled={isVerifyingThis || isVerifyingOther}
|
||||
className="btn-primary flex items-center justify-center gap-2 mx-auto disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
{isLoggedInNotVerified ? 'Verify with Fayda' : 'Verify with Fayda'}
|
||||
{isVerifyingThis ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Waiting for verification...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
Verify with Fayda
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{isVerifyingOther && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2">
|
||||
Finish verifying Passenger {(verifyingIndex ?? 0) + 1} first
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleForm(index)}
|
||||
className="text-sm text-gray-500 dark:text-gray-400 hover:underline mt-3 block mx-auto"
|
||||
disabled={isVerifyingThis}
|
||||
className="text-sm text-gray-500 dark:text-gray-400 hover:underline mt-3 block mx-auto disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
@@ -889,10 +1093,16 @@ export default function PassengersPage() {
|
||||
{status === 'success' && (
|
||||
<div className="p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg mb-4">
|
||||
<p className="text-green-700 dark:text-green-300 text-sm flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" /> Verified with Fayda
|
||||
<CheckCircle className="w-4 h-4" /> Verified with Fayda — details auto-filled below
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && faydaError && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg mb-4 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" />
|
||||
<p className="text-red-700 dark:text-red-300 text-sm">{faydaError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{/* Full Name */}
|
||||
@@ -915,6 +1125,7 @@ export default function PassengersPage() {
|
||||
value={passengers[index]?.dateOfBirth || ''}
|
||||
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
|
||||
error={errors.passengers?.[index]?.dateOfBirth?.message}
|
||||
passengerType={isChildPassenger ? 'CHILD' : 'ADULT'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -944,32 +1155,6 @@ export default function PassengersPage() {
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
|
||||
<PhoneInput
|
||||
nationality={passengers[index]?.nationality || 'ETHIOPIAN'}
|
||||
storedValue={passengers[index]?.phone || ''}
|
||||
onInterimChange={(v) => setValue(`passengers.${index}.phone`, v)}
|
||||
onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
|
||||
error={errors.passengers?.[index]?.phone?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
{...register(`passengers.${index}.email`)}
|
||||
className={`input-field ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
{errors.passengers?.[index]?.email && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.email?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
@@ -995,6 +1180,7 @@ export default function PassengersPage() {
|
||||
value={passengers[index]?.dateOfBirth || ''}
|
||||
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
|
||||
error={errors.passengers?.[index]?.dateOfBirth?.message}
|
||||
passengerType={isChildPassenger ? 'CHILD' : 'ADULT'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1024,32 +1210,6 @@ export default function PassengersPage() {
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
|
||||
<PhoneInput
|
||||
nationality={passengers[index]?.nationality || 'OTHER'}
|
||||
storedValue={passengers[index]?.phone || ''}
|
||||
onInterimChange={(v) => setValue(`passengers.${index}.phone`, v)}
|
||||
onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
|
||||
error={errors.passengers?.[index]?.phone?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
{...register(`passengers.${index}.email`)}
|
||||
className={`input-field ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
{errors.passengers?.[index]?.email && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.email?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Passport fields */}
|
||||
@@ -1106,7 +1266,41 @@ export default function PassengersPage() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold mb-1 text-gray-900 dark:text-gray-100">Contact Information</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
This phone number and email will be used for booking and ticketing communication for all passengers.
|
||||
</p>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{/* Contact Phone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
|
||||
<PhoneInput
|
||||
nationality={passengers[0]?.nationality || searchCriteria?.nationality || 'ETHIOPIAN'}
|
||||
storedValue={watch('contactPhone') || ''}
|
||||
onInterimChange={(v) => setValue('contactPhone', v)}
|
||||
onNormalized={(v) => setValue('contactPhone', v, { shouldValidate: true })}
|
||||
error={errors.contactPhone?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Contact Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email *</label>
|
||||
<input
|
||||
type="email"
|
||||
{...register('contactEmail')}
|
||||
className={`input-field ${errors.contactEmail ? 'border-red-500' : ''}`}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
{errors.contactEmail && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.contactEmail.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isAuthenticated && (
|
||||
<div className="card">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
@@ -1116,6 +1310,13 @@ export default function PassengersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitError && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" />
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{submitError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button type="button" onClick={() => {
|
||||
const params = new URLSearchParams({
|
||||
@@ -1132,8 +1333,10 @@ export default function PassengersPage() {
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
<button type="submit" className="btn-primary flex-1" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Continue to seat selection'}
|
||||
<button type="submit" className="btn-primary flex-1 flex items-center justify-center gap-2" disabled={saving}>
|
||||
{saving
|
||||
? <Loader2 className="w-4 h-4 animate-spin" />
|
||||
: 'Continue to seat selection'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { apiClient } from "@/lib/api-client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { PaymentMethod } from "@/types";
|
||||
import { format } from "date-fns";
|
||||
import { formatTime, getTimePeriod } from '@/utils/format';
|
||||
import { calculatePassengerFare, isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
|
||||
import {
|
||||
CreditCard,
|
||||
Smartphone,
|
||||
@@ -65,21 +67,21 @@ export default function PaymentPage() {
|
||||
enabled: !!selectedMethod && !!bookingId,
|
||||
});
|
||||
|
||||
// Fallback: estimate from local store while API hasn't responded yet
|
||||
const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce(
|
||||
(sum) => sum + (outboundSchedule.baseFareAdult || 0),
|
||||
0,
|
||||
) : 0;
|
||||
// Fallback: estimate from local store while API hasn't responded yet.
|
||||
// Uses the same first-child-free calculation as the review page so the
|
||||
// breakdown shown here matches what the passenger already saw there.
|
||||
const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce((sum, _, i) => {
|
||||
return sum + calculatePassengerFare(passengers, i, outboundSchedule.baseFareAdult || 0);
|
||||
}, 0) : 0;
|
||||
|
||||
const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce(
|
||||
(sum) => sum + (inboundSchedule.baseFareAdult || 0),
|
||||
0,
|
||||
) : 0;
|
||||
const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum, _, i) => {
|
||||
return sum + calculatePassengerFare(passengers, i, inboundSchedule.baseFareAdult || 0);
|
||||
}, 0) : 0;
|
||||
|
||||
const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce(
|
||||
(sum) => sum + (selectedSchedule?.baseFareAdult || 0),
|
||||
0,
|
||||
);
|
||||
const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, _, i) => {
|
||||
const farePerPassenger = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0;
|
||||
return sum + calculatePassengerFare(passengers, i, farePerPassenger);
|
||||
}, 0);
|
||||
|
||||
// API returns amount in major units (e.g. 11602.5 DJF); convert to minor for display consistency
|
||||
const totalAmount = bookingAmountData != null
|
||||
@@ -203,10 +205,12 @@ export default function PaymentPage() {
|
||||
<div className="flex-1 flex flex-col pl-2">
|
||||
<div className="pb-5">
|
||||
<div className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{schedule?.departureTime ? format(new Date(schedule.departureTime), 'HH:mm') : '--:--'}
|
||||
{schedule?.departureTime ? formatTime(schedule.departureTime) : '--:--'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{schedule?.departureTime ? format(new Date(schedule.departureTime), 'EEE, MMM d') : ''}
|
||||
{schedule?.departureTime
|
||||
? `${format(new Date(schedule.departureTime), 'EEE, MMM d')} · ${getTimePeriod(schedule.departureTime)}`
|
||||
: ''}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white mt-1">{schedule?.origin}</div>
|
||||
</div>
|
||||
@@ -216,10 +220,12 @@ export default function PaymentPage() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{schedule?.arrivalTime ? format(new Date(schedule.arrivalTime), 'HH:mm') : '--:--'}
|
||||
{schedule?.arrivalTime ? formatTime(schedule.arrivalTime) : '--:--'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{schedule?.arrivalTime ? format(new Date(schedule.arrivalTime), 'EEE, MMM d') : ''}
|
||||
{schedule?.arrivalTime
|
||||
? `${format(new Date(schedule.arrivalTime), 'EEE, MMM d')} · ${getTimePeriod(schedule.arrivalTime)}`
|
||||
: ''}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white mt-1">{schedule?.destination}</div>
|
||||
</div>
|
||||
@@ -240,7 +246,9 @@ export default function PaymentPage() {
|
||||
<div className="card space-y-4">
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100 pb-2 border-b border-gray-100 dark:border-gray-800">
|
||||
Order summary
|
||||
<span className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">Ref: {pnr}</span>
|
||||
<span className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
Ref: <span className="font-bold text-gray-900 dark:text-gray-100">{pnr}</span>
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
{isRoundTrip ? (
|
||||
@@ -254,14 +262,58 @@ export default function PaymentPage() {
|
||||
<JourneyLeg schedule={selectedSchedule} label="Your journey" />
|
||||
)}
|
||||
|
||||
<div className="pt-2 border-t-2 border-gray-200 dark:border-gray-700 space-y-1.5">
|
||||
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>Passengers</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{passengers.length} passenger{passengers.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-1">
|
||||
{/* Fare breakdown — same first-child-free logic as the review page */}
|
||||
<div className="pt-2 border-t-2 border-gray-200 dark:border-gray-700 space-y-2">
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">Fare breakdown</h3>
|
||||
{passengers.map((p, i) => {
|
||||
const outFare = outboundSchedule?.baseFareAdult || 0;
|
||||
const inFare = inboundSchedule?.baseFareAdult || 0;
|
||||
const onewayFare = selectedSchedule?.baseFareAdult || 0;
|
||||
|
||||
const outboundFare = calculatePassengerFare(passengers, i, outFare);
|
||||
const inboundFare = calculatePassengerFare(passengers, i, inFare);
|
||||
const oneWayFare = calculatePassengerFare(passengers, i, onewayFare);
|
||||
|
||||
const passengerTotal = isRoundTrip ? outboundFare + inboundFare : oneWayFare;
|
||||
const isChildPassenger = isChild(p);
|
||||
const isFreeChild = isChildPassenger && isFirstChild(passengers, i);
|
||||
|
||||
return (
|
||||
<div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
|
||||
<div className="flex justify-between mb-0.5">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[60%]">
|
||||
{p.name || `Passenger ${i + 1}`}
|
||||
{isChildPassenger && (
|
||||
<span className={`text-xs font-semibold ml-1 ${
|
||||
isFreeChild ? 'text-green-600' : 'text-blue-600'
|
||||
}`}>
|
||||
({isFreeChild ? 'CHILD - FREE' : 'CHILD'})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{formatFare(passengerTotal, displayCurrency)}
|
||||
</span>
|
||||
</div>
|
||||
{isRoundTrip && (
|
||||
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="flex justify-between">
|
||||
<span>Outbound {isFreeChild ? '(Free)' : ''}</span>
|
||||
<span>{formatFare(outboundFare, displayCurrency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Return {isFreeChild ? '(Free)' : ''}</span>
|
||||
<span>{formatFare(inboundFare, displayCurrency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t-2 border-gray-200 dark:border-gray-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-bold text-gray-900 dark:text-gray-100">Total</span>
|
||||
<span className="text-xl font-bold text-primary flex items-center gap-1.5">
|
||||
{loadingAmount && (
|
||||
@@ -311,6 +363,22 @@ export default function PaymentPage() {
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">Complete payment</h1>
|
||||
|
||||
{/* Reservation confirmation banner */}
|
||||
<div className="card bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 mb-4 flex items-start gap-3">
|
||||
<CheckCircle className="w-6 h-6 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-green-800 dark:text-green-300">
|
||||
Your booking is successfully reserved
|
||||
</p>
|
||||
<p className="text-sm text-green-700 dark:text-green-400 mt-0.5">
|
||||
Booking Reference: <span className="font-bold">{pnr}</span>
|
||||
</p>
|
||||
<p className="text-xs text-green-700/80 dark:text-green-400/80 mt-1">
|
||||
Please complete the payment below to confirm your booking. Your seats are held temporarily until payment is completed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Processing Overlay */}
|
||||
{isProcessing && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { usePaymentStore } from '@/lib/payment-store';
|
||||
import { useEffect, Suspense } from 'react';
|
||||
import { XCircle, Loader2, RefreshCw, ChevronLeft } from 'lucide-react';
|
||||
import { XCircle, Loader2, ChevronLeft } from 'lucide-react';
|
||||
|
||||
function TelebirrFailureContent() {
|
||||
const router = useRouter();
|
||||
@@ -30,11 +30,6 @@ function TelebirrFailureContent() {
|
||||
{merchantOrderId && <p className="text-xs text-gray-400 mb-1">Order ID: {merchantOrderId}</p>}
|
||||
{trxRef && <p className="text-xs text-gray-400 mb-4">Ref: {trxRef}</p>}
|
||||
<div className="flex flex-col gap-3 mt-4">
|
||||
<button onClick={() => router.push('/booking/payment')}
|
||||
className="btn-primary w-full flex items-center justify-center gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Try Again
|
||||
</button>
|
||||
<button onClick={() => router.push('/booking/review')}
|
||||
className="btn-secondary w-full flex items-center justify-center gap-2">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { usePaymentStore } from '@/lib/payment-store';
|
||||
import { useEffect, Suspense } from 'react';
|
||||
import { XCircle, Loader2, RefreshCw, ChevronLeft } from 'lucide-react';
|
||||
import { XCircle, Loader2, ChevronLeft } from 'lucide-react';
|
||||
|
||||
function WaafiFailureContent() {
|
||||
const router = useRouter();
|
||||
@@ -33,11 +33,6 @@ function WaafiFailureContent() {
|
||||
<p className="text-xs text-gray-400 mb-4">Ref: {referenceId || transactionId}</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-3 mt-4">
|
||||
<button onClick={() => router.push('/booking/payment')}
|
||||
className="btn-primary w-full flex items-center justify-center gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Try Again
|
||||
</button>
|
||||
<button onClick={() => router.push('/booking/review')}
|
||||
className="btn-secondary w-full flex items-center justify-center gap-2">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useBookingStore } from '@/lib/booking-store';
|
||||
import { Schedule } from '@/types';
|
||||
import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Check, X, MapPin, Gift, Train, Bed, Armchair, Star } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { formatTime, getTimePeriod } from '@/utils/format';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export default function ResultsPage() {
|
||||
@@ -19,6 +20,10 @@ export default function ResultsPage() {
|
||||
);
|
||||
const [classModal, setClassModal] = useState<Schedule | null>(null);
|
||||
const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null);
|
||||
const [roundTripStep, setRoundTripStep] = useState<'outbound' | 'inbound'>(() => {
|
||||
const { outboundSchedule, searchCriteria: sc } = useBookingStore.getState();
|
||||
return outboundSchedule && sc?.tripType === 'ROUND_TRIP' ? 'inbound' : 'outbound';
|
||||
});
|
||||
|
||||
const searchCriteria = useBookingStore((s) => s.searchCriteria);
|
||||
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
|
||||
@@ -28,7 +33,7 @@ export default function ResultsPage() {
|
||||
destinationStationId: searchParams.get('destination') || searchCriteria?.destinationStationId || '',
|
||||
date: searchParams.get('date') || searchCriteria?.departureDate || '',
|
||||
returnDate: searchParams.get('returnDate') || searchCriteria?.returnDate,
|
||||
journeyType: searchParams.get('tripType') === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY',
|
||||
journeyType: (searchParams.get('tripType') ?? searchCriteria?.tripType ?? 'ONE_WAY') === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY',
|
||||
adultCount: parseInt(searchParams.get('adults') || '') || searchCriteria?.adultCount || 1,
|
||||
childCount: parseInt(searchParams.get('children') || '') || searchCriteria?.childCount || 0,
|
||||
nationality: searchParams.get('nationality') || searchCriteria?.nationality || 'ETHIOPIAN',
|
||||
@@ -111,6 +116,8 @@ export default function ResultsPage() {
|
||||
return response;
|
||||
},
|
||||
enabled: !!searchData.originStationId && !!searchData.destinationStationId,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
});
|
||||
|
||||
const isRoundTrip = searchData.journeyType === 'ROUND_TRIP';
|
||||
@@ -188,18 +195,13 @@ export default function ResultsPage() {
|
||||
seatClassName: (selectedCoachType as any).seatClassName || selectedCoachType.name,
|
||||
};
|
||||
|
||||
// For round trip, store outbound and wait for inbound selection
|
||||
// For round trip, store outbound and advance to inbound step
|
||||
if (isRoundTrip && isOutbound) {
|
||||
setOutboundScheduleData(scheduleData);
|
||||
setOutboundSchedule(scheduleData);
|
||||
setClassModal(null);
|
||||
// Scroll to inbound section
|
||||
setTimeout(() => {
|
||||
const inboundSection = document.getElementById('inbound-section');
|
||||
if (inboundSection) {
|
||||
inboundSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}, 100);
|
||||
setRoundTripStep('inbound');
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -256,10 +258,10 @@ export default function ResultsPage() {
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{schedule.departureAt ? format(new Date(schedule.departureAt), 'HH:mm') : '--:--'}
|
||||
{schedule.departureAt ? formatTime(schedule.departureAt) : '--:--'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{schedule.departureAt ? format(new Date(schedule.departureAt), 'MMM d') : ''}
|
||||
{schedule.departureAt ? `${format(new Date(schedule.departureAt), 'MMM d')} · ${getTimePeriod(schedule.departureAt)}` : ''}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.origin?.name || 'Origin'}</div>
|
||||
</div>
|
||||
@@ -283,10 +285,10 @@ export default function ResultsPage() {
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'HH:mm') : '--:--'}
|
||||
{schedule.arrivalAt ? formatTime(schedule.arrivalAt) : '--:--'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center justify-center gap-1">
|
||||
<span>{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'MMM d') : ''}</span>
|
||||
<span>{schedule.arrivalAt ? `${format(new Date(schedule.arrivalAt), 'MMM d')} · ${getTimePeriod(schedule.arrivalAt)}` : ''}</span>
|
||||
{isNextDay && <span className="text-orange-500 font-medium">(+1)</span>}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.destination?.name || 'Destination'}</div>
|
||||
@@ -699,6 +701,33 @@ export default function ResultsPage() {
|
||||
Modify search
|
||||
</button>
|
||||
<h1 className="section-title">Available schedules</h1>
|
||||
{isRoundTrip && (
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
<div className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-semibold transition-all ${
|
||||
roundTripStep === 'inbound'
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
|
||||
: 'bg-primary text-white shadow-md shadow-primary/30'
|
||||
}`}>
|
||||
{roundTripStep === 'inbound'
|
||||
? <Check className="w-3.5 h-3.5" />
|
||||
: <span className="text-xs leading-none">1</span>}
|
||||
<span>Outbound</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-4 h-0.5 bg-gray-300 dark:bg-gray-600" />
|
||||
<ArrowRight className="w-3 h-3 text-gray-400" />
|
||||
<div className="w-4 h-0.5 bg-gray-300 dark:bg-gray-600" />
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-semibold transition-all ${
|
||||
roundTripStep === 'inbound'
|
||||
? 'bg-primary text-white shadow-md shadow-primary/30'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500'
|
||||
}`}>
|
||||
<span className="text-xs leading-none">2</span>
|
||||
<span>Return</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden sm:flex flex-wrap items-center gap-4 text-gray-600 dark:text-gray-400 mt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
@@ -718,39 +747,74 @@ export default function ResultsPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{outboundSchedules.length > 0 && (
|
||||
<div>
|
||||
{isRoundTrip && (
|
||||
{isRoundTrip ? (
|
||||
roundTripStep === 'outbound' ? (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<ArrowRight className="w-5 h-5 text-primary" />
|
||||
Outbound Journey
|
||||
Select Outbound Journey
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{searchData.date ? format(new Date(searchData.date), 'EEEE, MMMM d, yyyy') : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{outboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, true))}
|
||||
<div className="space-y-4">
|
||||
{outboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, true))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRoundTrip && inboundSchedules.length > 0 && outboundScheduleData && (
|
||||
<div id="inbound-section">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<ArrowRight className="w-5 h-5 text-primary rotate-180" />
|
||||
Return Journey
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{searchData.returnDate ? format(new Date(searchData.returnDate), 'EEEE, MMMM d, yyyy') : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{inboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule))}
|
||||
) : (
|
||||
<div>
|
||||
{outboundScheduleData && (
|
||||
<div className="mb-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-green-100 dark:bg-green-900/40 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<Check className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-green-900 dark:text-green-200">Outbound journey selected</p>
|
||||
<p className="text-xs text-green-700 dark:text-green-400 mt-0.5">
|
||||
{outboundScheduleData.origin} → {outboundScheduleData.destination}
|
||||
{outboundScheduleData.selectedSeatClassName ? ` · ${outboundScheduleData.selectedSeatClassName}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setRoundTripStep('outbound');
|
||||
const prevId = outboundScheduleData?.id;
|
||||
setOutboundScheduleData(null);
|
||||
if (prevId) {
|
||||
setSelectedCoachTypes(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[prevId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="text-xs font-semibold text-primary hover:underline flex-shrink-0 ml-4"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<ArrowRight className="w-5 h-5 text-primary rotate-180" />
|
||||
Select Return Journey
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{searchData.returnDate ? format(new Date(searchData.returnDate), 'EEEE, MMMM d, yyyy') : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4" id="inbound-section">
|
||||
{inboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, false))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{outboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, true))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { format } from 'date-fns';
|
||||
import { formatTime, getTimePeriod } from '@/utils/format';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { calculatePassengerFare, isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
|
||||
@@ -93,6 +94,14 @@ export default function ReviewPage() {
|
||||
return suffix ? `${base}${suffix}` : base;
|
||||
};
|
||||
|
||||
// The seat class/category is chosen once per leg (coach type selected on /booking/seats),
|
||||
// so every seat on that leg shares it — no need to look it up per-seat.
|
||||
const formatSeatClass = (schedule: any): string => {
|
||||
const raw = schedule?.selectedSeatClassName || schedule?.seatClassName || schedule?.selectedSeatClass;
|
||||
if (!raw) return 'Standard';
|
||||
return String(raw).replace(/_/g, ' ');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSeatDetails = async () => {
|
||||
try {
|
||||
@@ -525,10 +534,12 @@ export default function ReviewPage() {
|
||||
{/* Origin */}
|
||||
<div className="pb-8">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{outboundSchedule.departureTime ? format(new Date(outboundSchedule.departureTime), 'HH:mm') : '--:--'}
|
||||
{outboundSchedule.departureTime ? formatTime(outboundSchedule.departureTime) : '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{outboundSchedule.departureTime ? format(new Date(outboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
|
||||
{outboundSchedule.departureTime
|
||||
? `${format(new Date(outboundSchedule.departureTime), 'EEE, MMM d')} · ${getTimePeriod(outboundSchedule.departureTime)}`
|
||||
: 'N/A'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{outboundSchedule.origin}
|
||||
@@ -556,10 +567,12 @@ export default function ReviewPage() {
|
||||
{/* Destination */}
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{outboundSchedule.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'HH:mm') : '--:--'}
|
||||
{outboundSchedule.arrivalTime ? formatTime(outboundSchedule.arrivalTime) : '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{outboundSchedule.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
|
||||
{outboundSchedule.arrivalTime
|
||||
? `${format(new Date(outboundSchedule.arrivalTime), 'EEE, MMM d')} · ${getTimePeriod(outboundSchedule.arrivalTime)}`
|
||||
: 'N/A'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{outboundSchedule.destination}
|
||||
@@ -598,10 +611,12 @@ export default function ReviewPage() {
|
||||
{/* Origin */}
|
||||
<div className="pb-8">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{inboundSchedule.departureTime ? format(new Date(inboundSchedule.departureTime), 'HH:mm') : '--:--'}
|
||||
{inboundSchedule.departureTime ? formatTime(inboundSchedule.departureTime) : '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{inboundSchedule.departureTime ? format(new Date(inboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
|
||||
{inboundSchedule.departureTime
|
||||
? `${format(new Date(inboundSchedule.departureTime), 'EEE, MMM d')} · ${getTimePeriod(inboundSchedule.departureTime)}`
|
||||
: 'N/A'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{inboundSchedule.origin}
|
||||
@@ -629,10 +644,12 @@ export default function ReviewPage() {
|
||||
{/* Destination */}
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{inboundSchedule.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'HH:mm') : '--:--'}
|
||||
{inboundSchedule.arrivalTime ? formatTime(inboundSchedule.arrivalTime) : '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{inboundSchedule.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
|
||||
{inboundSchedule.arrivalTime
|
||||
? `${format(new Date(inboundSchedule.arrivalTime), 'EEE, MMM d')} · ${getTimePeriod(inboundSchedule.arrivalTime)}`
|
||||
: 'N/A'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{inboundSchedule.destination}
|
||||
@@ -671,10 +688,12 @@ export default function ReviewPage() {
|
||||
{/* Origin */}
|
||||
<div className="pb-8">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'HH:mm') : '--:--'}
|
||||
{selectedSchedule.departureTime ? formatTime(selectedSchedule.departureTime) : '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
|
||||
{selectedSchedule.departureTime
|
||||
? `${format(new Date(selectedSchedule.departureTime), 'EEE, MMM d')} · ${getTimePeriod(selectedSchedule.departureTime)}`
|
||||
: 'N/A'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{selectedSchedule.origin}
|
||||
@@ -702,10 +721,12 @@ export default function ReviewPage() {
|
||||
{/* Destination */}
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'HH:mm') : '--:--'}
|
||||
{selectedSchedule.arrivalTime ? formatTime(selectedSchedule.arrivalTime) : '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
|
||||
{selectedSchedule.arrivalTime
|
||||
? `${format(new Date(selectedSchedule.arrivalTime), 'EEE, MMM d')} · ${getTimePeriod(selectedSchedule.arrivalTime)}`
|
||||
: 'N/A'}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{selectedSchedule.destination}
|
||||
@@ -736,18 +757,27 @@ export default function ReviewPage() {
|
||||
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
|
||||
{(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : 'Auto-assign'}
|
||||
</p>
|
||||
{(p as any).outboundSeatId && (
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(outboundSchedule)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-2">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Return Seat</p>
|
||||
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
|
||||
{(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : 'Auto-assign'}
|
||||
</p>
|
||||
{(p as any).inboundSeatId && (
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(inboundSchedule)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Seat</p>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : 'Auto-assign'}</p>
|
||||
{p.seatId && (
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(selectedSchedule)}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,14 +4,13 @@ import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@/lib/auth-store";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { useBookingStore } from "@/lib/booking-store";
|
||||
import { Station } from "@/types";
|
||||
import {
|
||||
MapPin,
|
||||
ArrowRight,
|
||||
ArrowLeftRight,
|
||||
Plus,
|
||||
Minus,
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
X,
|
||||
ChevronLeft,
|
||||
Clock,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import ModernDatePicker from "@/components/ModernDatePicker";
|
||||
@@ -79,12 +77,6 @@ const searchSchema = z
|
||||
|
||||
type SearchForm = z.infer<typeof searchSchema>;
|
||||
|
||||
const POPULAR_ROUTES = [
|
||||
{ from: "Sebeta", to: "Nagad", duration: "12h", icon: "🌆" },
|
||||
{ from: "Sebeta", to: "Diredawa", duration: "8h", icon: "🏔️" },
|
||||
{ from: "Diredawa", to: "Nagad", duration: "4h", icon: "🌊" },
|
||||
];
|
||||
|
||||
// ─── Station Modal ────────────────────────────────────────────────────────────
|
||||
function StationModal({
|
||||
stations,
|
||||
@@ -530,6 +522,8 @@ export default function SearchPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
|
||||
const clearBooking = useBookingStore((s) => s.clearBooking);
|
||||
const queryClient = useQueryClient();
|
||||
const { user, isAuthenticated } = useAuthStore();
|
||||
|
||||
const [passengerModalOpen, setPassengerModalOpen] = useState(false);
|
||||
@@ -701,7 +695,10 @@ export default function SearchPage() {
|
||||
|
||||
const onSubmit = (data: SearchForm) => {
|
||||
setHasInteracted(true);
|
||||
// Clear previous booking selections and search cache before starting a new search
|
||||
clearBooking();
|
||||
setSearchCriteria(data);
|
||||
queryClient.removeQueries({ queryKey: ["search"] });
|
||||
if (data.originStationId) saveRecent(data.originStationId);
|
||||
if (data.destinationStationId) saveRecent(data.destinationStationId);
|
||||
const params = new URLSearchParams({
|
||||
@@ -723,20 +720,6 @@ export default function SearchPage() {
|
||||
const originStation = getStationById(originId);
|
||||
const destStation = getStationById(destId);
|
||||
|
||||
const handlePopularRoute = (fromName: string, toName: string) => {
|
||||
const origin = stations.find((s) =>
|
||||
s.name.toLowerCase().includes(fromName.toLowerCase()),
|
||||
);
|
||||
const dest = stations.find((s) =>
|
||||
s.name.toLowerCase().includes(toName.toLowerCase()),
|
||||
);
|
||||
if (origin && dest) {
|
||||
setValue("originStationId", origin.id);
|
||||
setValue("destinationStationId", dest.id);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 dark:bg-gray-950">
|
||||
{/* Passenger modal (mobile) */}
|
||||
@@ -790,9 +773,15 @@ export default function SearchPage() {
|
||||
)}
|
||||
|
||||
{/* ── 90vh hero with banner image ── */}
|
||||
{/* Round trip stacks an extra Return Date field into the widget on mobile, which grows
|
||||
upward from its bottom-anchored position — give the hero extra height there so the
|
||||
widget's top edge doesn't creep up into the sticky header. */}
|
||||
<section
|
||||
className="relative"
|
||||
style={{ height: "90vh", minHeight: "560px" }}
|
||||
className={`relative ${
|
||||
tripType === "ROUND_TRIP"
|
||||
? "h-[calc(90vh+60px)] min-h-[670px] md:h-[90vh] md:min-h-[560px]"
|
||||
: "h-[94vh] min-h-[560px]"
|
||||
}`}
|
||||
>
|
||||
{/* Background image with zoom - fully isolated */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
@@ -956,7 +945,7 @@ export default function SearchPage() {
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Date
|
||||
</label>
|
||||
<div className="relative z-30">
|
||||
<div>
|
||||
<ModernDatePicker
|
||||
value={
|
||||
departureDate
|
||||
@@ -980,7 +969,7 @@ export default function SearchPage() {
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Return Date
|
||||
</label>
|
||||
<div className="relative z-20">
|
||||
<div>
|
||||
<ModernDatePicker
|
||||
value={
|
||||
returnDate
|
||||
@@ -1059,7 +1048,11 @@ export default function SearchPage() {
|
||||
clearErrors("originStationId");
|
||||
clearErrors("destinationStationId");
|
||||
}}
|
||||
error={hasInteracted ? errors.originStationId?.message : undefined}
|
||||
error={
|
||||
hasInteracted
|
||||
? errors.originStationId?.message
|
||||
: undefined
|
||||
}
|
||||
onOpen={scrollWidgetIntoView}
|
||||
/>
|
||||
{hasInteracted && errors.originStationId && (
|
||||
@@ -1094,7 +1087,11 @@ export default function SearchPage() {
|
||||
if (s.id) saveRecent(s.id);
|
||||
clearErrors("destinationStationId");
|
||||
}}
|
||||
error={hasInteracted ? errors.destinationStationId?.message : undefined}
|
||||
error={
|
||||
hasInteracted
|
||||
? errors.destinationStationId?.message
|
||||
: undefined
|
||||
}
|
||||
onOpen={scrollWidgetIntoView}
|
||||
/>
|
||||
{hasInteracted && errors.destinationStationId && (
|
||||
@@ -1110,7 +1107,7 @@ export default function SearchPage() {
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Date
|
||||
</label>
|
||||
<div className="relative z-30">
|
||||
<div>
|
||||
<ModernDatePicker
|
||||
value={
|
||||
departureDate
|
||||
@@ -1191,7 +1188,11 @@ export default function SearchPage() {
|
||||
clearErrors("originStationId");
|
||||
clearErrors("destinationStationId");
|
||||
}}
|
||||
error={hasInteracted ? errors.originStationId?.message : undefined}
|
||||
error={
|
||||
hasInteracted
|
||||
? errors.originStationId?.message
|
||||
: undefined
|
||||
}
|
||||
onOpen={scrollWidgetIntoView}
|
||||
/>
|
||||
{hasInteracted && errors.originStationId && (
|
||||
@@ -1226,7 +1227,11 @@ export default function SearchPage() {
|
||||
if (s.id) saveRecent(s.id);
|
||||
clearErrors("destinationStationId");
|
||||
}}
|
||||
error={hasInteracted ? errors.destinationStationId?.message : undefined}
|
||||
error={
|
||||
hasInteracted
|
||||
? errors.destinationStationId?.message
|
||||
: undefined
|
||||
}
|
||||
onOpen={scrollWidgetIntoView}
|
||||
/>
|
||||
{hasInteracted && errors.destinationStationId && (
|
||||
@@ -1242,7 +1247,7 @@ export default function SearchPage() {
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Departure
|
||||
</label>
|
||||
<div className="relative z-30">
|
||||
<div>
|
||||
<ModernDatePicker
|
||||
value={
|
||||
departureDate
|
||||
@@ -1272,7 +1277,7 @@ export default function SearchPage() {
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Return
|
||||
</label>
|
||||
<div className="relative z-20">
|
||||
<div>
|
||||
<ModernDatePicker
|
||||
value={
|
||||
returnDate
|
||||
@@ -1497,41 +1502,6 @@ export default function SearchPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Popular Routes — below hero */}
|
||||
<div className="bg-gray-50 dark:bg-gray-950 py-10">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Zap className="w-4 h-4 text-primary" />
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white">
|
||||
Popular Routes
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{POPULAR_ROUTES.map((route, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => handlePopularRoute(route.from, route.to)}
|
||||
className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-4 hover:border-primary hover:shadow-md transition-all text-left active:scale-95"
|
||||
>
|
||||
<div className="text-xl mb-2">{route.icon}</div>
|
||||
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<span>{route.from}</span>
|
||||
<ArrowRight className="w-3.5 h-3.5 text-primary flex-shrink-0" />
|
||||
<span>{route.to}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{route.duration} journey
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
|
||||
@@ -21,7 +21,7 @@ const buildSeatLabel = (seat: any): string => {
|
||||
return suffix ? `${base}${suffix}` : base;
|
||||
};
|
||||
|
||||
const BedCard = memo(({ bed, isSelected, onToggle }: any) => {
|
||||
const BedCard = memo(({ bed, isSelected, isAssignedToOther, onToggle }: any) => {
|
||||
const seatLabel = bed.label || bed.seatNumber || bed.number || "?";
|
||||
const bedPosition = bed.bedPosition || "";
|
||||
const bedType =
|
||||
@@ -30,21 +30,28 @@ const BedCard = memo(({ bed, isSelected, onToggle }: any) => {
|
||||
: bedPosition === "middle"
|
||||
? "Middle"
|
||||
: "Lower";
|
||||
const isDisabled = bed.status !== "AVAILABLE" || isAssignedToOther;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onToggle(bed.id)}
|
||||
disabled={bed.status !== "AVAILABLE"}
|
||||
disabled={isDisabled}
|
||||
className={`relative flex flex-col items-center justify-center gap-1 px-3 py-2 rounded-lg transition-all ${
|
||||
isSelected
|
||||
? "bg-blue-50 border-2 border-blue-500 dark:bg-blue-900/20 dark:border-blue-400"
|
||||
: bed.status === "AVAILABLE"
|
||||
? "bg-green-50 border border-green-300 hover:bg-green-100 dark:bg-green-900/20 dark:border-green-700"
|
||||
: bed.status === "BOOKED"
|
||||
? "bg-red-50 border border-red-300 cursor-not-allowed dark:bg-red-900/20 dark:border-red-700"
|
||||
: "bg-gray-100 border border-gray-300 cursor-not-allowed dark:bg-gray-800 dark:border-gray-700"
|
||||
: isAssignedToOther
|
||||
? "bg-purple-50 border border-purple-300 cursor-not-allowed dark:bg-purple-900/20 dark:border-purple-700"
|
||||
: bed.status === "AVAILABLE"
|
||||
? "bg-green-50 border border-green-300 hover:bg-green-100 dark:bg-green-900/20 dark:border-green-700"
|
||||
: bed.status === "BOOKED"
|
||||
? "bg-red-50 border border-red-300 cursor-not-allowed dark:bg-red-900/20 dark:border-red-700"
|
||||
: "bg-gray-100 border border-gray-300 cursor-not-allowed dark:bg-gray-800 dark:border-gray-700"
|
||||
}`}
|
||||
title={`Bed ${seatLabel} - ${bedType} - ${bed.status}`}
|
||||
title={
|
||||
isAssignedToOther
|
||||
? `Bed ${seatLabel} - already assigned to another passenger`
|
||||
: `Bed ${seatLabel} - ${bedType} - ${bed.status}`
|
||||
}
|
||||
>
|
||||
<Image src="/bed.png" alt="bed" width={32} height={32} className="object-contain" />
|
||||
<div className="text-xs font-bold text-gray-900 dark:text-white">
|
||||
@@ -63,6 +70,7 @@ const SeatButton = memo(
|
||||
({
|
||||
seat,
|
||||
isSelected,
|
||||
isAssignedToOther,
|
||||
onToggle,
|
||||
isBedCoach,
|
||||
bedLabel,
|
||||
@@ -71,22 +79,29 @@ const SeatButton = memo(
|
||||
const seatLabel = seat.number || seat.label || seat.seatNumber || "?";
|
||||
const bedWidth = "w-24";
|
||||
const width = isBedCoach ? bedWidth : "w-10";
|
||||
const isDisabled = seat.status !== "AVAILABLE" || isAssignedToOther;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<button
|
||||
onClick={() => onToggle(seat.id)}
|
||||
disabled={seat.status !== "AVAILABLE"}
|
||||
disabled={isDisabled}
|
||||
className={`${width} h-11 rounded flex items-center justify-center transition-all ${
|
||||
isSelected
|
||||
? "bg-[rgb(20_113_76)] text-white shadow-md scale-105"
|
||||
: seat.status === "AVAILABLE"
|
||||
? "bg-green-500 hover:bg-green-600 text-white cursor-pointer hover:shadow-md"
|
||||
: seat.status === "HELD"
|
||||
? "bg-yellow-500 text-white cursor-not-allowed opacity-75"
|
||||
: "bg-gray-500 text-white cursor-not-allowed opacity-60"
|
||||
: isAssignedToOther
|
||||
? "bg-purple-400 text-white cursor-not-allowed opacity-75"
|
||||
: seat.status === "AVAILABLE"
|
||||
? "bg-green-500 hover:bg-green-600 text-white cursor-pointer hover:shadow-md"
|
||||
: seat.status === "HELD"
|
||||
? "bg-yellow-500 text-white cursor-not-allowed opacity-75"
|
||||
: "bg-gray-500 text-white cursor-not-allowed opacity-60"
|
||||
}`}
|
||||
title={`Seat ${seatLabel}${bedLabel} - ${seat.status} - ${coachSeatClass}`}
|
||||
title={
|
||||
isAssignedToOther
|
||||
? `Seat ${seatLabel}${bedLabel} - already assigned to another passenger`
|
||||
: `Seat ${seatLabel}${bedLabel} - ${seat.status} - ${coachSeatClass}`
|
||||
}
|
||||
style={
|
||||
isBedCoach
|
||||
? seat.row % 2 === 1
|
||||
@@ -122,7 +137,10 @@ export default function SeatsPage() {
|
||||
searchCriteria,
|
||||
bookingId,
|
||||
} = useBookingStore();
|
||||
const [selectedSeats, setSelectedSeats] = useState<string[]>([]);
|
||||
// Maps passenger index -> assigned seat id. A passenger can only get a seat while
|
||||
// they are the "active" passenger, which prevents bulk/batch selection across passengers.
|
||||
const [passengerSeatMap, setPassengerSeatMap] = useState<Record<number, string>>({});
|
||||
const [activePassengerIndex, setActivePassengerIndex] = useState(0);
|
||||
const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
|
||||
const [currentJourneyType, setCurrentJourneyType] = useState<
|
||||
"outbound" | "inbound"
|
||||
@@ -344,71 +362,87 @@ export default function SeatsPage() {
|
||||
return seats;
|
||||
}, [allSeats, selectedCoachData, currentSchedule?.selectedSeatClass]);
|
||||
|
||||
const handleSeatClick = useCallback(
|
||||
(seatId: string) => {
|
||||
setSelectedSeats((prev) => {
|
||||
if (prev.length < passengers.length) {
|
||||
return [...prev, seatId];
|
||||
} else {
|
||||
return [seatId];
|
||||
}
|
||||
});
|
||||
},
|
||||
[passengers.length],
|
||||
// Seats already claimed by any passenger in this journey leg
|
||||
const assignedSeatIds = useMemo(
|
||||
() => new Set(Object.values(passengerSeatMap)),
|
||||
[passengerSeatMap],
|
||||
);
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (isRoundTrip && currentJourneyType === "outbound") {
|
||||
if (selectedSeats.length > 0) {
|
||||
try {
|
||||
await holdMutation.mutateAsync(selectedSeats);
|
||||
const updatedPassengers = passengers.map((p, i) => {
|
||||
const seatData = validSeats?.find(
|
||||
(s: any) => s.id === selectedSeats[i],
|
||||
);
|
||||
return {
|
||||
...p,
|
||||
outboundSeatId: selectedSeats[i],
|
||||
outboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
|
||||
};
|
||||
});
|
||||
setPassengers(updatedPassengers);
|
||||
} catch (error: any) {
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
title: "Seat Hold Failed",
|
||||
message:
|
||||
error?.response?.data?.message ||
|
||||
"Failed to hold seats. Please try again.",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
setCurrentJourneyType("inbound");
|
||||
setSelectedSeats([]);
|
||||
setSelectedCoach(null);
|
||||
return;
|
||||
}
|
||||
// The furthest passenger a user is allowed to jump to — cannot skip ahead of the
|
||||
// first passenger who still needs a seat.
|
||||
const firstUnassignedIndex = useMemo(
|
||||
() => passengers.findIndex((_, i) => !passengerSeatMap[i]),
|
||||
[passengers, passengerSeatMap],
|
||||
);
|
||||
const maxSelectableIndex =
|
||||
firstUnassignedIndex === -1 ? passengers.length - 1 : firstUnassignedIndex;
|
||||
|
||||
if (selectedSeats.length > 0) {
|
||||
const isSeatSelected = useCallback(
|
||||
(seatId: string) => passengerSeatMap[activePassengerIndex] === seatId,
|
||||
[passengerSeatMap, activePassengerIndex],
|
||||
);
|
||||
|
||||
const isSeatAssignedToOther = useCallback(
|
||||
(seatId: string) =>
|
||||
Object.entries(passengerSeatMap).some(
|
||||
([idx, sid]) => Number(idx) !== activePassengerIndex && sid === seatId,
|
||||
),
|
||||
[passengerSeatMap, activePassengerIndex],
|
||||
);
|
||||
|
||||
const handleSelectPassenger = useCallback(
|
||||
(index: number) => {
|
||||
if (index > maxSelectableIndex) return; // no skipping ahead of unassigned passengers
|
||||
setActivePassengerIndex(index);
|
||||
},
|
||||
[maxSelectableIndex],
|
||||
);
|
||||
|
||||
const handleSeatClick = useCallback(
|
||||
(seatId: string) => {
|
||||
// Seat already claimed by a different passenger — never allow duplicate assignment
|
||||
const takenByOther = Object.entries(passengerSeatMap).some(
|
||||
([idx, sid]) => Number(idx) !== activePassengerIndex && sid === seatId,
|
||||
);
|
||||
if (takenByOther) return;
|
||||
|
||||
const isDeselecting = passengerSeatMap[activePassengerIndex] === seatId;
|
||||
const next = { ...passengerSeatMap };
|
||||
if (isDeselecting) {
|
||||
delete next[activePassengerIndex];
|
||||
} else {
|
||||
next[activePassengerIndex] = seatId;
|
||||
}
|
||||
setPassengerSeatMap(next);
|
||||
|
||||
if (!isDeselecting) {
|
||||
// Move on to the next passenger who still needs a seat — one passenger at a time
|
||||
const nextUnassigned = passengers.findIndex(
|
||||
(_, i) => i !== activePassengerIndex && !next[i],
|
||||
);
|
||||
if (nextUnassigned !== -1) setActivePassengerIndex(nextUnassigned);
|
||||
}
|
||||
},
|
||||
[passengerSeatMap, activePassengerIndex, passengers],
|
||||
);
|
||||
|
||||
const allSeatsAssigned =
|
||||
passengers.length > 0 &&
|
||||
passengers.every((_, i) => !!passengerSeatMap[i]);
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (!allSeatsAssigned) return;
|
||||
const seatIds = passengers.map((_, i) => passengerSeatMap[i]);
|
||||
|
||||
if (isRoundTrip && currentJourneyType === "outbound") {
|
||||
try {
|
||||
await holdMutation.mutateAsync(selectedSeats);
|
||||
await holdMutation.mutateAsync(seatIds);
|
||||
const updatedPassengers = passengers.map((p, i) => {
|
||||
const seatData = validSeats?.find(
|
||||
(s: any) => s.id === selectedSeats[i],
|
||||
);
|
||||
if (isRoundTrip && currentJourneyType === "inbound") {
|
||||
return {
|
||||
...p,
|
||||
inboundSeatId: selectedSeats[i],
|
||||
inboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
|
||||
};
|
||||
}
|
||||
const seatData = validSeats?.find((s: any) => s.id === seatIds[i]);
|
||||
return {
|
||||
...p,
|
||||
seatId: selectedSeats[i],
|
||||
seatNumber: seatData ? buildSeatLabel(seatData) : '',
|
||||
outboundSeatId: seatIds[i],
|
||||
outboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
|
||||
};
|
||||
});
|
||||
setPassengers(updatedPassengers);
|
||||
@@ -423,42 +457,32 @@ export default function SeatsPage() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
router.push("/booking/review");
|
||||
};
|
||||
|
||||
const handleAutoAssign = async () => {
|
||||
const availableSeats =
|
||||
validSeats?.filter((s: any) => s.status === "AVAILABLE") || [];
|
||||
if (availableSeats.length < passengers.length) {
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
title: "Not Enough Seats",
|
||||
message: `Only ${availableSeats.length} seat(s) available in this coach, but you need ${passengers.length} seat(s). Please select another coach.`,
|
||||
type: "warning",
|
||||
});
|
||||
setCurrentJourneyType("inbound");
|
||||
setPassengerSeatMap({});
|
||||
setActivePassengerIndex(0);
|
||||
setSelectedCoach(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const autoSelectedSeats = availableSeats
|
||||
.slice(0, passengers.length)
|
||||
.map((s: any) => s.id);
|
||||
setSelectedSeats(autoSelectedSeats);
|
||||
|
||||
try {
|
||||
await holdMutation.mutateAsync(autoSelectedSeats);
|
||||
await holdMutation.mutateAsync(seatIds);
|
||||
const updatedPassengers = passengers.map((p, i) => {
|
||||
const seatData = availableSeats[i];
|
||||
const seatData = validSeats?.find((s: any) => s.id === seatIds[i]);
|
||||
if (isRoundTrip && currentJourneyType === "inbound") {
|
||||
return {
|
||||
...p,
|
||||
inboundSeatId: seatIds[i],
|
||||
inboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
seatId: autoSelectedSeats[i],
|
||||
seatId: seatIds[i],
|
||||
seatNumber: seatData ? buildSeatLabel(seatData) : '',
|
||||
};
|
||||
});
|
||||
setPassengers(updatedPassengers);
|
||||
router.push("/booking/review");
|
||||
} catch (error: any) {
|
||||
console.error("Failed to hold seats:", error);
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
title: "Seat Hold Failed",
|
||||
@@ -467,7 +491,38 @@ export default function SeatsPage() {
|
||||
"Failed to hold seats. Please try again.",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
router.push("/booking/review");
|
||||
};
|
||||
|
||||
const handleAutoAssign = () => {
|
||||
const unassignedIndices = passengers
|
||||
.map((_, i) => i)
|
||||
.filter((i) => !passengerSeatMap[i]);
|
||||
|
||||
if (unassignedIndices.length === 0) return;
|
||||
|
||||
const availableSeats = (
|
||||
validSeats?.filter((s: any) => s.status === "AVAILABLE") || []
|
||||
).filter((s: any) => !assignedSeatIds.has(s.id));
|
||||
|
||||
if (availableSeats.length < unassignedIndices.length) {
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
title: "Not Enough Seats",
|
||||
message: `Only ${availableSeats.length} seat(s) available in this coach, but you need ${unassignedIndices.length} more seat(s). Please select another coach.`,
|
||||
type: "warning",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const next = { ...passengerSeatMap };
|
||||
unassignedIndices.forEach((passengerIndex, offset) => {
|
||||
next[passengerIndex] = availableSeats[offset].id;
|
||||
});
|
||||
setPassengerSeatMap(next);
|
||||
setActivePassengerIndex(passengers.length - 1);
|
||||
};
|
||||
|
||||
const handleBackToPassengers = () => {
|
||||
@@ -494,8 +549,9 @@ export default function SeatsPage() {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (bookingId && selectedSeats.length > 0) {
|
||||
bookSeatsMutation.mutate(selectedSeats);
|
||||
const heldSeatIds = Object.values(passengerSeatMap);
|
||||
if (bookingId && heldSeatIds.length > 0) {
|
||||
bookSeatsMutation.mutate(heldSeatIds);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bookingId]);
|
||||
@@ -636,7 +692,8 @@ export default function SeatsPage() {
|
||||
<div key={bed.id} className="relative">
|
||||
<BedCard
|
||||
bed={bed}
|
||||
isSelected={selectedSeats.includes(bed.id)}
|
||||
isSelected={isSeatSelected(bed.id)}
|
||||
isAssignedToOther={isSeatAssignedToOther(bed.id)}
|
||||
onToggle={handleSeatClick}
|
||||
bedLabel="U"
|
||||
/>
|
||||
@@ -661,7 +718,8 @@ export default function SeatsPage() {
|
||||
<div key={bed.id} className="relative">
|
||||
<BedCard
|
||||
bed={bed}
|
||||
isSelected={selectedSeats.includes(bed.id)}
|
||||
isSelected={isSeatSelected(bed.id)}
|
||||
isAssignedToOther={isSeatAssignedToOther(bed.id)}
|
||||
onToggle={handleSeatClick}
|
||||
bedLabel="L"
|
||||
/>
|
||||
@@ -695,7 +753,8 @@ export default function SeatsPage() {
|
||||
<BedCard
|
||||
key={bed.id}
|
||||
bed={bed}
|
||||
isSelected={selectedSeats.includes(bed.id)}
|
||||
isSelected={isSeatSelected(bed.id)}
|
||||
isAssignedToOther={isSeatAssignedToOther(bed.id)}
|
||||
onToggle={handleSeatClick}
|
||||
bedLabel="L"
|
||||
/>
|
||||
@@ -713,7 +772,8 @@ export default function SeatsPage() {
|
||||
<BedCard
|
||||
key={bed.id}
|
||||
bed={bed}
|
||||
isSelected={selectedSeats.includes(bed.id)}
|
||||
isSelected={isSeatSelected(bed.id)}
|
||||
isAssignedToOther={isSeatAssignedToOther(bed.id)}
|
||||
onToggle={handleSeatClick}
|
||||
bedLabel="L"
|
||||
/>
|
||||
@@ -739,7 +799,8 @@ export default function SeatsPage() {
|
||||
<BedCard
|
||||
key={bed.id}
|
||||
bed={bed}
|
||||
isSelected={selectedSeats.includes(bed.id)}
|
||||
isSelected={isSeatSelected(bed.id)}
|
||||
isAssignedToOther={isSeatAssignedToOther(bed.id)}
|
||||
onToggle={handleSeatClick}
|
||||
bedLabel="M"
|
||||
/>
|
||||
@@ -757,7 +818,8 @@ export default function SeatsPage() {
|
||||
<BedCard
|
||||
key={bed.id}
|
||||
bed={bed}
|
||||
isSelected={selectedSeats.includes(bed.id)}
|
||||
isSelected={isSeatSelected(bed.id)}
|
||||
isAssignedToOther={isSeatAssignedToOther(bed.id)}
|
||||
onToggle={handleSeatClick}
|
||||
bedLabel="M"
|
||||
/>
|
||||
@@ -783,7 +845,8 @@ export default function SeatsPage() {
|
||||
<BedCard
|
||||
key={bed.id}
|
||||
bed={bed}
|
||||
isSelected={selectedSeats.includes(bed.id)}
|
||||
isSelected={isSeatSelected(bed.id)}
|
||||
isAssignedToOther={isSeatAssignedToOther(bed.id)}
|
||||
onToggle={handleSeatClick}
|
||||
bedLabel="U"
|
||||
/>
|
||||
@@ -801,7 +864,8 @@ export default function SeatsPage() {
|
||||
<BedCard
|
||||
key={bed.id}
|
||||
bed={bed}
|
||||
isSelected={selectedSeats.includes(bed.id)}
|
||||
isSelected={isSeatSelected(bed.id)}
|
||||
isAssignedToOther={isSeatAssignedToOther(bed.id)}
|
||||
onToggle={handleSeatClick}
|
||||
bedLabel="U"
|
||||
/>
|
||||
@@ -880,7 +944,8 @@ export default function SeatsPage() {
|
||||
<SeatButton
|
||||
key={seat.id}
|
||||
seat={seat}
|
||||
isSelected={selectedSeats.includes(seat.id)}
|
||||
isSelected={isSeatSelected(seat.id)}
|
||||
isAssignedToOther={isSeatAssignedToOther(seat.id)}
|
||||
onToggle={handleSeatClick}
|
||||
isBedCoach={true}
|
||||
bedLabel={getBedLabel(seat.bedPosition)}
|
||||
@@ -992,7 +1057,8 @@ export default function SeatsPage() {
|
||||
<SeatButton
|
||||
key={seat.id}
|
||||
seat={seat}
|
||||
isSelected={selectedSeats.includes(seat.id)}
|
||||
isSelected={isSeatSelected(seat.id)}
|
||||
isAssignedToOther={isSeatAssignedToOther(seat.id)}
|
||||
onToggle={handleSeatClick}
|
||||
isBedCoach={false}
|
||||
bedLabel=""
|
||||
@@ -1065,7 +1131,8 @@ export default function SeatsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const allSelected = selectedSeats.length === passengers.length;
|
||||
const assignedCount = passengers.filter((_, i) => !!passengerSeatMap[i]).length;
|
||||
const activePassenger = passengers[activePassengerIndex];
|
||||
const isBedCoach =
|
||||
selectedCoachData?.isBedCoach === true ||
|
||||
selectedCoachData?.rooms?.length > 0 ||
|
||||
@@ -1081,18 +1148,18 @@ export default function SeatsPage() {
|
||||
</h3>
|
||||
<span
|
||||
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
||||
allSelected
|
||||
allSeatsAssigned
|
||||
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
}`}
|
||||
>
|
||||
{selectedSeats.length}/{passengers.length} selected
|
||||
{assignedCount}/{passengers.length} selected
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
{allSelected
|
||||
{allSeatsAssigned
|
||||
? "All seats selected — ready to continue"
|
||||
: `Select ${passengers.length - selectedSeats.length} more seat(s)`}
|
||||
: `Selecting seat for ${activePassenger?.name || `Passenger ${activePassengerIndex + 1}`} (${activePassengerIndex + 1} of ${passengers.length})`}
|
||||
</p>
|
||||
|
||||
{/* Progress bar */}
|
||||
@@ -1100,75 +1167,88 @@ export default function SeatsPage() {
|
||||
<div
|
||||
className="h-full bg-[rgb(20,113,76)] rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${(selectedSeats.length / passengers.length) * 100}%`,
|
||||
width: `${(assignedCount / passengers.length) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-5">
|
||||
{passengers.map((p, i) => {
|
||||
const assignedSeat = selectedSeats[i]
|
||||
? validSeats?.find((s: any) => s.id === selectedSeats[i])
|
||||
const assignedSeatId = passengerSeatMap[i];
|
||||
const assignedSeat = assignedSeatId
|
||||
? validSeats?.find((s: any) => s.id === assignedSeatId)
|
||||
: null;
|
||||
const seatLabel = assignedSeat
|
||||
? assignedSeat.number ||
|
||||
assignedSeat.label ||
|
||||
assignedSeat.seatNumber ||
|
||||
"—"
|
||||
: "—";
|
||||
const bedLabel = assignedSeat
|
||||
? getBedLabel(assignedSeat.bedPosition)
|
||||
: "";
|
||||
const seatLabel = assignedSeat ? buildSeatLabel(assignedSeat) : "";
|
||||
const isActive = i === activePassengerIndex;
|
||||
const isClickable = i <= maxSelectableIndex;
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
key={i}
|
||||
className="flex items-center justify-between py-2 border-b border-gray-100 dark:border-gray-800 last:border-0"
|
||||
type="button"
|
||||
onClick={() => handleSelectPassenger(i)}
|
||||
disabled={!isClickable}
|
||||
className={`w-full flex items-center justify-between py-2 px-2 rounded-lg border text-left transition-all ${
|
||||
isActive
|
||||
? "border-[rgb(20,113,76)] bg-[rgb(20,113,76)]/5 dark:bg-[rgb(20,113,76)]/10"
|
||||
: "border-transparent"
|
||||
} ${
|
||||
isClickable
|
||||
? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/60"
|
||||
: "cursor-not-allowed opacity-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
|
||||
assignedSeat
|
||||
? "bg-[rgb(20,113,76)] text-white"
|
||||
: "bg-gray-200 dark:bg-gray-700 text-gray-500"
|
||||
: isActive
|
||||
? "bg-[rgb(20,113,76)]/20 text-[rgb(20,113,76)] ring-2 ring-[rgb(20,113,76)]"
|
||||
: "bg-gray-200 dark:bg-gray-700 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 truncate max-w-[120px]">
|
||||
{p.name}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 truncate block max-w-[120px]">
|
||||
{p.name}
|
||||
</span>
|
||||
{isActive && !assignedSeat && (
|
||||
<span className="text-[10px] font-semibold text-[rgb(20,113,76)] uppercase tracking-wide">
|
||||
Now selecting
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-semibold ${
|
||||
className={`text-sm font-semibold flex-shrink-0 ${
|
||||
assignedSeat ? "text-[rgb(20,113,76)]" : "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{assignedSeat ? `${seatLabel}${bedLabel}` : "Not selected"}
|
||||
{assignedSeat ? `Seat ${seatLabel}` : "Not Assigned"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
disabled={selectedSeats.length === 0 || holdMutation.isPending}
|
||||
disabled={!allSeatsAssigned || holdMutation.isPending}
|
||||
className="w-full py-3 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold text-sm rounded-xl transition-all shadow-lg"
|
||||
>
|
||||
{holdMutation.isPending
|
||||
? "Holding seats..."
|
||||
: isRoundTrip && currentJourneyType === "outbound"
|
||||
? "Continue to Return Seats"
|
||||
: allSelected
|
||||
? "Continue"
|
||||
: "Continue with partial selection"}
|
||||
: "Continue"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAutoAssign}
|
||||
disabled={holdMutation.isPending}
|
||||
disabled={holdMutation.isPending || allSeatsAssigned}
|
||||
className="w-full mt-2 py-2.5 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 text-gray-700 dark:text-gray-300 font-semibold text-sm rounded-xl transition-all"
|
||||
>
|
||||
Auto-assign seats
|
||||
Auto Assign Seats
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
@@ -1183,21 +1263,17 @@ export default function SeatsPage() {
|
||||
type={modalState.type}
|
||||
/>
|
||||
|
||||
{/* Mobile summary bottom-sheet */}
|
||||
{selectedSeats.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-x-0 bottom-0 z-50 lg:hidden bg-white dark:bg-gray-900 rounded-t-2xl shadow-2xl p-5"
|
||||
style={{
|
||||
animation: "seats-slide-up 0.25s cubic-bezier(0.32,0.72,0,1)",
|
||||
}}
|
||||
>
|
||||
<div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full mx-auto mb-4" />
|
||||
<SummaryContent />
|
||||
</div>
|
||||
<style>{`@keyframes seats-slide-up{from{transform:translateY(100%)}to{transform:translateY(0)}}`}</style>
|
||||
</>
|
||||
)}
|
||||
{/* Mobile summary bottom-sheet — always visible so the active passenger is clear */}
|
||||
<div
|
||||
className="fixed inset-x-0 bottom-0 z-50 lg:hidden bg-white dark:bg-gray-900 rounded-t-2xl shadow-2xl p-5 max-h-[70vh] overflow-y-auto"
|
||||
style={{
|
||||
animation: "seats-slide-up 0.25s cubic-bezier(0.32,0.72,0,1)",
|
||||
}}
|
||||
>
|
||||
<div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full mx-auto mb-4" />
|
||||
<SummaryContent />
|
||||
</div>
|
||||
<style>{`@keyframes seats-slide-up{from{transform:translateY(100%)}to{transform:translateY(0)}}`}</style>
|
||||
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
{/* Header */}
|
||||
@@ -1211,15 +1287,22 @@ export default function SeatsPage() {
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
<h1 className="text-base font-bold text-gray-900 dark:text-white">
|
||||
{isRoundTrip
|
||||
? currentJourneyType === "outbound"
|
||||
? "Select Outbound Seats"
|
||||
: "Select Return Seats"
|
||||
: "Select Seats"}
|
||||
</h1>
|
||||
<div className="text-center">
|
||||
<h1 className="text-base font-bold text-gray-900 dark:text-white">
|
||||
{isRoundTrip
|
||||
? currentJourneyType === "outbound"
|
||||
? "Select Outbound Seats"
|
||||
: "Select Return Seats"
|
||||
: "Select Seats"}
|
||||
</h1>
|
||||
{!allSeatsAssigned && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Now selecting: {activePassenger?.name || `Passenger ${activePassengerIndex + 1}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-[rgb(20,113,76)]">
|
||||
{selectedSeats.length}/{passengers.length}
|
||||
{assignedCount}/{passengers.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -104,7 +104,7 @@ function fmt(iso: string, opts?: Intl.DateTimeFormatOptions): string {
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import { Suspense } from 'react';
|
||||
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
|
||||
import SearchPage from '@/app/booking/search/page';
|
||||
import PackagesSection from '@/components/PackagesSection';
|
||||
|
||||
export default function Home() {
|
||||
export default async function Home() {
|
||||
const queryClient = new QueryClient();
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: ['stations'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${apiUrl}/stations`, { next: { revalidate: 3600 } });
|
||||
const json = await res.json();
|
||||
return json?.data ?? json;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<SearchPage />
|
||||
<PackagesSection />
|
||||
</Suspense>
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<Suspense>
|
||||
<SearchPage />
|
||||
<PackagesSection />
|
||||
</Suspense>
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function AppHeader() {
|
||||
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-[rgb(20_113_76)] dark:bg-gray-900 border-b border-[rgb(16_89_60)] dark:border-gray-800 shadow-sm">
|
||||
<header className="sticky top-0 z-[60] bg-[rgb(20_113_76)] dark:bg-gray-900 border-b border-[rgb(16_89_60)] dark:border-gray-800 shadow-sm">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
|
||||
@@ -146,7 +146,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="space-y-2 relative z-30 md:col-span-1">
|
||||
<div className="space-y-2 md:col-span-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Date</label>
|
||||
<ModernDatePicker
|
||||
value={watch('departureDate') ? new Date(watch('departureDate') + 'T00:00:00') : undefined}
|
||||
|
||||
@@ -119,7 +119,7 @@ function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, label: string | null
|
||||
|
||||
const dep = new Date(schedule.departureAt);
|
||||
doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
|
||||
doc.text(dep.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), margin + 5, y + 33);
|
||||
doc.text(dep.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), margin + 5, y + 33);
|
||||
doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
|
||||
doc.text(dep.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, y + 38);
|
||||
|
||||
@@ -143,7 +143,7 @@ function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, label: string | null
|
||||
|
||||
const arr = new Date(schedule.arrivalAt);
|
||||
doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
|
||||
doc.text(arr.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), dx, y + 33);
|
||||
doc.text(arr.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), dx, y + 33);
|
||||
doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
|
||||
doc.text(arr.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), dx, y + 38);
|
||||
|
||||
|
||||
@@ -13,9 +13,17 @@ export const formatDate = (date: string | Date, formatStr: string = 'MMM dd, yyy
|
||||
};
|
||||
|
||||
export const formatDateTime = (date: string | Date): string => {
|
||||
return format(new Date(date), 'MMM dd, yyyy HH:mm');
|
||||
return format(new Date(date), 'MMM dd, yyyy h:mm a');
|
||||
};
|
||||
|
||||
export const formatTime = (date: string | Date): string => {
|
||||
return format(new Date(date), 'HH:mm');
|
||||
return format(new Date(date), 'h:mm a');
|
||||
};
|
||||
|
||||
export const getTimePeriod = (date: string | Date): string => {
|
||||
const hour = new Date(date).getHours();
|
||||
if (hour >= 5 && hour < 12) return 'Morning';
|
||||
if (hour >= 12 && hour < 17) return 'Afternoon';
|
||||
if (hour >= 17 && hour < 21) return 'Evening';
|
||||
return 'Night';
|
||||
};
|
||||
|
||||
@@ -91,6 +91,7 @@ export enum BookingStatus {
|
||||
Cancelled = "CANCELLED",
|
||||
PendingConsolidation = "PENDING_CONSOLIDATION",
|
||||
Consolidated = "CONSOLIDATED",
|
||||
TruckAssigned = "TRUCK_ASSIGNED",
|
||||
/** General contract: paid umbrella contract that is accepting drawdown orders. */
|
||||
ContractActive = "CONTRACT_ACTIVE",
|
||||
/** General contract: closed because its quantity was exhausted (or period elapsed). */
|
||||
@@ -420,6 +421,12 @@ export interface IBooking extends BaseEntity {
|
||||
lastMileDeliveryAddress?: string | null;
|
||||
lastMileDeliveryLat?: number | null;
|
||||
lastMileDeliveryLng?: number | null;
|
||||
customerTruckPlateNumber?: string | null;
|
||||
customerTruckDriverName?: string | null;
|
||||
customerTruckType?: string | null;
|
||||
customerTruckContainerNumber?: string | null;
|
||||
customerTruckAssignedAt?: string | null;
|
||||
customerTruckArrivedAt?: string | null;
|
||||
|
||||
customsClearingEnabled?: boolean;
|
||||
customsClearingAgent?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user