mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 16:35:42 +00:00
Handover customer sign
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
|
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
|
||||||
"seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts",
|
"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:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",
|
||||||
|
"seed:paid-import-export-mile-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-paid-import-export-mile-demo.ts",
|
||||||
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.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",
|
"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",
|
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k
|
|||||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||||
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
||||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||||
|
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
|
||||||
//New Trains, Wagons, Container and Cargo management modules
|
//New Trains, Wagons, Container and Cargo management modules
|
||||||
import { TrainsModule } from "./modules/trains/trains.module";
|
import { TrainsModule } from "./modules/trains/trains.module";
|
||||||
import { WagonsModule } from "./modules/wagons/wagons.module";
|
import { WagonsModule } from "./modules/wagons/wagons.module";
|
||||||
@@ -165,6 +166,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
|||||||
ExportDjiboutiInterchangeDemoSeeder,
|
ExportDjiboutiInterchangeDemoSeeder,
|
||||||
MarshallingDemoTrainsSeeder,
|
MarshallingDemoTrainsSeeder,
|
||||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||||
|
PaidImportExportMileDemoSeeder,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
config({ path: resolve(__dirname, '../../.env') });
|
||||||
|
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from '../app.module';
|
||||||
|
import { PaidImportExportMileDemoSeeder } from '../seed/paid-import-export-mile-demo.seeder';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||||
|
logger: ['error', 'warn', 'log'],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const seeder = app.get(PaidImportExportMileDemoSeeder);
|
||||||
|
await seeder.run();
|
||||||
|
console.log('Paid import/export mile demo bookings seeded.');
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Paid import/export mile demo booking seed failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
|
||||||
|
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||||
|
import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity';
|
||||||
|
import { FirstMile } from '../modules/first-mile/entities/first-mile.entity';
|
||||||
|
import { LastMile } from '../modules/last-mile/entities/last-mile.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';
|
||||||
|
|
||||||
|
const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_PAID_MILE';
|
||||||
|
const COMPANY_TIN = 'PAIDMILE001';
|
||||||
|
const COMPANY_EMAIL = 'paid-mile-demo@edr.local';
|
||||||
|
|
||||||
|
const YARDS = [
|
||||||
|
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 },
|
||||||
|
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONTAINER_TYPES = [
|
||||||
|
{ code: '20FT', label: '20FT', sizeFt: 20 },
|
||||||
|
{ code: '40FT', label: '40FT', sizeFt: 40 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Six paid, approved container bookings that mirror the real trucking legs:
|
||||||
|
* - EXPORT (Ethiopia -> Djibouti) carries a FIRST-MILE leg (factory -> rail terminal).
|
||||||
|
* - IMPORT (Djibouti -> Ethiopia) carries a LAST-MILE leg (dry port -> final delivery).
|
||||||
|
* Each booking is paymentStatus PAID and its single mile leg is marked paid + ready to transit.
|
||||||
|
*/
|
||||||
|
const DEMO_BOOKINGS = [
|
||||||
|
// ── IMPORT: last mile only ─────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
reference: 'PAID-IMP-001',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
containerCode: '40FT',
|
||||||
|
quantity: 8,
|
||||||
|
totalWeightTons: 224,
|
||||||
|
originCode: 'DJIBOUTI',
|
||||||
|
destinationCode: 'ADDIS_ABABA',
|
||||||
|
scheduledDate: '2026-07-01T08:00:00.000Z',
|
||||||
|
lastMileDeliveryAddress: 'Akaki Industrial Zone, Addis Ababa',
|
||||||
|
lastMileDeliveryLat: 8.8808,
|
||||||
|
lastMileDeliveryLng: 38.7876,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'PAID-IMP-002',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
containerCode: '20FT',
|
||||||
|
quantity: 12,
|
||||||
|
totalWeightTons: 240,
|
||||||
|
originCode: 'DJIBOUTI',
|
||||||
|
destinationCode: 'ADDIS_ABABA',
|
||||||
|
scheduledDate: '2026-07-02T08:00:00.000Z',
|
||||||
|
lastMileDeliveryAddress: 'Kality Logistics Hub, Addis Ababa',
|
||||||
|
lastMileDeliveryLat: 8.9137,
|
||||||
|
lastMileDeliveryLng: 38.7815,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'PAID-IMP-003',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
containerCode: '40FT',
|
||||||
|
quantity: 6,
|
||||||
|
totalWeightTons: 180,
|
||||||
|
originCode: 'DJIBOUTI',
|
||||||
|
destinationCode: 'ADDIS_ABABA',
|
||||||
|
scheduledDate: '2026-07-03T08:00:00.000Z',
|
||||||
|
lastMileDeliveryAddress: 'Bole Lemi Industrial Park, Addis Ababa',
|
||||||
|
lastMileDeliveryLat: 8.9806,
|
||||||
|
lastMileDeliveryLng: 38.8736,
|
||||||
|
},
|
||||||
|
// ── EXPORT: first mile only ────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
reference: 'PAID-EXP-001',
|
||||||
|
tradeDirection: 'EXPORT',
|
||||||
|
containerCode: '40FT',
|
||||||
|
quantity: 7,
|
||||||
|
totalWeightTons: 196,
|
||||||
|
originCode: 'ADDIS_ABABA',
|
||||||
|
destinationCode: 'DJIBOUTI',
|
||||||
|
scheduledDate: '2026-07-01T10:00:00.000Z',
|
||||||
|
firstMilePickupAddress: 'Bole Lemi Industrial Park, Addis Ababa',
|
||||||
|
firstMilePickupLat: 8.9806,
|
||||||
|
firstMilePickupLng: 38.8736,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'PAID-EXP-002',
|
||||||
|
tradeDirection: 'EXPORT',
|
||||||
|
containerCode: '20FT',
|
||||||
|
quantity: 11,
|
||||||
|
totalWeightTons: 220,
|
||||||
|
originCode: 'ADDIS_ABABA',
|
||||||
|
destinationCode: 'DJIBOUTI',
|
||||||
|
scheduledDate: '2026-07-02T10:00:00.000Z',
|
||||||
|
firstMilePickupAddress: 'Akaki Industrial Zone, Addis Ababa',
|
||||||
|
firstMilePickupLat: 8.8808,
|
||||||
|
firstMilePickupLng: 38.7876,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'PAID-EXP-003',
|
||||||
|
tradeDirection: 'EXPORT',
|
||||||
|
containerCode: '40FT',
|
||||||
|
quantity: 4,
|
||||||
|
totalWeightTons: 128,
|
||||||
|
originCode: 'ADDIS_ABABA',
|
||||||
|
destinationCode: 'DJIBOUTI',
|
||||||
|
scheduledDate: '2026-07-03T10:00:00.000Z',
|
||||||
|
firstMilePickupAddress: 'Kality Logistics Hub, Addis Ababa',
|
||||||
|
firstMilePickupLat: 8.9137,
|
||||||
|
firstMilePickupLng: 38.7815,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PaidImportExportMileDemoSeeder {
|
||||||
|
private readonly logger = new Logger(PaidImportExportMileDemoSeeder.name);
|
||||||
|
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.getRepository(Yard).upsert(
|
||||||
|
YARDS.map((yard) => ({ ...yard, isActive: true })),
|
||||||
|
{ conflictPaths: { code: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.getRepository(ServiceType).upsert(
|
||||||
|
{
|
||||||
|
code: SERVICE_TYPE_CODE,
|
||||||
|
serviceName: 'Rail Container with Paid First/Last Mile',
|
||||||
|
description: 'Demo service type for paid import/export bookings with a single mile leg',
|
||||||
|
canBeBookedAlone: true,
|
||||||
|
includesFirstMile: true,
|
||||||
|
includesLastMile: true,
|
||||||
|
includesCustoms: false,
|
||||||
|
priorityBonusPoints: 0,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 11,
|
||||||
|
},
|
||||||
|
{ conflictPaths: { code: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.getRepository(ContainerType).upsert(
|
||||||
|
CONTAINER_TYPES.map((containerType, index) => ({
|
||||||
|
...containerType,
|
||||||
|
wagonsPerUnit: 1,
|
||||||
|
isReefer: false,
|
||||||
|
isOpenTop: false,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: index + 1,
|
||||||
|
})),
|
||||||
|
{ conflictPaths: { code: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.getRepository(Company).upsert(
|
||||||
|
{
|
||||||
|
name: 'Paid Import/Export Mile Demo Customer',
|
||||||
|
type: CompanyType.Customer,
|
||||||
|
status: CompanyStatus.Active,
|
||||||
|
tin: COMPANY_TIN,
|
||||||
|
vatNumber: COMPANY_TIN,
|
||||||
|
fanNumber: 'PMD0000000000001',
|
||||||
|
country: 'Ethiopia',
|
||||||
|
address: 'Addis Ababa',
|
||||||
|
phone: '251900000202',
|
||||||
|
email: COMPANY_EMAIL,
|
||||||
|
website: null,
|
||||||
|
contactPersonName: 'Paid Mile Demo',
|
||||||
|
contactPersonPhone: '251900000202',
|
||||||
|
generalManagerName: 'Demo Manager',
|
||||||
|
generalManagerEmail: COMPANY_EMAIL,
|
||||||
|
generalManagerPhone: '251900000202',
|
||||||
|
},
|
||||||
|
{ conflictPaths: { tin: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const [serviceType, company, yards, containerTypes] = await Promise.all([
|
||||||
|
manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }),
|
||||||
|
manager.getRepository(Company).findOneByOrFail({ tin: COMPANY_TIN }),
|
||||||
|
manager.getRepository(Yard).find(),
|
||||||
|
manager.getRepository(ContainerType).find(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const yardByCode = new Map(yards.map((yard) => [yard.code, yard]));
|
||||||
|
const containerTypeByCode = new Map(
|
||||||
|
containerTypes.map((containerType) => [containerType.code, containerType]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const demoBooking of DEMO_BOOKINGS) {
|
||||||
|
const origin = yardByCode.get(demoBooking.originCode);
|
||||||
|
const destination = yardByCode.get(demoBooking.destinationCode);
|
||||||
|
const containerType = containerTypeByCode.get(demoBooking.containerCode);
|
||||||
|
|
||||||
|
if (!origin || !destination || !containerType) {
|
||||||
|
throw new Error(`paid_import_export_mile_demo_dependency_missing:${demoBooking.reference}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isImport = demoBooking.tradeDirection === 'IMPORT';
|
||||||
|
const wagonsRequired =
|
||||||
|
Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1);
|
||||||
|
const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity;
|
||||||
|
|
||||||
|
await manager.getRepository(Booking).upsert(
|
||||||
|
{
|
||||||
|
reference: demoBooking.reference,
|
||||||
|
companyId: company.id,
|
||||||
|
status: 'APPROVED',
|
||||||
|
scheduledDate: new Date(demoBooking.scheduledDate),
|
||||||
|
estimatedShipmentDate: new Date(demoBooking.scheduledDate),
|
||||||
|
totalAmount: demoBooking.totalWeightTons * 25,
|
||||||
|
paymentStatus: 'PAID',
|
||||||
|
contractType: 'NEW',
|
||||||
|
serviceTypeId: serviceType.id,
|
||||||
|
// Only the leg that matches the trade direction carries an address.
|
||||||
|
firstMilePickupAddress: isImport ? null : demoBooking.firstMilePickupAddress,
|
||||||
|
firstMilePickupLat: isImport ? null : demoBooking.firstMilePickupLat,
|
||||||
|
firstMilePickupLng: isImport ? null : demoBooking.firstMilePickupLng,
|
||||||
|
lastMileDeliveryAddress: isImport ? demoBooking.lastMileDeliveryAddress : null,
|
||||||
|
lastMileDeliveryLat: isImport ? demoBooking.lastMileDeliveryLat : null,
|
||||||
|
lastMileDeliveryLng: isImport ? demoBooking.lastMileDeliveryLng : null,
|
||||||
|
equipmentReturn: 'WITHOUT_RETURN',
|
||||||
|
originYardId: origin.id,
|
||||||
|
destinationYardId: destination.id,
|
||||||
|
tradeDirection: demoBooking.tradeDirection,
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
cargoTypeId: null,
|
||||||
|
cargoFreeText: 'Demo container cargo',
|
||||||
|
shippingLineId: null,
|
||||||
|
cargoTotalWeightVgm: demoBooking.totalWeightTons,
|
||||||
|
isHazardous: false,
|
||||||
|
isReefer: false,
|
||||||
|
paymentCurrency: 'ETB',
|
||||||
|
approvedByStaffAt: new Date(),
|
||||||
|
priorityScore: 20,
|
||||||
|
wagonsRequired,
|
||||||
|
schedulingStatus: 'NOT_SCHEDULED',
|
||||||
|
versionNumber: 1,
|
||||||
|
},
|
||||||
|
{ conflictPaths: { reference: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const booking = await manager.getRepository(Booking).findOneByOrFail({
|
||||||
|
reference: demoBooking.reference,
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.getRepository(BookingContainer).delete({ bookingId: booking.id });
|
||||||
|
await manager.getRepository(BookingContainer).insert({
|
||||||
|
id: randomUUID(),
|
||||||
|
bookingId: booking.id,
|
||||||
|
containerTypeId: containerType.id,
|
||||||
|
quantity: demoBooking.quantity,
|
||||||
|
vgmPerUnitTons,
|
||||||
|
totalVgmTons: demoBooking.totalWeightTons,
|
||||||
|
wagonsRequired,
|
||||||
|
weightLimitRuleId: null,
|
||||||
|
isOverweight: vgmPerUnitTons > 35,
|
||||||
|
overweightExcessTons: vgmPerUnitTons > 35 ? vgmPerUnitTons - 35 : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset any existing legs for idempotency, then create the single paid leg.
|
||||||
|
await manager.getRepository(FirstMile).delete({ bookingId: booking.id });
|
||||||
|
await manager.getRepository(LastMile).delete({ bookingId: booking.id });
|
||||||
|
|
||||||
|
const paidAmount = demoBooking.totalWeightTons * 25;
|
||||||
|
|
||||||
|
if (isImport) {
|
||||||
|
await manager.getRepository(LastMile).insert({
|
||||||
|
bookingId: booking.id,
|
||||||
|
status: 'READY_TO_TRANSIT',
|
||||||
|
advancedPayment: paidAmount,
|
||||||
|
remainingPayment: 0,
|
||||||
|
paid: true,
|
||||||
|
estimatedKm: 22,
|
||||||
|
exactKm: null,
|
||||||
|
vehicleId: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await manager.getRepository(FirstMile).insert({
|
||||||
|
bookingId: booking.id,
|
||||||
|
status: 'READY_TO_TRANSIT',
|
||||||
|
advancedPayment: paidAmount,
|
||||||
|
remainingPayment: 0,
|
||||||
|
paid: true,
|
||||||
|
estimatedKm: 18,
|
||||||
|
exactKm: null,
|
||||||
|
vehicleId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
'Seeded 6 paid bookings: 3 import (last-mile) + 3 export (first-mile).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -170,5 +170,6 @@ export const URL_CONSTANTS = {
|
|||||||
BY_ID: (id: string) => `/api/warehouse-fee-invoices/${id}`,
|
BY_ID: (id: string) => `/api/warehouse-fee-invoices/${id}`,
|
||||||
DOCUMENT: (id: string) => `/api/warehouse-fee-invoices/${id}/document`,
|
DOCUMENT: (id: string) => `/api/warehouse-fee-invoices/${id}/document`,
|
||||||
RECEIPT: (id: string) => `/api/warehouse-fee-invoices/${id}/receipt`,
|
RECEIPT: (id: string) => `/api/warehouse-fee-invoices/${id}/receipt`,
|
||||||
|
PAY_ONLINE: (id: string) => `/api/warehouse-fee-invoices/${id}/pay-online`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core";
|
import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { Download, Receipt } from "lucide-react";
|
import { CreditCard, Download, Receipt } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||||
import {
|
import {
|
||||||
warehouseInvoicesService,
|
warehouseInvoicesService,
|
||||||
type PortalWarehouseInvoice,
|
type PortalWarehouseInvoice,
|
||||||
} from "@/services/warehouse-invoices.service";
|
} from "@/services/warehouse-invoices.service";
|
||||||
import { saveBlob } from "@/utils/download";
|
import { saveBlob } from "@/utils/download";
|
||||||
|
|
||||||
|
import { PaymentMethodModal } from "./PaymentMethodModal";
|
||||||
import { CardTitle, SectionCard } from "./layout";
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
|
|
||||||
|
/** Warehouse fee invoices the customer can still settle online. */
|
||||||
|
const PAYABLE_STATUSES = new Set(["ISSUED", "PARTIALLY_PAID"]);
|
||||||
|
const isPayable = (inv: PortalWarehouseInvoice) =>
|
||||||
|
PAYABLE_STATUSES.has(inv.status) && Number(inv.balanceAmount ?? 0) > 0;
|
||||||
|
|
||||||
const money = (amount: number | string | null | undefined, currency: string) =>
|
const money = (amount: number | string | null | undefined, currency: string) =>
|
||||||
`${Number(amount ?? 0).toLocaleString()} ${currency}`;
|
`${Number(amount ?? 0).toLocaleString()} ${currency}`;
|
||||||
|
|
||||||
@@ -44,10 +52,12 @@ function StatusPill({ status }: { status: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Warehouse fee invoices linked to this booking — display + PDF download only.
|
* Warehouse fee invoices linked to this booking. Customers can pay outstanding
|
||||||
* Paying them online is tracked separately (in-system demurrage/storage
|
* demurrage/storage invoices online (Telebirr/Waafi) so they can then sign the
|
||||||
* payment). Renders nothing when the booking has no warehouse fees. Carries
|
* delivery handover; paid invoices expose the receipt PDF. The backoffice cash
|
||||||
* `id="warehouse-payments"` so the invoice detail page can deep-link here.
|
* `/pay` (record-a-payment) path is unaffected. Renders nothing when the booking
|
||||||
|
* has no warehouse fees. Carries `id="warehouse-payments"` so the invoice detail
|
||||||
|
* page can deep-link here.
|
||||||
*/
|
*/
|
||||||
export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
||||||
const { data: invoices = [] } = useQuery({
|
const { data: invoices = [] } = useQuery({
|
||||||
@@ -55,6 +65,41 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
|||||||
queryFn: () => warehouseInvoicesService.listForBooking(bookingId),
|
queryFn: () => warehouseInvoicesService.listForBooking(bookingId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [payInvoice, setPayInvoice] = useState<PortalWarehouseInvoice | null>(null);
|
||||||
|
|
||||||
|
const payMutation = useMutation({
|
||||||
|
mutationFn: (method: PaymentMethod) => {
|
||||||
|
if (!payInvoice) throw new Error("No invoice selected for payment.");
|
||||||
|
return warehouseInvoicesService.payOnline(payInvoice.id, {
|
||||||
|
method,
|
||||||
|
platform: "web",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (data, method) => {
|
||||||
|
if (!payInvoice) return;
|
||||||
|
// Redirect to the provider (or the fallback checkout page) — same as the
|
||||||
|
// booking "Pay now" flow, so behaviour is identical everywhere.
|
||||||
|
const redirectUrl =
|
||||||
|
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||||
|
? data.clientAction.url
|
||||||
|
: paymentsService.checkoutUrlForInvoice({ invoiceId: payInvoice.id, method });
|
||||||
|
window.location.href = redirectUrl;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const payError = payMutation.isError
|
||||||
|
? payMutation.error instanceof Error
|
||||||
|
? payMutation.error.message
|
||||||
|
: "Could not start payment. Please try again."
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const closePayModal = () => {
|
||||||
|
if (!payMutation.isPending) {
|
||||||
|
setPayInvoice(null);
|
||||||
|
payMutation.reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (invoices.length === 0) return null;
|
if (invoices.length === 0) return null;
|
||||||
|
|
||||||
const download = async (inv: PortalWarehouseInvoice) => {
|
const download = async (inv: PortalWarehouseInvoice) => {
|
||||||
@@ -128,6 +173,17 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
|
{isPayable(inv) && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
radius={10}
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<CreditCard size={14} />}
|
||||||
|
onClick={() => setPayInvoice(inv)}
|
||||||
|
>
|
||||||
|
Pay
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
@@ -151,6 +207,18 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<PaymentMethodModal
|
||||||
|
opened={payInvoice !== null}
|
||||||
|
onClose={closePayModal}
|
||||||
|
amountLabel={
|
||||||
|
payInvoice ? money(payInvoice.balanceAmount, payInvoice.currency) : undefined
|
||||||
|
}
|
||||||
|
currency={payInvoice?.currency}
|
||||||
|
onConfirm={(method) => payMutation.mutate(method)}
|
||||||
|
processing={payMutation.isPending}
|
||||||
|
error={payError}
|
||||||
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
import { client } from "../utils/api";
|
import { client } from "../utils/api";
|
||||||
|
import type {
|
||||||
|
InitiateResponse,
|
||||||
|
PaymentMethod,
|
||||||
|
PaymentPlatform,
|
||||||
|
} from "./payments.service";
|
||||||
|
|
||||||
const W = URL_CONSTANTS.WAREHOUSE_INVOICES;
|
const W = URL_CONSTANTS.WAREHOUSE_INVOICES;
|
||||||
|
|
||||||
@@ -51,4 +56,27 @@ export const warehouseInvoicesService = {
|
|||||||
const { data } = await client.get(W.RECEIPT(id), { responseType: "blob" });
|
const { data } = await client.get(W.RECEIPT(id), { responseType: "blob" });
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate a Telebirr/Waafi online payment for a warehouse demurrage/storage
|
||||||
|
* invoice. Returns the payment intent + `clientAction` to redirect the browser
|
||||||
|
* to the provider (mirrors the booking `/pay` flow). The backoffice cash
|
||||||
|
* `/pay` (record-a-payment) path is unaffected.
|
||||||
|
*/
|
||||||
|
payOnline: async (
|
||||||
|
id: string,
|
||||||
|
payload: {
|
||||||
|
method: PaymentMethod;
|
||||||
|
platform?: PaymentPlatform;
|
||||||
|
payerAccount?: string;
|
||||||
|
returnUrl?: string;
|
||||||
|
failureUrl?: string;
|
||||||
|
},
|
||||||
|
): Promise<InitiateResponse> => {
|
||||||
|
const { data } = await client.post(W.PAY_ONLINE(id), {
|
||||||
|
platform: "web",
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user