From bf8eadbc85b05c78c99c0ae14fdc7f3058d960e4 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 10 Aug 2026 12:00:08 +0000 Subject: [PATCH 001/174] fix: portal cannot load last-mile confirmation request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LAST_MILE_REQUESTS URL constants were missing the /api prefix every other endpoint in URLS.ts carries — all five calls (get, submit, contract view/document/sign) 404'd against the deployed API, so the departure notification's confirm link never loaded for the customer. Also widen GET /last-mile-requests/:id from @BookingStaff to @MixedAudience with the same ownership check submit()/sign() already use — the confirm page calls this as its first request, before the customer has done anything else, so it can't be staff-only. Co-Authored-By: Claude Opus 5 (1M context) --- .../last-mile-requests.controller.ts | 10 +++++++--- .../last-mile-requests.service.ts | 16 +++++++++++++++- .../edr-freight-web/portal/src/constants/URLS.ts | 10 +++++----- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts index c6910c7a7..3d9fa4254 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts @@ -91,11 +91,15 @@ export class LastMileRequestsController { return this.contractService.sign(id, dto, user?.id ?? null); } + // Customer-facing like :id/contract/view — the portal's confirm page opens + // this straight from the departure notification link before the customer + // has done anything else, so it can't be staff-only. Service ownership- + // checks against the resolved company; staff may also open it. @Get(':id') - @BookingStaff(FREIGHT_PERMS.lastMile.requestView) + @MixedAudience(FREIGHT_PERMS.lastMile.requestView) @ApiOperation({ summary: 'Get a last-mile confirmation request by ID' }) - findOne(@Param('id', ParseUUIDPipe) id: string) { - return this.requestsService.findById(id); + findOne(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { + return this.requestsService.findById(id, user?.id ?? null); } // No @BookingStaff — the customer (portal) fills this, not backoffice staff. diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts index b89f06479..ebdc93746 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -199,11 +199,25 @@ export class LastMileRequestsService { }; } - async findById(id: string): Promise { + /** + * `userId` is set only when a portal customer calls this directly (the + * confirm-page deep link from the departure notification, before they've + * submitted or signed anything) — staff and every internal caller pass + * nothing and skip the check, same convention as submit()/sign(). + */ + async findById(id: string, userId?: string | null): Promise { const record = await this.requestsRepository.findById(id, { relations: { booking: { company: true } }, }); if (!record) throw new NotFoundException(`Last-mile request ${id} not found`); + + if (userId) { + const companyId = await this.bookingsService.resolveCustomerCompanyId(userId); + if (companyId && record.booking?.companyId && companyId !== record.booking.companyId) { + throw new BadRequestException('This request does not belong to your company'); + } + } + return record; } diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 153ed5e8b..a0d33f5ba 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -224,10 +224,10 @@ export const URL_CONSTANTS = { }, LAST_MILE_REQUESTS: { - BY_ID: (id: string) => `/last-mile-requests/${id}`, - SUBMIT: (id: string) => `/last-mile-requests/${id}/submit`, - CONTRACT_VIEW: (id: string) => `/last-mile-requests/${id}/contract/view`, - CONTRACT_DOCUMENT: (id: string) => `/last-mile-requests/${id}/contract/document`, - CONTRACT_SIGN: (id: string) => `/last-mile-requests/${id}/contract/sign`, + BY_ID: (id: string) => `/api/last-mile-requests/${id}`, + SUBMIT: (id: string) => `/api/last-mile-requests/${id}/submit`, + CONTRACT_VIEW: (id: string) => `/api/last-mile-requests/${id}/contract/view`, + CONTRACT_DOCUMENT: (id: string) => `/api/last-mile-requests/${id}/contract/document`, + CONTRACT_SIGN: (id: string) => `/api/last-mile-requests/${id}/contract/sign`, }, }; From 45715d68381fac5a6f841f40c110dba31a815461 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 10 Aug 2026 12:02:19 +0000 Subject: [PATCH 002/174] fix: default DataTable status to success so rows render status was required with no default; omitting it left the body blank while the footer (which renders regardless of status) still showed "Showing 1-N of N". Rows to draw is the common case, so default to success instead of requiring every caller to pass it explicitly. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ui-common/src/components/data-table/table.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ui-common/src/components/data-table/table.tsx b/packages/ui-common/src/components/data-table/table.tsx index 957da3300..3dc2d5786 100644 --- a/packages/ui-common/src/components/data-table/table.tsx +++ b/packages/ui-common/src/components/data-table/table.tsx @@ -13,7 +13,10 @@ import { DataTableFooter } from "./footer"; export function DataTable({ columns, data, - status, + // The body only renders under "success", but the footer renders regardless — + // omitting status gave a blank table under a populated "Showing 1–N of N" + // footer. Having rows to draw is the default case, so default to success. + status = "success", onRowClick, rowStyle, rowClassName, From 1b4a317d927aa6c01527560f9e2bb0f83f428b7c Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 10 Aug 2026 12:02:31 +0000 Subject: [PATCH 003/174] docs: trim eims.types.ts file header comment Co-Authored-By: Claude Opus 5 (1M context) --- apps/edr-freight-api/src/modules/eims/eims.types.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/eims/eims.types.ts b/apps/edr-freight-api/src/modules/eims/eims.types.ts index 6e74604bd..8cea86619 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.types.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.types.ts @@ -1,6 +1,5 @@ /** - * Wire types for the MoR EIMS gateway, taken from the supplied Postman collection. - * + * Wire types for the MoR EIMS gateway, * Every protected payload is the same envelope: the business object under `request`, a base64 * RSA-SHA512 signature over the *inner* object only, and the base64 certificate bundle. */ From b701ae3f470b669f149702e1175cf0e82378df85 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 10 Aug 2026 12:05:04 +0000 Subject: [PATCH 004/174] Revert "docs: trim eims.types.ts file header comment" This reverts commit f4b8dab58d921791b4b3f69e3c9952b6802835cc. --- apps/edr-freight-api/src/modules/eims/eims.types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/eims/eims.types.ts b/apps/edr-freight-api/src/modules/eims/eims.types.ts index 8cea86619..6e74604bd 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.types.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.types.ts @@ -1,5 +1,6 @@ /** - * Wire types for the MoR EIMS gateway, + * Wire types for the MoR EIMS gateway, taken from the supplied Postman collection. + * * Every protected payload is the same envelope: the business object under `request`, a base64 * RSA-SHA512 signature over the *inner* object only, and the base64 certificate bundle. */ From c24c93bcf347ac2840ba396344ab7f4a1f5365af Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 10 Aug 2026 13:56:25 +0000 Subject: [PATCH 005/174] feat: add 11 digit vat --- .../modules/companies/dto/update-profile.dto.ts | 10 +++++----- .../accounts/companyProfileForm/schema.test.ts | 14 +++++++++++++- .../pages/accounts/companyProfileForm/schema.ts | 2 +- .../companyProfileForm/steps/CompanyInfoStep.tsx | 4 ++-- .../src/pages/settings/TabCompanyProfile.tsx | 4 ++-- 5 files changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 214c3858a..c89326fa1 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -36,13 +36,13 @@ export class UpdateProfileDto { @IsTin({ message: "TIN must be exactly 10 digits" }) tin?: string; - // Ethiopian VAT registration numbers are 10 digits, the same shape as the - // TIN. Both portal forms enforce that; without it here the API happily stored - // whatever a stale client sent, and the two layers disagreed about what the - // column may hold. + // Ethiopian VAT registration numbers are 10 digits (the same shape as the + // TIN), but some are issued with an 11th. Both portal forms enforce the same + // range; without it here the API happily stored whatever a stale client sent, + // and the two layers disagreed about what the column may hold. @IsOptional() @IsString() - @Matches(/^\d{10}$/, { message: "VAT number must be exactly 10 digits" }) + @Matches(/^\d{10,11}$/, { message: "VAT number must be 10 or 11 digits" }) vatNumber?: string; // `fanNumber` is deliberately absent: the FAN is the Fayda number of the diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts index f38d54432..0d357f691 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts @@ -59,10 +59,22 @@ describe("VAT number", () => { ).toBeUndefined(); }); + it("accepts eleven digits", () => { + expect( + errorFor(values({ vatNumber: "00123456789" }), "vatNumber"), + ).toBeUndefined(); + }); + + it("rejects twelve digits", () => { + expect(errorFor(values({ vatNumber: "001234567890" }), "vatNumber")).toBe( + "VAT number must be 10 or 11 digits", + ); + }); + // `.length(10)` used to pass this, so a ten-letter string reached the API. it("rejects ten non-digits", () => { expect(errorFor(values({ vatNumber: "ABCDEFGHIJ" }), "vatNumber")).toBe( - "VAT number must be exactly 10 digits", + "VAT number must be 10 or 11 digits", ); }); diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts index 37f92e05e..db48667db 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts @@ -25,7 +25,7 @@ export const onboardingSchema = z.object({ vatNumber: z .string() .min(1, "VAT number is required") - .regex(/^\d{10}$/, "VAT number must be exactly 10 digits"), + .regex(/^\d{10,11}$/, "VAT number must be 10 or 11 digits"), // The owner's passport number — the foreign-company identity credential // (Fayda is an Ethiopian national ID). Required only for a foreign company; // enforced in buildOnboardingSchema since that depends on `nationality`. diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx index 3f37506a4..978377151 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx @@ -51,7 +51,7 @@ export default function CompanyInfoStep({ index={1} title="VAT number" status={ - watch("vatNumber")?.length === 10 && !errors.vatNumber + (watch("vatNumber")?.length ?? 0) >= 10 && !errors.vatNumber ? "done" : "todo" } @@ -59,7 +59,7 @@ export default function CompanyInfoStep({ diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index 6a315392c..deaec110f 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -46,7 +46,7 @@ export const COMPANY_PROFILE_SCHEMA = z.object({ vatNumber: z .string() .min(1, "VAT number is required") - .regex(/^\d{10}$/, "VAT number must be exactly 10 digits"), + .regex(/^\d{10,11}$/, "VAT number must be 10 or 11 digits"), ownerPassportNumber: z.string().optional(), // Registration/address fields are eTrade-sourced — locked once eTrade // supplies a value, editable only as an escape hatch when it doesn't @@ -332,7 +332,7 @@ export default function TabCompanyProfile({ From e29e3ff8371b692f36e634702b7f0750c49b6c94 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 10 Aug 2026 14:02:13 +0000 Subject: [PATCH 006/174] fix(freight-backoffice): render wagon transfer reason as sanitized HTML in a modal --- .../modules/trains/train-builder.service.ts | 16 ++++- .../src/pages/fleet/FleetResourcePage.tsx | 18 +++++- .../src/pages/fleet/config/resources.ts | 9 ++- .../src/pages/wagons/TransferHistoryPanel.tsx | 5 +- .../src/pages/wagons/WagonTransfersPage.tsx | 61 +++++++++++++++++-- .../src/pages/wagons/wagon-transfer-ui.tsx | 10 +++ 6 files changed, 108 insertions(+), 11 deletions(-) diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index fbb5301fb..9da3cecf0 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -510,6 +510,8 @@ export class TrainBuilderService { trainId: null, sequenceNumber: null, status: WagonStatus.Available, + importTrainNumber: null, + exportTrainNumber: null, }); await this.resequenceWagons(manager, train.id); await this.syncLiveScheduleAfterConsistChange( @@ -544,6 +546,8 @@ export class TrainBuilderService { trainId: null, sequenceNumber: null, status: WagonStatus.Maintenance, + importTrainNumber: null, + exportTrainNumber: null, }); // Audit row: which train it came off and when. The wagon does not change // yard here, so from/to are the same — the ledger is the wagon's history @@ -761,7 +765,13 @@ export class TrainBuilderService { .getRepository(Wagon) .update( { trainId: train.id }, - { trainId: null, sequenceNumber: null, status: WagonStatus.Available }, + { + trainId: null, + sequenceNumber: null, + status: WagonStatus.Available, + importTrainNumber: null, + exportTrainNumber: null, + }, ); await manager.getRepository(TrainLocomotive).delete({ trainId: train.id }); await manager.getRepository(Train).remove(train); @@ -1021,6 +1031,10 @@ export class TrainBuilderService { trainId: train.id, sequenceNumber: sequence, status: WagonStatus.Assigned, + // Wagon inherits the train's run numbers on coupling — no per-wagon + // number entry, they ride whatever numbers the train was built with. + importTrainNumber: train.importTrainNumber, + exportTrainNumber: train.exportTrainNumber, }); } return toAttach; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 6bea10a8c..06d9a20ab 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -102,6 +102,7 @@ const FleetResourcePage = () => { const currentYardId = listFilterValues.currentYardId; const availability = listFilterValues.availability; const trainNumber = listFilterValues.trainNumber; + const trainId = listFilterValues.trainId; if (status && status !== "ALL") { (filters as { status?: string }).status = status; } @@ -114,6 +115,9 @@ const FleetResourcePage = () => { if (trainNumber && trainNumber !== "ALL") { (filters as { trainNumber?: string }).trainNumber = trainNumber; } + if (trainId && trainId !== "ALL") { + filters.trainId = trainId; + } // Wagons only: narrow the fleet to one wagon type (the API filters on it). const wagonTypeId = listFilterValues.wagonTypeId; if (wagonTypeId && wagonTypeId !== "ALL") { @@ -191,6 +195,11 @@ const FleetResourcePage = () => { const { data: drivers = [] } = useQuery( api.fleet.list.queryOptions({ input: { slug: "drivers" } }), ); + // Wagons-only: "Train" list filter needs every train's code to pick from. + const { data: trains = [], isLoading: trainsLoading } = useQuery({ + ...api.trains.list.queryOptions(), + enabled: slug === "wagons", + }); useEffect(() => { setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); @@ -247,6 +256,9 @@ const FleetResourcePage = () => { const yardOpts = (yards as Array<{ id: string; label?: string; code?: string }>).map( (y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }), ); + const trainOpts = (trains as Array<{ id: string; code: string; trainName?: string | null }>).map( + (t) => ({ value: t.id, label: t.trainName ? `${t.code} - ${t.trainName}` : t.code }), + ); // Carries capacity + trailer configuration so picking a truck type can // pre-fill the vehicle's capacity and drop the trailer plate on a rigid type. @@ -274,8 +286,9 @@ const FleetResourcePage = () => { wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts], containers: containerOpts, yards: yardOpts, + trains: trainOpts, }; - }, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards]); + }, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards, trains]); const listFilterSelects = useMemo(() => { if (!config?.listFilters?.length) return null; @@ -327,7 +340,8 @@ const FleetResourcePage = () => { truckTypesLoading || wagonsLoading || containersLoading || - yardsLoading; + yardsLoading || + trainsLoading; const filteredRows = useMemo(() => { if (!config) return allRows; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 9ba826e6f..11ca236be 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -33,7 +33,8 @@ export type FleetDynamicOptions = | "truckTypes" | "wagons" | "containers" - | "yards"; + | "yards" + | "trains"; /** * A dynamic select option that can carry the record it came from. Picking a @@ -324,6 +325,12 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ allLabel: "All trains", options: TRAIN_RUN_FILTER_OPTIONS, }, + { + key: "trainId", + label: "Train", + allLabel: "All trains", + dynamicOptions: "trains", + }, ], cardTitleKey: "wagonNumber", cardSubtitleKey: "currentYard", diff --git a/apps/edr-freight-web/backoffice/src/pages/wagons/TransferHistoryPanel.tsx b/apps/edr-freight-web/backoffice/src/pages/wagons/TransferHistoryPanel.tsx index dff9cdaa3..807745b73 100644 --- a/apps/edr-freight-web/backoffice/src/pages/wagons/TransferHistoryPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/wagons/TransferHistoryPanel.tsx @@ -35,6 +35,7 @@ import { STATUS_META, TransferProgress, TransferStatusBadge, + stripHtmlToText, wagonTypeLabel, yardLabel, } from "./wagon-transfer-ui"; @@ -119,9 +120,9 @@ function RequestItem({ request }: { request: WagonTransferRequest }) { {wagonTypeLabel(request.wagonType)} - {request.reason ? ( + {stripHtmlToText(request.reason) ? ( - {request.reason} + {stripHtmlToText(request.reason)} ) : null} diff --git a/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx index 95705e964..1a08b5c54 100644 --- a/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx @@ -10,6 +10,7 @@ import { Tabs, Text, TextInput, + UnstyledButton, } from "@mantine/core"; import { useDebouncedValue } from "@mantine/hooks"; import { useQuery } from "@tanstack/react-query"; @@ -31,6 +32,7 @@ import { useMutation } from "@tanstack/react-query"; import { useAuth } from "@/auth/useAuth"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { sanitizeHtml } from "@/shared/lib/sanitize"; import { api } from "@/services/api"; import type { TransferRequestListFilter, @@ -55,6 +57,7 @@ import { fmtDateTime, isOpenRequest, outstandingOn, + stripHtmlToText, wagonTypeLabel, yardLabel, } from "./wagon-transfer-ui"; @@ -108,6 +111,9 @@ export default function WagonTransfersPage() { const [closingShort, setClosingShort] = useState( null, ); + const [viewingReason, setViewingReason] = useState( + null, + ); const filter: TransferRequestListFilter = useMemo( () => ({ @@ -197,11 +203,29 @@ export default function WagonTransfersPage() { { id: "reason", header: () => Reason, - cell: ({ row }) => ( - - {row.original.reason || "—"} - - ), + cell: ({ row }) => { + const text = stripHtmlToText(row.original.reason); + return text ? ( + setViewingReason(row.original)} + data-stop-row-click + > + + {text} + + + ) : ( + + — + + ); + }, }, { id: "filed", @@ -522,6 +546,33 @@ export default function WagonTransfersPage() { )} + setViewingReason(null)} + radius="md" + title="Reason" + > + {!viewingReason ? null : ( + + + {yardLabel(viewingReason.fromYard)}{" "} + {" "} + {yardLabel(viewingReason.toYard)} ·{" "} + {wagonTypeLabel(viewingReason.wagonType)} ·{" "} + {viewingReason.quantity} wagon(s) + + + + )} + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/wagons/wagon-transfer-ui.tsx b/apps/edr-freight-web/backoffice/src/pages/wagons/wagon-transfer-ui.tsx index e60480590..ea73a95d2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/wagons/wagon-transfer-ui.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/wagons/wagon-transfer-ui.tsx @@ -3,6 +3,16 @@ import { Badge, Box, Group, Progress, Text, Tooltip } from "@mantine/core"; import type { WagonTransferRequest } from "@/services/wagon.service"; +/** Reason/note fields come from a rich-text editor and store HTML — this + * gives a plain-text preview for list/table contexts (full formatting is + * shown via `sanitizeHtml` + `dangerouslySetInnerHTML` where there's room). */ +export const stripHtmlToText = (html?: string | null): string => + (html ?? "") + .replace(/<[^>]*>/g, " ") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim(); + export const yardLabel = (y?: { label?: string; code?: string } | null) => y?.label || y?.code || "—"; From 615f7e679818d288c3a89208a1e81e61ea1d3f42 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 10 Aug 2026 14:49:17 +0000 Subject: [PATCH 007/174] feat: add choice to etrade fetcher --- .../modules/companies/companies.controller.ts | 1 + .../modules/companies/companies.service.ts | 22 ++- .../companies/dto/etrade-response.dto.ts | 4 +- .../modules/companies/dto/fetch-etrade.dto.ts | 12 +- .../etrade-business-selection.spec.ts | 87 +++++++++ .../companies/services/etrade.service.ts | 29 ++- .../src/modules/payment/payments.dto.ts | 11 +- .../src/components/onboarding/ETradeInfo.tsx | 184 ++++++++++++++++-- .../portal/src/hooks/useETradeData.ts | 11 +- .../src/pages/accounts/CompanyProfileForm.tsx | 4 +- .../companyProfileForm/ETradeCompanyCard.tsx | 126 ++---------- .../accounts/companyProfileForm/helpers.ts | 7 +- .../companyProfileForm/schema.test.ts | 23 ++- .../accounts/companyProfileForm/schema.ts | 46 ++--- .../steps/CompanyInfoStep.tsx | 10 +- .../src/pages/settings/TabCompanyProfile.tsx | 168 +++------------- .../portal/src/services/companies.service.ts | 5 +- packages/types/src/freight/etrade.ts | 23 ++- 18 files changed, 443 insertions(+), 330 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 78f8db410..de7a01484 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -210,6 +210,7 @@ export class CompaniesController { const data = await this.companiesService.fetchETradeData( dto.tin, companyId, + dto.licenceNumber, ); return new ETradeResponseDto(data); } diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 0ed91c9e3..5dd55e704 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -3541,9 +3541,10 @@ export class CompaniesService { /** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */ private async resolveEtradeRegistration( tin: string, + licenceNumber?: string, ): Promise { const { businessInfo, companyInfo } = - await this.etradeService.resolveCompanyData(tin); + await this.etradeService.resolveCompanyData(tin, licenceNumber); if (!businessInfo) { throw new BadRequestException( "We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.", @@ -3555,8 +3556,15 @@ export class CompaniesService { ); } - async fetchETradeData(tin: string, excludeCompanyId?: string) { - const registrationData = await this.resolveEtradeRegistration(tin); + async fetchETradeData( + tin: string, + excludeCompanyId?: string, + licenceNumber?: string, + ) { + const registrationData = await this.resolveEtradeRegistration( + tin, + licenceNumber, + ); const tinTaken = await this.companiesRepo.existsByTin( tin, excludeCompanyId, @@ -3583,7 +3591,13 @@ export class CompaniesService { if (!touched) return; const tin = dto.tin ?? company.tin; - const registration = await this.resolveEtradeRegistration(tin); + // Re-verify the licence the customer actually chose. Without it a TIN + // holding several licences would silently snap back to eTrade's first one on + // every save, overwriting the selection with a different business's record. + const registration = await this.resolveEtradeRegistration( + tin, + dto.licenceNumber ?? company.licenceNumber ?? undefined, + ); const fresh: Partial< Record<(typeof ETRADE_SOURCED_FIELDS)[number], string> > = { diff --git a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts index bfe1f9b72..40b1f308e 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts @@ -1,4 +1,4 @@ -import { CompanyRegistrationData } from "@edr/types"; +import { CompanyRegistrationData, ETradeBusinessOption } from "@edr/types"; export class ETradeResponseDto implements CompanyRegistrationData { companyName!: string; @@ -19,6 +19,7 @@ export class ETradeResponseDto implements CompanyRegistrationData { managerEmail?: string; managerPhone!: string; tinTaken?: boolean; + businesses?: ETradeBusinessOption[]; constructor(data: CompanyRegistrationData) { this.companyName = data.companyName; @@ -39,5 +40,6 @@ export class ETradeResponseDto implements CompanyRegistrationData { this.managerEmail = data.managerEmail; this.managerPhone = data.managerPhone; this.tinTaken = data.tinTaken; + this.businesses = data.businesses; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts index 466c03ed6..9ca533835 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsNotEmpty } from "class-validator"; +import { IsString, IsNotEmpty, IsOptional, MaxLength } from "class-validator"; import { IsTin } from "../../../common/validators/is-tin.validator"; export class FetchETradeDto { @@ -6,4 +6,14 @@ export class FetchETradeDto { @IsNotEmpty() @IsTin({ message: "TIN must be exactly 10 digits" }) tin!: string; + + /** + * Which of the TIN's business licences to resolve. Omitted on the first + * lookup — the response lists them all so the customer can pick, and the pick + * comes back here. + */ + @IsOptional() + @IsString() + @MaxLength(100) + licenceNumber?: string; } diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts b/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts new file mode 100644 index 000000000..0719d7fe8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts @@ -0,0 +1,87 @@ +import { ETradeService } from './etrade.service'; +import type { ETradeBusinessInfo, ETradeCompanyInfo } from '@edr/types'; + +/** + * A TIN routinely holds several business licences (import of vehicles, export of + * coffee, freight forwarding…), all under the same trade name. The customer + * picks one, and every later lookup has to resolve that same licence — snapping + * back to eTrade's first would silently swap their company record. + */ +const companyInfo = (): ETradeCompanyInfo => + ({ + Tin: '0045014036', + BusinessName: 'PAVE LOGISTICS AND TRADING P L C', + Businesses: [ + { + LicenceNumber: 'MT/AA/14/670/11551235/2017', + TradesName: 'PAVE LOGISTICS AND TRADING P L C', + RenewedTo: '7/7/2026', + SubGroups: [ + { Code: 66331, Description: 'Export trade in minerals' }, + ], + }, + { + LicenceNumber: 'MT/AA/14/670/128936/2007', + TradesName: 'PAVE LOGISTICS AND TRADING P L C', + RenewedTo: '7/7/2026', + SubGroups: [{ Code: 72131, Description: '(72131)Freight Forwarders' }], + }, + ], + }) as unknown as ETradeCompanyInfo; + +describe('ETradeService business selection', () => { + const build = () => { + const service = new ETradeService({} as never); + const fetched: string[] = []; + jest + .spyOn(service, 'getCompanyInfoByTin') + .mockResolvedValue(companyInfo()); + jest + .spyOn(service, 'getBusinessByLicenseNo') + .mockImplementation(async (licenceNo: string) => { + fetched.push(licenceNo); + return { LicenceNumber: licenceNo } as ETradeBusinessInfo; + }); + return { service, fetched }; + }; + + it('defaults to the first licence when none is chosen', async () => { + const { service, fetched } = build(); + await service.resolveCompanyData('0045014036'); + expect(fetched).toEqual(['MT/AA/14/670/11551235/2017']); + }); + + it('resolves the chosen licence', async () => { + const { service, fetched } = build(); + await service.resolveCompanyData('0045014036', 'MT/AA/14/670/128936/2007'); + expect(fetched).toEqual(['MT/AA/14/670/128936/2007']); + }); + + it('falls back to the first licence when the chosen one is gone', async () => { + const { service, fetched } = build(); + await service.resolveCompanyData('0045014036', 'NO/SUCH/LICENCE'); + expect(fetched).toEqual(['MT/AA/14/670/11551235/2017']); + }); + + it('lists every licence for the picker, code prefixes stripped', () => { + const { service } = build(); + const data = service.extractRegistrationData( + { LicenceNumber: 'x' } as ETradeBusinessInfo, + companyInfo(), + ); + expect(data.businesses).toEqual([ + { + licenceNumber: 'MT/AA/14/670/11551235/2017', + tradeName: 'PAVE LOGISTICS AND TRADING P L C', + activity: 'Export trade in minerals', + renewedTo: '7/7/2026', + }, + { + licenceNumber: 'MT/AA/14/670/128936/2007', + tradeName: 'PAVE LOGISTICS AND TRADING P L C', + activity: 'Freight Forwarders', + renewedTo: '7/7/2026', + }, + ]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts index 3292ee2d3..45cc8e840 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -66,7 +66,15 @@ export class ETradeService { } } - async resolveCompanyData(tin: string): Promise<{ + /** + * @param licenceNumber which of the TIN's licences to resolve. Defaults to the + * first one — a TIN with several licences is only unambiguous once the + * customer has picked one (see {@link ETradeBusinessOption}). + */ + async resolveCompanyData( + tin: string, + licenceNumber?: string, + ): Promise<{ companyInfo: ETradeCompanyInfo; businessInfo: ETradeBusinessInfo | null; }> { @@ -76,10 +84,15 @@ export class ETradeService { return { companyInfo, businessInfo: null }; } - const latestBusiness = companyInfo.Businesses[0]; + // An unknown licence falls back to the first rather than 400-ing: eTrade can + // drop or renumber a licence between the customer picking it and the save + // that re-verifies it, and that must not lock them out of their own profile. + const selected = + companyInfo.Businesses.find((b) => b.LicenceNumber === licenceNumber) ?? + companyInfo.Businesses[0]; try { const businessInfo = await this.getBusinessByLicenseNo( - latestBusiness.LicenceNumber, + selected.LicenceNumber, tin, ); return { companyInfo, businessInfo }; @@ -124,6 +137,16 @@ export class ETradeService { regularPhone: businessInfo.AddressInfo?.RegularPhone || "", managerName: primaryManager?.ManagerNameEng || "", managerPhone: primaryManager?.RegularPhone || "", + businesses: (companyInfo?.Businesses ?? []).map((b) => ({ + licenceNumber: b.LicenceNumber, + tradeName: b.TradesName?.trim() || "", + activity: (b.SubGroups ?? []) + // Some descriptions repeat the code inline ("(65611)Import trade …"). + .map((g) => g.Description?.replace(/^\(\d+\)\s*/, "").trim()) + .filter(Boolean) + .join(", "), + renewedTo: b.RenewedTo || "", + })), }; } } diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts index ee7b703d2..92ff7dc38 100644 --- a/apps/edr-freight-api/src/modules/payment/payments.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -69,6 +69,7 @@ export class ClientActionDto { "LAUNCH_APP", "INVOKE_BRIDGE", "COLLECT_OTP", + "AWAIT_PUSH", "SHOW_BILL_REFERENCE", ], }) @@ -77,6 +78,7 @@ export class ClientActionDto { | "LAUNCH_APP" | "INVOKE_BRIDGE" | "COLLECT_OTP" + | "AWAIT_PUSH" | "SHOW_BILL_REFERENCE"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) @@ -104,9 +106,16 @@ export class ClientActionDto { @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" }) providerOrderId?: string; - @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" }) + @ApiPropertyOptional({ + description: "Set when type=COLLECT_OTP or type=AWAIT_PUSH", + }) message?: string; + @ApiPropertyOptional({ + description: "Set when type=AWAIT_PUSH (masked MSISDN the push prompt went to)", + }) + payerAccountMasked?: string; + @ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)", }) diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx index 74e267a30..56273351d 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -1,7 +1,17 @@ -import { Alert, Button, Loader, Stack, TextInput } from "@mantine/core"; -import { useEffect, useRef } from "react"; +import { + Alert, + Button, + Card, + Group, + Loader, + Radio, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useEffect, useRef, useState } from "react"; import type { UseFormRegisterReturn } from "react-hook-form"; -import { AlertCircle, Download } from "lucide-react"; +import { AlertCircle, Building2, Download } from "lucide-react"; import { useETradeData } from "@/hooks/useETradeData"; import { extractApiError } from "@/utils/result"; import type { CompanyRegistrationData } from "@edr/types"; @@ -12,6 +22,8 @@ export type ETradeStatus = | "verified" | "not-found" | "taken" + /** eTrade returned several business licences; the customer must pick one. */ + | "choose-business" | "error"; interface ETradeInfoProps { @@ -36,6 +48,12 @@ interface ETradeInfoProps { * stays available for a deliberate re-verify. */ alreadyVerified?: boolean; + /** + * The licence this company already operates under, if any. Pre-selects it in + * the picker so a deliberate re-verify refreshes that same business rather + * than silently snapping to eTrade's first one. + */ + selectedLicenceNumber?: string; } // Digits, not just length: a 10-character non-numeric TIN used to fire a lookup @@ -51,6 +69,7 @@ export default function ETradeInfo({ onStatusChange, onReset, alreadyVerified, + selectedLicenceNumber, }: ETradeInfoProps) { const mutation = useETradeData(); const isLoading = mutation.isPending; @@ -61,14 +80,39 @@ export default function ETradeInfo({ // user has typed a different TIN and overwrite its fields with stale data. const requestIdRef = useRef(0); - const handleFetch = async () => { + // Which of the TIN's licences the customer operates as. A ref alongside the + // state because handleFetch is called from an effect that doesn't re-run on + // this value. + const [licence, setLicence] = useState( + selectedLicenceNumber || null, + ); + const licenceRef = useRef(licence); + licenceRef.current = licence; + + const handleFetch = async (chosen = licenceRef.current) => { if (!isValidTin(tin)) return; const requestId = ++requestIdRef.current; - const result = await mutation.mutateAsync(tin); + const result = await mutation.mutateAsync({ + tin, + licenceNumber: chosen ?? undefined, + }); if (requestIdRef.current !== requestId) return; - if (result && !result.tinTaken) { - onDataLoaded(result); - } + if (!result || result.tinTaken) return; + // Several licences and no pick yet: the registration data describes only + // eTrade's first one, so it must not be adopted as this company's record + // until the customer says which business they're acting as. + if (!chosen && (result.businesses?.length ?? 0) > 1) return; + onDataLoaded(result); + }; + + // The picker collapses to a one-line summary + "Change" once a business is + // settled on; it only stays open while the choice is still outstanding. + const [pickerOpen, setPickerOpen] = useState(false); + + const handleChooseBusiness = (value: string) => { + setLicence(value); + setPickerOpen(false); + handleFetch(value); }; // Auto-fetch as soon as the TIN reaches its full 10-digit length — only @@ -87,8 +131,12 @@ export default function ETradeInfo({ if (tin !== lastFetchedTin.current) { // TIN moved away from whatever we last fetched — that result (verified // data, "taken", or an error) no longer describes this TIN. Drop it so - // the UI doesn't keep showing the previous TIN's outcome. + // the UI doesn't keep showing the previous TIN's outcome. The licence pick + // belongs to the old TIN too, so it goes with it (via the ref as well, so + // the fetch below doesn't reuse it before the state lands). requestIdRef.current++; + licenceRef.current = null; + setLicence(null); if (mutation.data || mutation.error) { mutation.reset(); onReset?.(); @@ -116,17 +164,28 @@ export default function ETradeInfo({ : apiError.message : null; + const businesses = mutation.data?.businesses ?? []; + const chosenBusiness = businesses.find((b) => b.licenceNumber === licence); + // More than one licence and none of them picked: the lookup succeeded but + // this company's record is still undecided, so it must not read as verified. + // Matched against the list rather than `licence` alone — a saved licence + // eTrade no longer lists is not a choice among what it offers today. + const needsChoice = businesses.length > 1 && !chosenBusiness; + const showPicker = needsChoice || pickerOpen; + const status: ETradeStatus = isLoading ? "loading" : tinTaken ? "taken" - : mutation.isSuccess && mutation.data && !mutation.data.tinTaken - ? "verified" - : notFound - ? "not-found" - : errorMessage - ? "error" - : "idle"; + : needsChoice + ? "choose-business" + : mutation.isSuccess && mutation.data && !mutation.data.tinTaken + ? "verified" + : notFound + ? "not-found" + : errorMessage + ? "error" + : "idle"; const lastReportedStatus = useRef(null); useEffect(() => { @@ -142,7 +201,11 @@ export default function ETradeInfo({ const willAutoFetch = isValidTin(tin) && lastFetchedTin.current !== tin; const showLoading = isLoading || willAutoFetch; - const showRetry = isValidTin(tin) && status !== "verified" && !showLoading; + const showRetry = + isValidTin(tin) && + status !== "verified" && + status !== "choose-business" && + !showLoading; return ( @@ -170,7 +233,7 @@ export default function ETradeInfo({ className="max-w-none" variant="filled" color="edr-green" - onClick={handleFetch} + onClick={() => handleFetch()} disabled={!isValidTin(tin)} leftSection={} > @@ -179,6 +242,91 @@ export default function ETradeInfo({ )} + {businesses.length > 1 && !showPicker && chosenBusiness && ( + + + + + + + {chosenBusiness.activity || + chosenBusiness.tradeName || + chosenBusiness.licenceNumber} + + + + {chosenBusiness.licenceNumber} + + {chosenBusiness.renewedTo && ( + + · valid to {chosenBusiness.renewedTo} + + )} + + + + + + + )} + + {businesses.length > 1 && showPicker && ( + + } + color={needsChoice ? "yellow" : "blue"} + title={ + needsChoice + ? `This TIN holds ${businesses.length} business licences` + : "Change business" + } + > + Pick the business you're registering as — its licence and registered + address become this account's record. + + + + {businesses.map((b) => ( + + + + {b.activity || b.tradeName || b.licenceNumber} + + + + {b.licenceNumber} + + {b.renewedTo && ( + + · valid to {b.renewedTo} + + )} + + + } + /> + + ))} + + + + )} + {notFound && ( } diff --git a/apps/edr-freight-web/portal/src/hooks/useETradeData.ts b/apps/edr-freight-web/portal/src/hooks/useETradeData.ts index 9fbce53d9..e764ecad5 100644 --- a/apps/edr-freight-web/portal/src/hooks/useETradeData.ts +++ b/apps/edr-freight-web/portal/src/hooks/useETradeData.ts @@ -3,10 +3,17 @@ import { companiesService } from "@/services/companies.service"; import { extractApiError } from "@/utils/result"; import type { CompanyRegistrationData } from "@edr/types"; +/** + * `licenceNumber` picks which of the TIN's business licences to resolve — a TIN + * routinely holds several, and the customer says which one they operate as. + */ export function useETradeData() { return useMutation({ - mutationFn: async (tin: string): Promise => { - return companiesService.fetchETradeInfo({ tin }); + mutationFn: async (vars: { + tin: string; + licenceNumber?: string; + }): Promise => { + return companiesService.fetchETradeInfo(vars); }, onError: (error) => { const { message } = extractApiError(error); diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 21956c9a1..92e1a7118 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -989,7 +989,9 @@ export default function CompanyProfileForm({ } if (step === "company" && !tinVerified) { setSaveError( - "We need to confirm your TIN with eTrade before continuing.", + tinStatus === "choose-business" + ? "This TIN holds more than one business licence — pick the one you're registering as." + : "We need to confirm your TIN with eTrade before continuing.", ); return; } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ETradeCompanyCard.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ETradeCompanyCard.tsx index 2634fed0d..4469009ed 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ETradeCompanyCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ETradeCompanyCard.tsx @@ -1,70 +1,27 @@ -import { Badge, Card, Group, Select, SimpleGrid, Text, TextInput } from "@mantine/core"; +import { Badge, Card, Group, SimpleGrid, Text } from "@mantine/core"; import { CheckCircle2 } from "lucide-react"; -import { Controller } from "react-hook-form"; -import type { - Control, - FieldErrors, - UseFormRegister, - UseFormWatch, -} from "react-hook-form"; -import { ETHIOPIAN_REGIONS } from "@edr/types"; +import type { UseFormWatch } from "react-hook-form"; import type { FormData } from "./schema"; import { ReadOnlyField } from "./ReadOnlyField"; /** - * One field of the verified-registration card: locked read-only once eTrade - * supplied a value, but falls back to an editable input when eTrade left it - * blank — otherwise a gap in eTrade's own data would leave the field - * permanently empty and the user stuck (zod requires all of these). + * The verified eTrade record, rendered strictly read-only. * - * A value that fails validation unlocks the same way. eTrade (or a row saved - * before the current rules) can supply something the schema rejects, and a - * rejected value rendered read-only is a step that can never be completed and - * never says why. + * Nothing here is typeable — not even a field eTrade left blank. These values + * are the government's record of the company, so a customer-typed substitute + * would be an unverified claim wearing the badge of a verified one. A gap stays + * a visible gap ("—"), and the schema no longer requires these fields, so it + * cannot block the step either. */ -function LockedField({ - label, - name, - register, - watch, - errors, -}: { - label: string; - name: keyof FormData; - register: UseFormRegister; - watch: UseFormWatch; - errors: FieldErrors; -}) { - const value = watch(name) as string | undefined; - if (value && value.trim() && !errors[name]) { - return ; - } - return ( - - ); -} - export default function ETradeCompanyCard({ tin, - register, watch, - errors, - control, }: { tin: string; - register: UseFormRegister; watch: UseFormWatch; - errors: FieldErrors; - control: Control; }) { const companyName = watch("companyName"); - const region = watch("region"); return ( @@ -88,73 +45,18 @@ export default function ETradeCompanyCard({ - + - {/* Membership of the catalog, not mere presence: eTrade's normalizer - returns null for a region it doesn't recognise, and older rows can - hold a spelling that isn't in the list. Showing such a value - read-only left the customer with a required field they could not - correct. */} - {(ETHIOPIAN_REGIONS as readonly string[]).includes(region ?? "") ? ( - - ) : ( - ( - ({ value: r, label: r }))} - error={error} - value={field.value || null} - onChange={(v) => field.onChange(v ?? "")} - onBlur={field.onBlur} - /> - )} - /> - ); -} diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index 58de2bd16..d6a78f1af 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -480,7 +480,10 @@ export const companiesService = { }, /** Fetch company registration data from eTrade by TIN. */ - fetchETradeInfo: async (payload: { tin: string }): Promise => { + fetchETradeInfo: async (payload: { + tin: string; + licenceNumber?: string; + }): Promise => { const response = await client.post>( URL_CONSTANTS.COMPANIES_API.FETCH_ETRADE_INFO, payload, diff --git a/packages/types/src/freight/etrade.ts b/packages/types/src/freight/etrade.ts index 67a7de28b..6f33567e4 100644 --- a/packages/types/src/freight/etrade.ts +++ b/packages/types/src/freight/etrade.ts @@ -55,10 +55,24 @@ export interface ETradeCompanyInfo { RenewedFrom: string; RenewedTo: string; BusinessLicensingGroupMain: string | null; - SubGroups: string | null; + SubGroups: Array<{ Code: number; Description: string }> | null; }>; } +/** + * One business licence held under a TIN. A single owner routinely holds many + * (import of vehicles, export of coffee, freight forwarding…), all sharing the + * same trade name — the licensed activity is what tells them apart, so that is + * what the customer picks by. + */ +export interface ETradeBusinessOption { + licenceNumber: string; + tradeName: string; + /** The licensed activities ("Import trade in …"), joined. May be empty. */ + activity: string; + renewedTo: string; +} + export interface CompanyRegistrationData { /** * The registered organization name — `ETradeCompanyInfo.BusinessName`, falling @@ -84,4 +98,11 @@ export interface CompanyRegistrationData { managerPhone: string; /** True when this TIN is already registered to an existing company. */ tinTaken?: boolean; + /** + * Every licence this TIN holds. More than one means the customer has to say + * which business they are acting as before the registration data above can be + * trusted — it describes whichever licence was selected (the first, by + * default). + */ + businesses?: ETradeBusinessOption[]; } From 92371f17da48e4fdde7050a77444ae54e7c82bd2 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 10 Aug 2026 18:45:54 +0000 Subject: [PATCH 008/174] fix: the position based gl gate on sidbear --- .../components/layout/sidebar-sections.tsx | 45 +------------------ 1 file changed, 2 insertions(+), 43 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index cfbc50528..83e80d11e 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -40,8 +40,6 @@ import type { SidebarItem, SidebarSection } from "./types"; import { FREIGHT_PERMS, hasPermission as hasFreightPermission, - isDjiboutiGl, - isEthiopianGl, isSuperAdmin, } from "@/lib/permissions"; import { getCategorySidebarChildren } from "@/pages/ruleEngine/config/resources"; @@ -554,38 +552,10 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] }, ]; -/** Hrefs of the two document-clearance menu items (stable identifiers). */ -export const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance"; -export const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance"; - -// Routes a GL officer may reach beyond their clearance hub. Path B booking is -// part of their job (create/rebook under a cleared contract, then view that -// booking's clearance), but those routes live outside the clearance prefix — -// without this allowlist the single-prefix lock bounces them out of their own -// workflow. Matched against location.pathname (no query string). -export const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [ - /^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/, - /^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/, - // The ET hub's rows open the shipment clearance detail at this URL. - /^\/dashboard\/clearance\/[^/]+(\/|$)/, -]; - -const isEtClearanceItem = (item: SidebarItem): boolean => - item.href === ET_CLEARANCE_HREF; -const isDjClearanceItem = (item: SidebarItem): boolean => - item.href === DJ_CLEARANCE_HREF; -const isClearanceItem = (item: SidebarItem): boolean => - isEtClearanceItem(item) || isDjClearanceItem(item); - /** * Keep only items the user is permitted to see; drop now-empty sections. - * - * Position-scoped visibility (super_admin sees everything): - * - Super Admin → sees all items (all permissions pass, all tabs visible) - * - Ethiopian GL → sees ONLY the ET document-clearance page. - * - Djibouti GL → sees ONLY the DJ clearance page. - * - Everyone else → sees everything they have permission for, EXCEPT the two - * clearance pages (those are GL-only). + * Super Admin sees everything; everyone else is filtered purely by each + * item's `permission` field (OR across the array when one is given). */ export const filterSidebarByPermission = ( sections: SidebarSection[], @@ -594,9 +564,6 @@ export const filterSidebarByPermission = ( // Superadmin sees every section and item — no permission filtering. if (isSuperAdmin(user)) return sections; - const etGl = isEthiopianGl(user); - const djGl = isDjiboutiGl(user); - const permissionAllowed = (item: SidebarItem): boolean => { if (!item.permission) return true; const keys = Array.isArray(item.permission) @@ -616,14 +583,6 @@ export const filterSidebarByPermission = ( : item, ) .filter((item) => { - if (etGl || djGl) { - // GL positions are locked to their single clearance page (parents - // survive only as the path to that page). - const isTarget = etGl ? isEtClearanceItem : isDjClearanceItem; - return isTarget(item) || (item.children?.length ?? 0) > 0; - } - // Everyone else: hide the GL-only clearance pages entirely. - if (isClearanceItem(item)) return false; if (!permissionAllowed(item)) return false; if (item.children) return item.children.length > 0; return true; From 40672e9c41ab0b59013ac0bbb3c8140d13fc9b8e Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Mon, 10 Aug 2026 21:47:28 +0300 Subject: [PATCH 009/174] feat(freight): surface scheduling clock and gate pay during drain --- .../src/modules/bookings/bookings.service.ts | 58 +++++ .../train-scheduling/booking-batch.service.ts | 35 +-- .../detail/BookingSchedulingWindowCard.tsx | 215 ++++++++++++++++++ .../src/components/bookings/detail/index.ts | 1 + .../bookings/BookingRequestDetailPage.tsx | 2 + .../backoffice/src/types/booking.ts | 25 ++ .../MyPortalPage/components/BookingRow.tsx | 8 +- .../src/pages/MyPortalPage/constants.ts | 2 +- .../components/BookingPaymentPanel.tsx | 23 +- .../src/pages/bookings/BookingsListPage.tsx | 10 +- .../pages/bookings/payments/PayNowButton.tsx | 20 ++ .../payments/PaymentProcessingNotice.tsx | 84 +++++++ .../pages/bookings/payments/payment-drain.ts | 60 +++++ packages/types/src/freight/index.ts | 6 + 14 files changed, 524 insertions(+), 25 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/payments/PaymentProcessingNotice.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/payments/payment-drain.ts diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 2fb9b3032..e2a3d9b0c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -34,6 +34,7 @@ import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { Contract } from '../contracts/entities/contract.entity'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { paymentDrainEndsAtIso } from '../train-scheduling/booking-batch.constants'; import { BookingContractService } from './booking-contract.service'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; @@ -57,6 +58,24 @@ import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto' import { PdfRenderService } from '../billing/documents/pdf-render.service'; import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util'; +/** + * The allocated train as the backoffice booking detail page needs it: which + * train, its window phase, and both the planned and actual clock. Attached by + * `findById` only when the booking is on a schedule. + */ +export interface TrainScheduleSummary { + id: string; + reference: string | null; + trainNumber: string | null; + status: string | null; + scheduledDepartureDate: string | null; + scheduledArrivalDate: string | null; + actualDepartureAt: string | null; + actualArrivalAt: string | null; + windowPhase: string | null; + paymentPhaseEndsAt: string | null; +} + /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ export interface PaginatedBookings { items: Booking[]; @@ -1738,6 +1757,20 @@ export class BookingsService { (b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature = pending.has(b.id); } + this.attachPaymentDrainEnds(bookings); + } + + /** + * Derived, no query: end of the settlement drain tail after `paymentDeadline`. + * The portal hides "Pay now" between the deadline and this instant — a payment + * started just before the buzzer is still settling, so offering to pay again + * would invite a double payment. + */ + private attachPaymentDrainEnds(bookings: Booking[]): void { + for (const b of bookings) { + (b as Booking & { paymentDrainEndsAt?: string | null }).paymentDrainEndsAt = + paymentDrainEndsAtIso(b.paymentDeadline); + } } async findAll( @@ -2105,8 +2138,33 @@ export class BookingsService { .findOne({ where: { id: booking.trainScheduleId } }); (booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus = schedule?.status ?? null; + // Backoffice staff view: the allocated train's identity and clock, so the + // detail page can state which train the booking rides and when it runs + // without a second round-trip to the schedules API. + ( + booking as Booking & { trainScheduleSummary?: TrainScheduleSummary | null } + ).trainScheduleSummary = schedule + ? { + id: schedule.id, + reference: schedule.reference ?? null, + trainNumber: schedule.trainNumber ?? null, + status: schedule.status ?? null, + scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null, + scheduledArrivalDate: schedule.scheduledArrivalDate?.toISOString() ?? null, + actualDepartureAt: schedule.actualDepartureAt?.toISOString() ?? null, + actualArrivalAt: schedule.actualArrivalAt?.toISOString() ?? null, + windowPhase: schedule.windowPhase ?? null, + paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null, + } + : null; } + // End of this booking's own pay window including the settlement drain tail — + // the deadline staff should quote, since a payment landing inside the drain + // still counts (see paymentDrainEndsAtIso). + (booking as Booking & { paymentDrainEndsAt?: string | null }).paymentDrainEndsAt = + paymentDrainEndsAtIso(booking.paymentDeadline); + // A generated-but-unsigned handover means the customer must approve delivery // from the portal. Self-haul: booking-based, one per booking. EDR last-mile: // per delivering truck (generated on truck exit), signed one by one. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 23024bfdf..cb568171a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -3680,23 +3680,24 @@ export class BookingBatchService implements OnModuleInit { // (provider query errored / payment still in flight) means we could not // confirm "not paid" — never expire on unknown; the next settle tick // asks again. - if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) { - const reconcile = await this.billing.reconcilePayable(booking.id); - if (reconcile.paid) { - this.logger.log( - `[BATCH] expire skipped for ${booking.reference} — gateway ` + - `reconcile found a settled payment; payment.succeeded will allocate it`, - ); - return; - } - if (reconcile.unverifiable) { - this.logger.warn( - `[BATCH] expire deferred for ${booking.reference} — settlement ` + - `unverifiable at the gateway; retrying next settle tick`, - ); - return; - } - } + // TODO: CBE has no reconcile endpoint yet — re-enable once available. + // if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) { + // const reconcile = await this.billing.reconcilePayable(booking.id); + // if (reconcile.paid) { + // this.logger.log( + // `[BATCH] expire skipped for ${booking.reference} — gateway ` + + // `reconcile found a settled payment; payment.succeeded will allocate it`, + // ); + // return; + // } + // if (reconcile.unverifiable) { + // this.logger.warn( + // `[BATCH] expire deferred for ${booking.reference} — settlement ` + + // `unverifiable at the gateway; retrying next settle tick`, + // ); + // return; + // } + // } } const freedScheduleId = booking.trainScheduleId; await this.bookingsRepository.update(booking.id, { diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx new file mode 100644 index 000000000..84f0645a8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx @@ -0,0 +1,215 @@ +import { useEffect, useState } from "react"; +import { Badge, Box, Group, Stack, Text } from "@mantine/core"; +import { CalendarClock } from "lucide-react"; + +import type { BookingDetail } from "@/types/booking"; +import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; + +import { SectionCard } from "./SectionCard"; + +export interface BookingSchedulingWindowCardProps { + booking: BookingDetail; +} + +/** Full date + time — staff read these against the operating clock, so no time is dropped. */ +function formatStamp(iso: string | null | undefined): string | null { + if (!iso) return null; + const ms = new Date(iso).getTime(); + if (!Number.isFinite(ms)) return null; + return new Date(ms).toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** "in 2h 14m" / "12m ago" — the at-a-glance read next to an absolute stamp. */ +function formatRelative(iso: string, nowMs: number): string { + const diff = new Date(iso).getTime() - nowMs; + const past = diff < 0; + const totalMinutes = Math.floor(Math.abs(diff) / 60_000); + const days = Math.floor(totalMinutes / 1440); + const hours = Math.floor((totalMinutes % 1440) / 60); + const minutes = totalMinutes % 60; + + const parts: string[] = []; + if (days) parts.push(`${days}d`); + if (hours) parts.push(`${hours}h`); + // Keep minutes when they're the only unit, so sub-hour gaps never read "0". + if (minutes || parts.length === 0) parts.push(`${minutes}m`); + + const span = parts.slice(0, 2).join(" "); + return past ? `${span} ago` : `in ${span}`; +} + +function Row({ + label, + value, + hint, + tone, +}: { + label: string; + value: string; + hint?: string | null; + tone?: "muted" | "warning" | "danger"; +}) { + const valueColor = + tone === "danger" ? "red.7" : tone === "warning" ? "orange.7" : "dark"; + return ( + + + {label} + + + + {value} + + {hint ? ( + + {hint} + + ) : null} + + + ); +} + +/** + * Backoffice-only staff view of the scheduling clock: which batch/train the + * booking is scheduled for, when its pay window closes, and the train's + * planned vs actual departure/arrival (i.e. when the run actually ended). + */ +export function BookingSchedulingWindowCard({ + booking, +}: BookingSchedulingWindowCardProps) { + const schedule = booking.trainScheduleSummary ?? null; + + // The pay-window end staff should quote is the drain end (a payment landing + // inside the drain still counts); fall back to the raw deadline if the API + // predates that field. + const payWindowEndsAt = booking.paymentDrainEndsAt ?? booking.paymentDeadline ?? null; + + // One shared ticking clock so every relative label in the card stays in sync. + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + const interval = setInterval(() => setNowMs(Date.now()), 30_000); + return () => clearInterval(interval); + }, []); + + const hasAnything = + Boolean(schedule) || Boolean(payWindowEndsAt) || Boolean(booking.holdExpiresAt); + if (!hasAnything) return null; + + const payWindowClosed = payWindowEndsAt + ? new Date(payWindowEndsAt).getTime() <= nowMs + : false; + + const trainLabel = + schedule?.trainNumber ?? + schedule?.reference ?? + (schedule ? "Assigned train" : null); + + return ( + } + > + + {trainLabel ? ( + + ) : ( + + )} + + {schedule?.status ? ( + + + Train status + + + {schedule.windowPhase ? ( + + {schedule.windowPhase.replace(/_/g, " ")} + + ) : null} + + {schedule.status} + + + + ) : null} + + {payWindowEndsAt ? ( + + ) : null} + + {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( + + ) : null} + + {schedule ? ( + <> + + + + ) : null} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index ecbb0488e..b0b024977 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -22,3 +22,4 @@ export * from "./BookingMileServicesCard"; export * from "./BookingCargoCard"; export * from "./BookingContractSummaryCard"; export * from "./BookingCompanyCard"; +export * from "./BookingSchedulingWindowCard"; diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index feee70b9d..4367fc708 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -40,6 +40,7 @@ import { BookingCompanyCard, BookingContractSummaryCard, BookingContainerUnitsCard, + BookingSchedulingWindowCard, BookingDocumentsPanel, BookingTrucksPanel, ContractOrdersPanel, @@ -246,6 +247,7 @@ export default function BookingRequestDetailPage() { + = { icon: Wallet, iconColor: "edr-amber-text", tile: "edr-amber-soft", - hint: "Selected for batch · payment due within 1 hour", + hint: "Selected for batch · payment due before the deadline", step: "edr-accent", badgeLabel: "Pay Now", badgeBg: "edr-amber-soft", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx index b94ea9fed..710c6b40f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx @@ -18,6 +18,8 @@ import { invoicesService, type PortalInvoice } from "@/services/invoices.service import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui"; import { paymentStatusLabel } from "@/pages/bookings/booking-display"; import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment"; +import { payWindowState } from "@/pages/bookings/payments/payment-drain"; +import { PaymentProcessingNotice } from "@/pages/bookings/payments/PaymentProcessingNotice"; import { saveBlob } from "@/utils/download"; import { @@ -216,6 +218,11 @@ export function BookingPaymentPanel({ (booking.status === "SELECTED_FOR_BATCH" || Boolean(booking.paymentDeadline)); + // Pay deadline passed but the settlement drain tail hasn't: in-flight payments + // are still landing, so the pay action gives way to a processing countdown. + const payWindow = payWindowState(booking); + const draining = payWindow.phase === "draining" && Boolean(payWindow.drainEndsAt); + const { data: invoices = [] } = useQuery({ queryKey: ["booking-invoices", booking.id], queryFn: () => invoicesService.listForSource("booking", booking.id), @@ -266,9 +273,11 @@ export function BookingPaymentPanel({ {paid ? : showCountdown ? : null} {paid ? "Paid" - : showCountdown - ? "Pay window open" - : paymentStatusLabel(booking.paymentStatus ?? "PENDING")} + : draining + ? "Payment processing" + : showCountdown + ? "Pay window open" + : paymentStatusLabel(booking.paymentStatus ?? "PENDING")} @@ -279,7 +288,11 @@ export function BookingPaymentPanel({ {/* USD: no online payment — bank transfer + slip to Finance, who confirm the payment (backoffice flow lands in a later phase). Shown for any unpaid USD booking, with or without an open pay window. */} - {!paid && offlineUsd && ( + {!paid && draining && payWindow.drainEndsAt && ( + + )} + + {!paid && !draining && offlineUsd && ( )} - {showCountdown && booking.paymentDeadline && ( + {showCountdown && !draining && booking.paymentDeadline && ( ; } // Contract ready for the customer's signature → full-page contract viewer. diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx index db50bd57f..63ca637b5 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx @@ -7,6 +7,8 @@ import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal"; import { priceTotal } from "../BookingDetailPage/utils"; import { isUsdOfflineBooking } from "./offline-payment"; +import { payWindowState } from "./payment-drain"; +import { PaymentProcessingNotice } from "./PaymentProcessingNotice"; import { useBookingPayment } from "./useBookingPayment"; interface PayNowButtonProps { @@ -29,6 +31,24 @@ export function PayNowButton({ }: PayNowButtonProps) { const pay = useBookingPayment(booking.id); const pricing = booking.pricingBreakdown; + const payWindow = payWindowState(booking); + + // Pay deadline passed but in-flight payments are still settling: show the + // drain countdown instead of any pay action, so nobody pays a second time. + // Checked before the USD branch — a bank transfer is just as double-payable. + if (payWindow.phase === "draining" && payWindow.drainEndsAt) { + return ( + + ); + } + + // Window fully over (drain included) — nothing to pay against anymore. + if (payWindow.phase === "closed") { + return null; + } // USD is paid by bank transfer and confirmed by Finance — no online payment. if (isUsdOfflineBooking(booking)) { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/PaymentProcessingNotice.tsx b/apps/edr-freight-web/portal/src/pages/bookings/payments/PaymentProcessingNotice.tsx new file mode 100644 index 000000000..56b7897ee --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/PaymentProcessingNotice.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import { Box, Group, Text } from "@mantine/core"; +import { Loader2 } from "lucide-react"; + +/** mm:ss left until `target`; clamped at zero so it never shows a negative. */ +function secondsLeft(target: number, now: number): string { + const total = Math.max(0, Math.ceil((target - now) / 1000)); + const minutes = Math.floor(total / 60); + const seconds = total % 60; + return `${minutes}:${String(seconds).padStart(2, "0")}`; +} + +export interface PaymentProcessingNoticeProps { + /** ISO end of the drain tail — the countdown target. */ + drainEndsAt: string; + /** Compact single-line form for list rows; full block for the detail page. */ + variant?: "inline" | "block"; + /** Called once the drain elapses, so the parent can refetch the new state. */ + onElapsed?: () => void; +} + +/** + * Shown in place of "Pay now" during the settlement drain tail: the pay deadline + * has passed but in-flight payments are still landing, so the customer waits + * rather than paying again. + */ +export function PaymentProcessingNotice({ + drainEndsAt, + variant = "block", + onElapsed, +}: PaymentProcessingNoticeProps) { + const targetMs = new Date(drainEndsAt).getTime(); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + setNow(Date.now()); + const interval = setInterval(() => { + const next = Date.now(); + setNow(next); + if (next >= targetMs) { + clearInterval(interval); + onElapsed?.(); + } + }, 1000); + return () => clearInterval(interval); + }, [targetMs, onElapsed]); + + const remaining = secondsLeft(targetMs, now); + + if (variant === "inline") { + return ( + + + + Processing · {remaining} + + + ); + } + + return ( + + + + + Payment processing — {remaining} left + + + + The payment window has closed and we're confirming the payments that + came in. If you already paid, it can take a few minutes to appear — + please don't pay again. This page updates on its own. + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/payment-drain.ts b/apps/edr-freight-web/portal/src/pages/bookings/payments/payment-drain.ts new file mode 100644 index 000000000..61d97864b --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/payment-drain.ts @@ -0,0 +1,60 @@ +import { Freight } from "@edr/types"; + +/** + * Where a booking sits relative to its pay window. + * + * - `open` — the window is live; the customer can pay. + * - `draining` — the deadline passed but the settlement drain tail has not. A + * payment started just before the buzzer may still be settling, + * so we show "processing" and hide every pay action rather than + * invite a second payment for the same booking. + * - `closed` — the drain tail elapsed too; the window is over. + * - `none` — no deadline on the booking (nothing to gate). + */ +export type PayWindowPhase = "open" | "draining" | "closed" | "none"; + +export interface PayWindowState { + phase: PayWindowPhase; + /** True only while the customer may actually start a payment. */ + canPay: boolean; + /** End of the drain tail — the countdown target while `draining`. */ + drainEndsAt: string | null; +} + +type PayableBooking = Pick< + Freight.IBooking, + "paymentDeadline" | "paymentDrainEndsAt" +>; + +/** + * Classify a booking's pay window against `now`. + * + * Falls back to the raw deadline when the server sent no `paymentDrainEndsAt` + * (older payload): with no known tail there is no drain to wait out, so the + * window goes straight from open to closed. + */ +export function payWindowState( + booking: PayableBooking | null | undefined, + now: number = Date.now(), +): PayWindowState { + const deadline = booking?.paymentDeadline ?? null; + if (!deadline) { + return { phase: "none", canPay: true, drainEndsAt: null }; + } + + const deadlineMs = new Date(deadline).getTime(); + if (!Number.isFinite(deadlineMs)) { + return { phase: "none", canPay: true, drainEndsAt: null }; + } + if (now < deadlineMs) { + return { phase: "open", canPay: true, drainEndsAt: null }; + } + + const drainRaw = booking?.paymentDrainEndsAt ?? null; + const drainMs = drainRaw ? new Date(drainRaw).getTime() : NaN; + if (Number.isFinite(drainMs) && now < drainMs) { + return { phase: "draining", canPay: false, drainEndsAt: drainRaw }; + } + + return { phase: "closed", canPay: false, drainEndsAt: null }; +} diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 7ff14721c..68f48e187 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -717,6 +717,12 @@ export interface IBooking extends BaseEntity { selectedForBatchAt?: string | null; /** End of the pay window once the booking is SELECTED_FOR_BATCH. */ paymentDeadline?: string | null; + /** + * End of the settlement drain tail that follows `paymentDeadline`. Between the + * two, an in-flight payment can still land, so the customer is shown a + * "payment processing" state instead of a pay action. + */ + paymentDrainEndsAt?: string | null; containers?: Array<{ type: string; qty: number; vgm: number }> | null; From c743750ef6cd7bb37561d298792922cd84e79a1f Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Mon, 10 Aug 2026 22:14:04 +0300 Subject: [PATCH 010/174] feat(freight): surface scheduling clock and gate pay during drain --- .../detail/BookingSchedulingWindowCard.tsx | 45 ++++++++++++++++++- .../backoffice/src/types/booking.ts | 5 +++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx index 84f0645a8..9b5cf91f2 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx @@ -44,6 +44,27 @@ function formatRelative(iso: string, nowMs: number): string { return past ? `${span} ago` : `in ${span}`; } +/** + * Length of a window as "1h 30m" / "45m". Null unless both ends are real and + * ordered — the pay window is configurable per schedule, so this is read off the + * actual stamps rather than assuming any fixed duration. + */ +function formatDuration( + from: string | null | undefined, + to: string | null | undefined, +): string | null { + if (!from || !to) return null; + const fromMs = new Date(from).getTime(); + const toMs = new Date(to).getTime(); + if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) return null; + const minutes = Math.round((toMs - fromMs) / 60_000); + if (minutes <= 0) return null; + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + if (!hours) return `${rest}m`; + return rest ? `${hours}h ${rest}m` : `${hours}h`; +} + function Row({ label, value, @@ -98,8 +119,18 @@ export function BookingSchedulingWindowCard({ return () => clearInterval(interval); }, []); + // How long the customer actually had to pay: start → the raw deadline, NOT the + // drain end (the drain is settlement grace, not payable time). + const windowDuration = formatDuration( + booking.selectedForBatchAt, + booking.paymentDeadline, + ); + const hasAnything = - Boolean(schedule) || Boolean(payWindowEndsAt) || Boolean(booking.holdExpiresAt); + Boolean(schedule) || + Boolean(payWindowEndsAt) || + Boolean(booking.selectedForBatchAt) || + Boolean(booking.holdExpiresAt); if (!hasAnything) return null; const payWindowClosed = payWindowEndsAt @@ -157,6 +188,18 @@ export function BookingSchedulingWindowCard({ ) : null} + {booking.selectedForBatchAt ? ( + + ) : null} + {payWindowEndsAt ? ( Date: Mon, 10 Aug 2026 23:30:18 +0300 Subject: [PATCH 011/174] feat(wagons): enforce wagon availability limits in transfer requests --- .../wagons/dto/create-transfer-request.dto.ts | 3 +- .../wagon-transfer-requests.service.spec.ts | 42 ++++++++++++- .../wagons/wagon-transfer-requests.service.ts | 23 +++++-- .../wagons/WagonYardWorkspaceModal.tsx | 16 +++-- .../pages/wagons/TransferRequestModals.tsx | 61 +++++++++++++++++-- 5 files changed, 127 insertions(+), 18 deletions(-) diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts index e747b69f2..85c879f70 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts @@ -14,7 +14,8 @@ import { * A count-only wagon-transfer request. The requester picks source yard, wagon * type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks * those at fulfilment. The quantity may not exceed the AVAILABLE wagons of that - * type currently in the source yard, and a reason is mandatory. + * type currently in the source yard (enforced in the service, which is the only + * layer that can count them), and a reason is mandatory. */ export class CreateTransferRequestDto { @IsUUID() diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts index 205d86450..8f7457b37 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts @@ -199,7 +199,7 @@ describe('WagonTransferRequestsService — partial fulfilment', () => { }); describe('createRequest', () => { - it('accepts a count larger than what the yard holds today', async () => { + it('accepts a count up to what the yard holds today', async () => { wagonRepo.count.mockResolvedValue(20); await service.createRequest( @@ -207,14 +207,50 @@ describe('WagonTransferRequestsService — partial fulfilment', () => { fromYardId: 'yard-a', toYardId: 'yard-b', wagonTypeId: 'type-1', - quantity: 50, + quantity: 20, reason: 'Grain campaign', }, 'user-1', ); expect(requestRepo.save).toHaveBeenCalled(); - expect(stored.quantity).toBe(50); + expect(stored.quantity).toBe(20); + }); + + it('refuses a count larger than what the yard holds today', async () => { + wagonRepo.count.mockResolvedValue(20); + + await expect( + service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 50, + reason: 'Grain campaign', + }, + 'user-1', + ), + ).rejects.toThrow(/only 20 wagon\(s\).*available/i); + expect(requestRepo.save).not.toHaveBeenCalled(); + }); + + it('refuses when the yard has nothing of that type available', async () => { + wagonRepo.count.mockResolvedValue(0); + + await expect( + service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 1, + reason: 'Grain campaign', + }, + 'user-1', + ), + ).rejects.toThrow(/no available wagons/i); + expect(requestRepo.save).not.toHaveBeenCalled(); }); it('still refuses a same-yard move', async () => { diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts index 8d4159726..6b63460ad 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts @@ -75,10 +75,11 @@ export class WagonTransferRequestsService { ) {} /** - * Record a PENDING request. Count-only — no wagons are picked here, and the - * count is NOT capped by what the source yard holds today: OCC fulfils in - * instalments, so asking for 50 while only 20 sit there is a normal, useful - * request. A reason is mandatory and is shown on the OCC queue. + * Record a PENDING request. Count-only — no wagons are picked here, but the + * count IS capped by what the source yard can hand over right now: a request + * may not exceed the AVAILABLE, uncoupled wagons of that type in the source + * yard (the same number the yard desk shows). A reason is mandatory and is + * shown on the OCC queue. */ async createRequest( dto: CreateTransferRequestDto, @@ -89,6 +90,20 @@ export class WagonTransferRequestsService { 'Source and destination yard must be different', ); } + const available = await this.countAvailable( + dto.fromYardId, + dto.wagonTypeId, + ); + if (available === 0) { + throw new BadRequestException( + 'No available wagons of this type in the source yard', + ); + } + if (dto.quantity > available) { + throw new BadRequestException( + `Only ${available} wagon(s) of this type are available in the source yard — cannot request ${dto.quantity}`, + ); + } const request = this.requestRepo.create({ fromYardId: dto.fromYardId, toYardId: dto.toYardId, diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx index 4a92695ca..cb9fdf86b 100644 --- a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx @@ -44,9 +44,8 @@ const clampInt = (v: number | string, max: number): number => { /** * NumberInput + Slider + All/Half presets, kept in sync. `max` bounds the field - * for actions that move real wagons; omit it for a transfer REQUEST, which may - * legitimately ask for more than the yard holds today (OCC fulfils it in - * instalments) — the slider then just tracks the current value. + * to the wagons on hand; omitting it leaves the field unbounded and the slider + * simply tracks the current value. */ const QuantityField = ({ value, @@ -434,9 +433,14 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro {availableCount} available - {/* No max: the request may exceed what the yard holds - today — OCC fulfils it in instalments. */} - + {/* Capped at the wagons actually available in this yard + right now (uncoupled + Available) — a request may not + ask for more than the yard can hand over. */} +