From 46971fd5c57f07fec7b3b49f46fb934f6c425c9f Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 3 Jul 2026 07:30:12 +0000 Subject: [PATCH 001/122] Export Djbouti unloading queue --- .../ExportDjiboutiUnloadingQueuePage.tsx | 52 ++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx index 31ee5ea36..609995e3e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx @@ -14,7 +14,17 @@ import { Text, } from '@mantine/core'; import { useNavigate } from 'react-router-dom'; -import { ChevronDown, ChevronRight, Eye, FileText, History, PackageOpen, Truck } from 'lucide-react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + ChevronDown, + ChevronRight, + Eye, + FileText, + History, + PackageOpen, + ShieldCheck, + Truck, +} from 'lucide-react'; import { PageHeader } from '@/components/page'; import Breadcrumbs from '@/components/ui/Breadcrumbs'; @@ -36,6 +46,7 @@ import { useInterchangeDocuments, } from '@/hooks/useInterchangeDocuments'; import { useToast } from '@/hooks/use-toast'; +import { trainSchedulingService } from '@/services/trainScheduling.service'; import type { AutoUnloadExportDjiboutiResult, ExportTrain, @@ -179,6 +190,14 @@ export default function ExportDjiboutiUnloadingQueuePage() { const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' }); const autoUnload = useAutoUnloadExportAtDjibouti(); const generateInterchange = useGenerateInterchangeDocument(); + const qc = useQueryClient(); + const secureGatePass = useMutation({ + mutationFn: (scheduleId: string) => trainSchedulingService.grantImportDjiboutiGatepass(scheduleId), + onSuccess: () => + qc.invalidateQueries({ + queryKey: ['warehouse-inventory', 'export-djibouti-arrival-queue'], + }), + }); const [openScheduleId, setOpenScheduleId] = useState(null); const [busyScheduleId, setBusyScheduleId] = useState(null); const [historyInventoryId, setHistoryInventoryId] = useState(null); @@ -189,6 +208,25 @@ export default function ExportDjiboutiUnloadingQueuePage() { .map((doc) => [doc.scheduleId as string, doc]), ); + const secureGate = async (train: ExportTrain) => { + setBusyScheduleId(train.scheduleId); + try { + await secureGatePass.mutateAsync(train.scheduleId); + toast({ + title: 'Gate pass secured', + description: `Djibouti Port entry allowed for ${train.trainNumber ?? 'the train'}. You can now auto unload.`, + }); + } catch (error) { + toast({ + variant: 'destructive', + title: 'Could not secure gate pass', + description: getErrorMessage(error), + }); + } finally { + setBusyScheduleId(null); + } + }; + const unloadTrain = async (train: ExportTrain) => { setBusyScheduleId(train.scheduleId); try { @@ -346,6 +384,16 @@ export default function ExportDjiboutiUnloadingQueuePage() { > Open + + + )} + {verifyWithFayda && faydaError && ( + + {faydaError} + + )} {shortFields.map(renderField)} diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx index 709f288ce..b2a34eadc 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx @@ -4,7 +4,7 @@ import { Badge, Text } from "@mantine/core"; import type { ColumnFormat } from "@/pages/ruleEngine/config/resources"; import { formatCell as formatRuleEngineCell } from "@/components/ruleEngine/ruleEngineFormat"; -export type FleetColumnFormat = ColumnFormat | "statusBadge"; +export type FleetColumnFormat = ColumnFormat | "statusBadge" | "verifiedBadge"; const optionLabelMap = new Map>(); @@ -20,6 +20,16 @@ export const formatFleetCell = ( format?: FleetColumnFormat, accessorKey?: string, ): ReactNode => { + if (format === "verifiedBadge") { + return value === true ? ( + + Verified + + ) : ( + + ); + } + if (format === "statusBadge") { const status = value == null || value === "" ? "—" : String(value); const getStatusColor = (st: string): string => { diff --git a/apps/edr-freight-web/backoffice/src/pages/FaydaCallbackPage.tsx b/apps/edr-freight-web/backoffice/src/pages/FaydaCallbackPage.tsx new file mode 100644 index 000000000..5febb664a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/FaydaCallbackPage.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from "react"; +import { Center, Loader, Stack, Text } from "@mantine/core"; + +import type { FaydaCallbackMessage } from "@/services/verifayda.service"; + +/** + * Landing page for the eSignet redirect_uri (FAYDA_WEB_REDIRECT_URI → + * http://localhost:5183/callback). Runs inside the verification popup: + * relays ?code&state (or ?error) to the window that opened it via + * postMessage, then closes itself. The opener performs the /complete call + * so the single-use session is only consumed once, in one place. + */ +const FaydaCallbackPage = () => { + const [standalone, setStandalone] = useState(false); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const message: FaydaCallbackMessage = { + type: "fayda-callback", + code: params.get("code") ?? undefined, + state: params.get("state") ?? undefined, + error: params.get("error") ?? undefined, + errorDescription: params.get("error_description") ?? undefined, + }; + + if (window.opener && window.opener !== window) { + (window.opener as Window).postMessage(message, window.location.origin); + window.close(); + } else { + // Opened as a full-page redirect instead of a popup — nothing to relay to. + setStandalone(true); + } + }, []); + + return ( +
+ + {standalone ? ( + <> + Verification window lost its parent page + + Close this tab and restart the verification from the form. + + + ) : ( + <> + + Completing Fayda verification… + + )} + +
+ ); +}; + +export default FaydaCallbackPage; 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 13be9ef71..db85fb0f9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -510,6 +510,7 @@ const FleetResourcePage = () => { isSubmitting={create.isPending || update.isPending} selectOptionsLoading={selectOptionsLoading} onSubmit={handleFormSubmit} + verifyWithFayda={Boolean(config.faydaVerification)} /> = { diff --git a/apps/edr-freight-web/backoffice/src/services/drivers.service.ts b/apps/edr-freight-web/backoffice/src/services/drivers.service.ts index d288975c2..79b0f88ef 100644 --- a/apps/edr-freight-web/backoffice/src/services/drivers.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/drivers.service.ts @@ -26,6 +26,8 @@ export interface Driver { address?: string | null; emergencyContact?: string | null; notes?: string | null; + faydaVerified?: boolean; + faydaSub?: string | null; totalTrips: number; rating: number; createdAt: string; diff --git a/apps/edr-freight-web/backoffice/src/services/verifayda.service.ts b/apps/edr-freight-web/backoffice/src/services/verifayda.service.ts new file mode 100644 index 000000000..d02394278 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/verifayda.service.ts @@ -0,0 +1,46 @@ +import { api as apiClient } from '../auth/http'; + +export interface FaydaStartResponse { + authorizationUrl: string; +} + +export interface FaydaCompleteResult { + purpose: 'LOGIN' | 'VERIFY'; + verified: boolean; + fullName?: string; + email?: string; + phoneNumber?: string; + /** ISO yyyy-MM-dd */ + birthdate?: string; + gender?: string; + iamUserId?: string; + userDataSaved?: boolean; +} + +/** Message posted from the /callback popup back to the opener window. */ +export interface FaydaCallbackMessage { + type: 'fayda-callback'; + code?: string; + state?: string; + error?: string; + errorDescription?: string; +} + +export const verifaydaService = { + /** Returns the eSignet authorize URL to open in a popup. */ + start: () => + apiClient + .post('/fayda/verification/start', { + purpose: 'VERIFY', + platform: 'WEB', + }) + .then((r) => r.data), + + /** Exchange the callback code+state for the verified identity attributes. */ + complete: (code: string, state: string) => + apiClient + .get( + `/fayda/verification/complete?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`, + ) + .then((r) => r.data), +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f4a28bec..08c3fc2f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,13 +83,13 @@ importers: version: 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/typeorm': specifier: ^11.0.1 - version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) '@tria-plc/api-common': specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz - version: file:local-packages/tria-plc-api-common-1.4.3.tgz(d6b22b11dde6cd6764a6a0c7e4c9ae51) + version: file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142) '@tria-plc/iamapi-common': specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz - version: file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(4837bbb980f4864b0c26765895373b58) + version: file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(578386f46cf99fd4720e3e99f196f69e) amqp-connection-manager: specifier: ^5.0.0 version: 5.0.0(amqplib@2.0.1) @@ -117,6 +117,9 @@ importers: handlebars: specifier: ^4.7.9 version: 4.7.9 + jose: + specifier: ^5.10.0 + version: 5.10.0 libphonenumber-js: specifier: ^1.13.6 version: 1.13.6 @@ -137,7 +140,7 @@ importers: version: 7.8.2 typeorm: specifier: ^0.3.30 - version: 0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) devDependencies: '@edr/eslint-config': specifier: workspace:* @@ -147,10 +150,10 @@ importers: version: link:../../packages/config/tsconfig '@nestjs/cli': specifier: ^11.0.0 - version: 11.0.21(@types/node@20.19.42) + version: 11.0.21(@types/node@20.19.42)(prettier@3.8.3) '@nestjs/schematics': specifier: ^11.0.0 - version: 11.1.0(chokidar@4.0.3)(typescript@5.9.3) + version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3) '@nestjs/testing': specifier: ^11.0.0 version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24) @@ -180,13 +183,13 @@ importers: version: 1.12.8 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + version: 29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) supertest: specifier: ^7.0.0 version: 7.2.2 ts-jest: specifier: ^29.2.5 - version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))(typescript@5.9.3) ts-loader: specifier: ^9.5.1 version: 9.6.0(typescript@5.9.3)(webpack@5.106.0) @@ -667,7 +670,7 @@ importers: version: 8.5.15 tailwindcss: specifier: ^3.4.13 - version: 3.4.19(tsx@4.22.4)(yaml@2.9.0) + version: 3.4.19(yaml@2.9.0) typescript: specifier: ^5.5.4 version: 5.9.3 @@ -755,7 +758,7 @@ importers: version: 8.5.15 tailwindcss: specifier: ^3.4.13 - version: 3.4.19(tsx@4.22.4)(yaml@2.9.0) + version: 3.4.19(yaml@2.9.0) typescript: specifier: ^5.5.4 version: 5.9.3 @@ -1497,294 +1500,138 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} cpu: [arm] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} cpu: [x64] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -6484,11 +6331,6 @@ packages: engines: {node: '>=12'} hasBin: true - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} - engines: {node: '>=18'} - hasBin: true - escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -10799,11 +10641,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.22.4: - resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} - engines: {node: '>=18.0.0'} - hasBin: true - turbo@2.9.16: resolution: {integrity: sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg==} hasBin: true @@ -11600,11 +11437,11 @@ snapshots: '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -11653,13 +11490,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.29.7': - dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-imports@7.29.7(supports-color@5.5.0)': dependencies: '@babel/traverse': 7.29.7(supports-color@5.5.0) @@ -11670,9 +11500,9 @@ snapshots: '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -11846,18 +11676,6 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.7(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -12142,150 +11960,72 @@ snapshots: '@esbuild/aix-ppc64@0.21.5': optional: true - '@esbuild/aix-ppc64@0.28.1': - optional: true - '@esbuild/android-arm64@0.21.5': optional: true - '@esbuild/android-arm64@0.28.1': - optional: true - '@esbuild/android-arm@0.21.5': optional: true - '@esbuild/android-arm@0.28.1': - optional: true - '@esbuild/android-x64@0.21.5': optional: true - '@esbuild/android-x64@0.28.1': - optional: true - '@esbuild/darwin-arm64@0.21.5': optional: true - '@esbuild/darwin-arm64@0.28.1': - optional: true - '@esbuild/darwin-x64@0.21.5': optional: true - '@esbuild/darwin-x64@0.28.1': - optional: true - '@esbuild/freebsd-arm64@0.21.5': optional: true - '@esbuild/freebsd-arm64@0.28.1': - optional: true - '@esbuild/freebsd-x64@0.21.5': optional: true - '@esbuild/freebsd-x64@0.28.1': - optional: true - '@esbuild/linux-arm64@0.21.5': optional: true - '@esbuild/linux-arm64@0.28.1': - optional: true - '@esbuild/linux-arm@0.21.5': optional: true - '@esbuild/linux-arm@0.28.1': - optional: true - '@esbuild/linux-ia32@0.21.5': optional: true - '@esbuild/linux-ia32@0.28.1': - optional: true - '@esbuild/linux-loong64@0.21.5': optional: true - '@esbuild/linux-loong64@0.28.1': - optional: true - '@esbuild/linux-mips64el@0.21.5': optional: true - '@esbuild/linux-mips64el@0.28.1': - optional: true - '@esbuild/linux-ppc64@0.21.5': optional: true - '@esbuild/linux-ppc64@0.28.1': - optional: true - '@esbuild/linux-riscv64@0.21.5': optional: true - '@esbuild/linux-riscv64@0.28.1': - optional: true - '@esbuild/linux-s390x@0.21.5': optional: true - '@esbuild/linux-s390x@0.28.1': - optional: true - '@esbuild/linux-x64@0.21.5': optional: true - '@esbuild/linux-x64@0.28.1': - optional: true - - '@esbuild/netbsd-arm64@0.28.1': - optional: true - '@esbuild/netbsd-x64@0.21.5': optional: true - '@esbuild/netbsd-x64@0.28.1': - optional: true - - '@esbuild/openbsd-arm64@0.28.1': - optional: true - '@esbuild/openbsd-x64@0.21.5': optional: true - '@esbuild/openbsd-x64@0.28.1': - optional: true - - '@esbuild/openharmony-arm64@0.28.1': - optional: true - '@esbuild/sunos-x64@0.21.5': optional: true - '@esbuild/sunos-x64@0.28.1': - optional: true - '@esbuild/win32-arm64@0.21.5': optional: true - '@esbuild/win32-arm64@0.28.1': - optional: true - '@esbuild/win32-ia32@0.21.5': optional: true - '@esbuild/win32-ia32@0.28.1': - optional: true - '@esbuild/win32-x64@0.21.5': optional: true - '@esbuild/win32-x64@0.28.1': - optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': dependencies: eslint: 8.57.1 @@ -12677,41 +12417,6 @@ snapshots: - supports-color - ts-node - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.42 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.9.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - '@jest/environment@29.7.0': dependencies: '@jest/fake-timers': 29.7.0 @@ -13203,42 +12908,6 @@ snapshots: axios: 1.17.0 rxjs: 7.8.2 - '@nestjs/cli@11.0.21(@types/node@20.19.42)': - dependencies: - '@angular-devkit/core': 19.2.24(chokidar@4.0.3) - '@angular-devkit/schematics': 19.2.24(chokidar@4.0.3) - '@angular-devkit/schematics-cli': 19.2.24(@types/node@20.19.42)(chokidar@4.0.3) - '@inquirer/prompts': 7.10.1(@types/node@20.19.42) - '@nestjs/schematics': 11.1.0(chokidar@4.0.3)(typescript@5.9.3) - ansis: 4.2.0 - chokidar: 4.0.3 - cli-table3: 0.6.5 - commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0) - glob: 13.0.6 - node-emoji: 1.11.0 - ora: 5.4.1 - tsconfig-paths: 4.2.0 - tsconfig-paths-webpack-plugin: 4.2.0 - typescript: 5.9.3 - webpack: 5.106.0 - webpack-node-externals: 3.0.0 - transitivePeerDependencies: - - '@minify-html/node' - - '@swc/css' - - '@swc/html' - - '@types/node' - - clean-css - - cssnano - - csso - - esbuild - - html-minifier-terser - - lightningcss - - postcss - - prettier - - uglify-js - - webpack-cli - '@nestjs/cli@11.0.21(@types/node@20.19.42)(prettier@3.8.3)': dependencies: '@angular-devkit/core': 19.2.24(chokidar@4.0.3) @@ -13389,17 +13058,6 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/schematics@11.1.0(chokidar@4.0.3)(typescript@5.9.3)': - dependencies: - '@angular-devkit/core': 19.2.24(chokidar@4.0.3) - '@angular-devkit/schematics': 19.2.24(chokidar@4.0.3) - comment-json: 5.0.0 - jsonc-parser: 3.3.1 - pluralize: 8.0.0 - typescript: 5.9.3 - transitivePeerDependencies: - - chokidar - '@nestjs/swagger@11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': dependencies: '@microsoft/tsdoc': 0.16.0 @@ -13453,14 +13111,6 @@ snapshots: rxjs: 7.8.2 typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - '@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))': - dependencies: - '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - typeorm: 0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - '@next/env@14.2.35': {} '@next/eslint-plugin-next@14.2.35': @@ -13647,7 +13297,7 @@ snapshots: '@puppeteer/browsers@2.13.2': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -15711,7 +15361,7 @@ snapshots: '@tokenizer/inflate@0.4.1': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) token-types: 6.1.2 transitivePeerDependencies: - supports-color @@ -15720,6 +15370,50 @@ snapshots: '@tootallnate/quickjs-emscripten@0.23.0': {} + '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142)': + dependencies: + '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) + '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) + '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(578386f46cf99fd4720e3e99f196f69e) + argon2: 0.43.1 + axios: 1.17.0 + change-case: 5.4.4 + class-transformer: 0.5.1 + class-validator: 0.14.4 + dotenv: 16.6.1 + ethiopian-calendar-date-converter: 2.1.6 + ethiopian-date: 0.0.6 + exceljs: 4.4.0 + file-type: 21.3.4 + handlebars: 4.7.9 + handlebars-helpers: 0.10.0 + jmespath: 0.16.0 + jose: 5.10.0 + jsonwebtoken: 9.0.3 + libphonenumber-js: 1.13.6 + libreoffice-convert: 1.8.1 + nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + passport-jwt: 4.0.1 + qrcode: 1.5.4 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + style-object-to-css-string: 1.1.3 + typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + uuid: 11.1.1 + xlsx: 0.18.5 + transitivePeerDependencies: + - '@faker-js/faker' + - debug + - supports-color + '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(c061d697b8a1e15b1d1aba893da7b0e6)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) @@ -15764,50 +15458,6 @@ snapshots: - debug - supports-color - '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(d6b22b11dde6cd6764a6a0c7e4c9ae51)': - dependencies: - '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) - '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) - '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) - '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) - '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(4837bbb980f4864b0c26765895373b58) - argon2: 0.43.1 - axios: 1.17.0 - change-case: 5.4.4 - class-transformer: 0.5.1 - class-validator: 0.14.4 - dotenv: 16.6.1 - ethiopian-calendar-date-converter: 2.1.6 - ethiopian-date: 0.0.6 - exceljs: 4.4.0 - file-type: 21.3.4 - handlebars: 4.7.9 - handlebars-helpers: 0.10.0 - jmespath: 0.16.0 - jose: 5.10.0 - jsonwebtoken: 9.0.3 - libphonenumber-js: 1.13.6 - libreoffice-convert: 1.8.1 - nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) - passport-jwt: 4.0.1 - qrcode: 1.5.4 - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - style-object-to-css-string: 1.1.3 - typeorm: 0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - uuid: 11.1.1 - xlsx: 0.18.5 - transitivePeerDependencies: - - '@faker-js/faker' - - debug - - supports-color - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(c97ba831ddde82920910406ab5262991)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) @@ -15843,7 +15493,7 @@ snapshots: - '@faker-js/faker' - supports-color - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(4837bbb980f4864b0c26765895373b58)': + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(578386f46cf99fd4720e3e99f196f69e)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -15853,8 +15503,8 @@ snapshots: '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) - '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(d6b22b11dde6cd6764a6a0c7e4c9ae51) + '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142) api-common: 1.2.2 argon2: 0.43.1 axios: 1.17.0 @@ -15871,8 +15521,8 @@ snapshots: qrcode: 1.5.4 reflect-metadata: 0.2.2 rxjs: 7.8.2 - typeorm: 0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) uuid: 11.1.1 transitivePeerDependencies: - '@faker-js/faker' @@ -16658,7 +16308,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -17311,7 +16961,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -17843,21 +17493,6 @@ snapshots: - supports-color - ts-node - create-jest@29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)): - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - create-require@1.1.1: {} cron@4.4.0: @@ -18009,10 +17644,6 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.4.3: - dependencies: - ms: 2.1.3 - debug@4.4.3(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -18027,8 +17658,6 @@ snapshots: decode-uri-component@0.2.2: {} - dedent@1.7.2: {} - dedent@1.7.2(babel-plugin-macros@3.1.0): optionalDependencies: babel-plugin-macros: 3.1.0 @@ -18426,36 +18055,6 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 - esbuild@0.28.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 - optional: true - escalade@3.2.0: {} escape-html@1.0.3: {} @@ -18482,7 +18081,7 @@ snapshots: '@typescript-eslint/parser': 8.60.1(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) @@ -18506,7 +18105,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3(supports-color@5.5.0) @@ -18521,14 +18120,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.60.1(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -18543,7 +18142,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -18849,7 +18448,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -18900,7 +18499,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -19047,7 +18646,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -19281,7 +18880,7 @@ snapshots: dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -19547,21 +19146,21 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -19965,7 +19564,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -20009,32 +19608,6 @@ snapshots: jest-util: 29.7.0 p-limit: 3.1.0 - jest-circus@29.7.0: - dependencies: - '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.42 - chalk: 4.1.2 - co: 4.6.0 - dedent: 1.7.2 - is-generator-fn: 2.1.0 - jest-each: 29.7.0 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - p-limit: 3.1.0 - pretty-format: 29.7.0 - pure-rand: 6.1.0 - slash: 3.0.0 - stack-utils: 2.0.6 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-circus@29.7.0(babel-plugin-macros@3.1.0): dependencies: '@jest/environment': 29.7.0 @@ -20080,25 +19653,6 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - jest-util: 29.7.0 - jest-validate: 29.7.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - jest-config@29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)): dependencies: '@babel/core': 7.29.7 @@ -20130,37 +19684,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)): - dependencies: - '@babel/core': 7.29.7 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.29.7) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 20.19.42 - ts-node: 10.9.2(@types/node@20.19.42)(typescript@5.9.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -20394,18 +19917,6 @@ snapshots: - supports-color - ts-node - jest@29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - jiti@1.21.7: {} jiti@2.6.1: {} @@ -21462,7 +20973,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) get-uri: 6.0.5 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -21677,13 +21188,12 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.15 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15)(tsx@4.22.4)(yaml@2.9.0): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.15 - tsx: 4.22.4 yaml: 2.9.0 postcss-nested@6.2.0(postcss@8.5.15): @@ -21777,7 +21287,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -21804,7 +21314,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.13.2 chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) devtools-protocol: 0.0.1608973 typed-query-selector: 2.12.2 webdriver-bidi-protocol: 0.4.1 @@ -22583,7 +22093,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -22701,7 +22211,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -22933,7 +22443,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) socks: 2.8.9 transitivePeerDependencies: - supports-color @@ -23221,7 +22731,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) fast-safe-stringify: 2.1.1 form-data: 4.0.5 formidable: 3.5.4 @@ -23289,7 +22799,7 @@ snapshots: dependencies: tailwindcss: 4.3.0 - tailwindcss@3.4.19(tsx@4.22.4)(yaml@2.9.0): + tailwindcss@3.4.19(yaml@2.9.0): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -23308,7 +22818,7 @@ snapshots: postcss: 8.5.15 postcss-import: 15.1.0(postcss@8.5.15) postcss-js: 4.1.0(postcss@8.5.15) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.15)(tsx@4.22.4)(yaml@2.9.0) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.15)(yaml@2.9.0) postcss-nested: 6.2.0(postcss@8.5.15) postcss-selector-parser: 6.1.2 resolve: 1.22.12 @@ -23539,26 +23049,6 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.29.7) jest-util: 29.7.0 - ts-jest@29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))(typescript@5.9.3): - dependencies: - bs-logger: 0.2.6 - fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.9 - jest: 29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.8.2 - type-fest: 4.41.0 - typescript: 5.9.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.29.7 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.29.7) - jest-util: 29.7.0 - ts-loader@9.6.0(typescript@5.9.3)(webpack@5.106.0): dependencies: chalk: 4.1.2 @@ -23635,13 +23125,6 @@ snapshots: tslib@2.8.1: {} - tsx@4.22.4: - dependencies: - esbuild: 0.28.1 - optionalDependencies: - fsevents: 2.3.3 - optional: true - turbo@2.9.16: optionalDependencies: '@turbo/darwin-64': 2.9.16 @@ -23734,19 +23217,6 @@ snapshots: typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) yargs: 18.0.0 - typeorm-extension@3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))): - dependencies: - '@faker-js/faker': 10.4.0 - consola: 3.4.2 - envix: 1.5.0 - locter: 2.2.1 - pascal-case: 3.1.2 - rapiq: 0.9.0 - reflect-metadata: 0.2.2 - smob: 1.6.2 - typeorm: 0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - yargs: 18.0.0 - typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)): dependencies: '@sqltools/formatter': 1.2.5 @@ -23795,30 +23265,6 @@ snapshots: - babel-plugin-macros - supports-color - typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)): - dependencies: - '@sqltools/formatter': 1.2.5 - ansis: 4.3.1 - app-root-path: 3.1.0 - buffer: 6.0.3 - dayjs: 1.11.21 - debug: 4.4.3 - dedent: 1.7.2 - dotenv: 16.6.1 - glob: 10.5.0 - reflect-metadata: 0.2.2 - sha.js: 2.4.12 - sql-highlight: 6.1.0 - tslib: 2.8.1 - uuid: 11.1.1 - yargs: 17.7.2 - optionalDependencies: - pg: 8.21.0 - ts-node: 10.9.2(@types/node@20.19.42)(typescript@5.9.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - typescript@5.9.3: {} uglify-js@3.19.3: From 48db0b240b7ab78ad997d2b8400c35782af0bd2d Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 09:32:41 +0000 Subject: [PATCH 003/122] fix fayda --- apps/edr-freight-api/src/main.ts | 3 +- .../verifayda/fayda-callback.controller.ts | 31 +++++++++++++++++++ .../src/modules/verifayda/verifayda.module.ts | 3 +- 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index 0107956e4..0fa1056dd 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -38,7 +38,8 @@ async function bootstrap() { maxAge: 86400, // cache preflight for 24h to cut chatter in dev }); - app.setGlobalPrefix("api"); + // /callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack endpoint. + app.setGlobalPrefix("api", { exclude: ["callback"] }); // enableImplicitConversion is OFF: class-transformer's implicit boolean // coercion turns any non-empty multipart/form-data string (including the // literal "false") into `true`, silently corrupting flags like isHazardous diff --git a/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts b/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts new file mode 100644 index 000000000..71b0dc4b0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; +import { VerifaydaCallbackDto } from './verifayda.dto'; + +/** + * Plain acknowledgement endpoint for the Fayda redirect_uri when it points at + * the API instead of the web app (e.g. MOBILE clients or connectivity checks). + * Registered at /callback (excluded from the global /api prefix in main.ts). + * It does NOT consume the verification session — the client must still call + * GET /api/fayda/verification/complete with the echoed code+state. + */ +@ApiTags('Fayda Verification') +@Controller('callback') +export class FaydaCallbackController { + @Get() + @IsPublic() + @ApiOperation({ summary: 'Acknowledge a Fayda redirect (returns OK, echoes code/state)' }) + @ApiOkResponse({ + schema: { example: { status: 'ok', code: '...', state: '...' } }, + }) + ok(@Query() query: VerifaydaCallbackDto) { + return { + status: 'ok', + ...(query.code ? { code: query.code } : {}), + ...(query.state ? { state: query.state } : {}), + ...(query.error ? { error: query.error } : {}), + ...(query.error_description ? { error_description: query.error_description } : {}), + }; + } +} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts index b87b90e16..82fb9435c 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { VerifaydaController } from './verifayda.controller'; +import { FaydaCallbackController } from './fayda-callback.controller'; import { VerifaydaService } from './verifayda.service'; import { FaydaVerificationSession } from './entities/fayda-verification-session.entity'; @Module({ imports: [TypeOrmModule.forFeature([FaydaVerificationSession])], - controllers: [VerifaydaController], + controllers: [VerifaydaController, FaydaCallbackController], providers: [VerifaydaService], exports: [VerifaydaService], }) From e14df58a4728764ac315d088820df3bb0c49b86f Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 13:26:40 +0000 Subject: [PATCH 004/122] fix issues --- .../contracts/contract-booking.service.ts | 55 +++++- .../src/modules/contracts/contracts.module.ts | 5 + .../batch-window.util.spec.ts | 112 ++++++------ .../train-scheduling/batch-window.util.ts | 171 ++++++++++-------- .../train-scheduling/booking-batch.service.ts | 20 +- .../train-scheduling.service.ts | 47 +++++ apps/edr-freight-web/backoffice/src/App.tsx | 12 +- .../TrainSchedulingGlobalRulesPage.tsx | 29 +-- .../new-booking-form/LocationPicker.tsx | 30 ++- .../src/pages/contracts/NewShipmentPage.tsx | 34 +++- .../portal/src/services/contracts.service.ts | 4 + 11 files changed, 354 insertions(+), 165 deletions(-) diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index ef36ea5eb..01b35fc71 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -1,11 +1,14 @@ import { BadRequestException, ForbiddenException, + Inject, Injectable, Logger, NotFoundException, + forwardRef, } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { ExchangeService } from '@edr/api-common'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; @@ -15,6 +18,7 @@ import { BookingPricingService } from '../bookings/booking-pricing.service'; import { BookingInvoiceService } from '../bookings/booking-invoice.service'; import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; +import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; @@ -62,6 +66,9 @@ export class ContractBookingService { private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, private readonly dataSource: DataSource, + private readonly exchangeService: ExchangeService, + @Inject(forwardRef(() => TrainSchedulingService)) + private readonly trainSchedulingService: TrainSchedulingService, ) {} async createUnderContract( @@ -113,6 +120,20 @@ export class ContractBookingService { const generalCustoms = contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); + // Booking-window gate (config-driven): an operations booking may only be + // created while the route's booking window is open — import: the day's window + // (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours); + // export: within exportBookingLeadHours of departure. Customs Path B bookings + // enter clearance first and are scheduled later, so they are not gated here. + if (!generalCustoms) { + await this.trainSchedulingService.assertBookingWindowOpen({ + originYardId: route?.originYardId ?? null, + destinationYardId: route?.destinationYardId ?? null, + scheduledDate: dto.scheduledDate ?? null, + direction: contract.tradeDirection ?? null, + }); + } + // Denormalize route/direction/freight onto the booking for the scheduling engine. const booking = await this.bookingsRepository.create({ reference, @@ -582,13 +603,22 @@ export class ContractBookingService { maxAllowedTons: number; excessTons: number; }>; + overweightSurchargeAmount: number; + currency: string | null; pairingErrors: string[]; }> { const contract = await this.contractsRepository.findByIdWithRelations(contractId); if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); const lines = dto.containers ?? []; - if (!lines.length) return { overweightLines: [], pairingErrors: [] }; + if (!lines.length) { + return { + overweightLines: [], + overweightSurchargeAmount: 0, + currency: null, + pairingErrors: [], + }; + } // Resolve each line's container type + total VGM (sum of unit weights) so the // rule engine can flag overweight per line (maxVgmTons × quantity vs total). @@ -661,7 +691,28 @@ export class ContractBookingService { (v) => v.message, ); - return { overweightLines, pairingErrors }; + // Real overweight surcharge (same rate the rule engine bills at booking-create + // time) so the confirm-modal total isn't missing the charge the warning refers to. + // Rates are stored in USD; convert to the contract's payment currency the same + // way BookingPricingService does so this preview matches the eventual booking total. + const overweightModifier = ruleResult.appliedModifiers.find( + (m) => m.surchargeCode === 'OVERWEIGHT_PER_TON', + ); + let overweightSurchargeAmount = 0; + if (overweightModifier) { + const isEtb = contract.paymentCurrency === 'ETB'; + const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; + overweightSurchargeAmount = isEtb + ? Math.round(overweightModifier.calculatedAmount * usdToEtb) + : overweightModifier.calculatedAmount; + } + + return { + overweightLines, + overweightSurchargeAmount, + currency: overweightLines.length ? contract.paymentCurrency : null, + pairingErrors, + }; } private async max20ftPairDiffTons(): Promise { diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index e0eece986..48476a7ea 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -12,6 +12,7 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; import { SignaturesModule } from '../signatures/signatures.module'; import { BookingsModule } from '../bookings/bookings.module'; +import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { ContractsController } from './contracts.controller'; import { ContractsService } from './contracts.service'; @@ -76,6 +77,10 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum // BookingsModule provides BookingsRepository/BookingPricingService used by the // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). forwardRef(() => BookingsModule), + // TrainSchedulingModule provides the config-driven booking-window gate used + // by ContractBookingService.createUnderContract. forwardRef because + // TrainSchedulingModule already imports ContractsModule. + forwardRef(() => TrainSchedulingModule), ExchangeModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ExchangeOptions => diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts index e765e5694..764589b73 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts @@ -3,9 +3,9 @@ import { listBatchWindowsForDate, listBatchWindowsForBookings, BATCH_WINDOW_START_HOURS, - boardWindowForTimestamp, - listBoardWindowsForRange, + listConfigBookingWindows, groupBookingsIntoBoardWindows, + type BoardWindowConfig, } from './batch-window.util'; describe('batch-window.util', () => { @@ -54,83 +54,87 @@ describe('batch-window.util', () => { }); }); -describe('batch-window board windows (midnight-based 3h slots)', () => { - it('maps 04:00 EAT to the 03:00–06:00 slot', () => { - // 01:00 UTC = 04:00 EAT on 11 Jun - const w = boardWindowForTimestamp(new Date('2026-06-11T01:00:00.000Z')); - expect(w.label).toContain('03:00'); - expect(w.label).toContain('06:00'); - expect(w.date).toBe('2026-06-11'); - expect(w.dateLabel).toContain('11 Jun'); - }); +describe('batch-window board windows (config-driven booking cycles)', () => { + // Default rules: open 08:00 EAT, 3 days before departure, 3h long, reopen 90m later. + const cfg: BoardWindowConfig = { + importWindowLeadDays: 3, + windowOpenHour: 8, + windowDurationHours: 3, + reopenDelayMinutes: 90, + exportBookingLeadHours: 24, + }; - it('maps 00:30 EAT to the 00:00–03:00 slot of that EAT day', () => { - // 21:30 UTC on 10 Jun = 00:30 EAT on 11 Jun - const w = boardWindowForTimestamp(new Date('2026-06-10T21:30:00.000Z')); - expect(w.label).toContain('00:00'); - expect(w.label).toContain('03:00'); - expect(w.date).toBe('2026-06-11'); - }); - - it('maps 23:00 EAT to the final 21:00–24:00 slot', () => { - // 20:00 UTC = 23:00 EAT on 11 Jun - const w = boardWindowForTimestamp(new Date('2026-06-11T20:00:00.000Z')); - expect(w.label).toContain('21:00'); - expect(w.label).toContain('24:00'); - expect(w.date).toBe('2026-06-11'); - }); - - it('lists a continuous range open→departure clamped at both ends', () => { - // open 05 Jun 08:00 EAT (05:00 UTC) → departs 08 Jun 14:00 EAT (11:00 UTC) - const open = new Date('2026-06-05T05:00:00.000Z'); + it('import: first window opens at windowOpenHour EAT, importWindowLeadDays before departure', () => { + // departs 08 Jun 14:00 EAT (11:00 UTC) → window day = 05 Jun, opens 08:00 EAT (05:00 UTC) const departure = new Date('2026-06-08T11:00:00.000Z'); - const windows = listBoardWindowsForRange(open, departure); + const windows = listConfigBookingWindows('IMPORT', departure, cfg); - // Day 5: 06,09,12,15,18,21 = 6 ; Days 6,7: 8 each ; Day 8: 00,03,06,09,12 = 5 - expect(windows).toHaveLength(6 + 8 + 8 + 5); expect(windows[0].date).toBe('2026-06-05'); - expect(windows[0].label).toContain('06:00'); - expect(windows[0].label).toContain('09:00'); - const last = windows[windows.length - 1]; - expect(last.date).toBe('2026-06-08'); - expect(last.label).toContain('12:00'); - expect(last.label).toContain('15:00'); - // chronological + unique keys - const keys = windows.map((w) => w.key); - expect(new Set(keys).size).toBe(keys.length); + expect(windows[0].label).toContain('08:00'); + expect(windows[0].start.toISOString()).toBe('2026-06-05T05:00:00.000Z'); + // end = open + windowDurationHours (3h) = 08:00 → 11:00 EAT (08:00 UTC) + expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z'); }); - it('handles a same-day open→departure range', () => { - const open = new Date('2026-06-05T05:00:00.000Z'); // 08:00 EAT (06–09 slot) - const departure = new Date('2026-06-05T11:00:00.000Z'); // 14:00 EAT (12–15 slot) - const windows = listBoardWindowsForRange(open, departure); - // 06,09,12 = 3 slots - expect(windows).toHaveLength(3); + it('import: reopens reopenDelayMinutes after close, same booking day', () => { + const departure = new Date('2026-06-08T11:00:00.000Z'); + const windows = listConfigBookingWindows('IMPORT', departure, cfg); + // cycle 1: 08:00–11:00; reopen +90m → cycle 2 opens 12:30 EAT + expect(windows.length).toBeGreaterThanOrEqual(2); + expect(windows[1].start.toISOString()).toBe('2026-06-05T09:30:00.000Z'); // 12:30 EAT + // all cycles stay on the same EAT booking day expect(windows.every((w) => w.date === '2026-06-05')).toBe(true); }); - it('buckets bookings by fullyExecutedAt and keeps empty + pending windows', () => { - const open = new Date('2026-06-05T05:00:00.000Z'); - const departure = new Date('2026-06-06T11:00:00.000Z'); + it('export: single FCFS window exportBookingLeadHours before departure', () => { + const departure = new Date('2026-06-08T11:00:00.000Z'); + const windows = listConfigBookingWindows('EXPORT', departure, cfg); + expect(windows).toHaveLength(1); + // 24h before 11:00 UTC on 08 Jun = 11:00 UTC on 07 Jun + expect(windows[0].start.toISOString()).toBe('2026-06-07T11:00:00.000Z'); + expect(windows[0].end.toISOString()).toBe(departure.toISOString()); + }); + + it('buckets bookings into config cycles and keeps empty + pending windows', () => { + const departure = new Date('2026-06-08T11:00:00.000Z'); const items = [ - { id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → 06–09 on 5th + { id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → inside cycle 1 { id: 'b', ts: null }, // pending ]; const map = groupBookingsIntoBoardWindows( items, (i) => i.ts, - open, + 'IMPORT', departure, + cfg, 'pending-contract', ); const pending = map.get('pending-contract'); expect(pending?.items.map((i) => i.id)).toEqual(['b']); const withA = [...map.values()].find((b) => b.items.some((i) => i.id === 'a')); expect(withA?.window?.date).toBe('2026-06-05'); - // empty slots are retained for the UI + // empty cycles are retained for the UI const emptyCount = [...map.values()].filter( (b) => b.window && b.items.length === 0, ).length; expect(emptyCount).toBeGreaterThan(0); }); + + it('attaches a booking made before the window opened to the first cycle', () => { + const departure = new Date('2026-06-08T11:00:00.000Z'); + const items = [{ id: 'early', ts: new Date('2026-06-01T00:00:00.000Z') }]; + const map = groupBookingsIntoBoardWindows( + items, + (i) => i.ts, + 'IMPORT', + departure, + cfg, + 'pending-contract', + ); + const withEarly = [...map.values()].find((b) => + b.items.some((i) => i.id === 'early'), + ); + expect(withEarly?.window?.date).toBe('2026-06-05'); + expect(withEarly?.window?.label).toContain('08:00'); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index 650da3adc..6d295c8fd 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -230,14 +230,13 @@ export function listBatchWindowsForBookings( } // --------------------------------------------------------------------------- -// Board-display windows: full-day, midnight-based 3h slots over a date range. -// These are used ONLY for the batch-board UI grouping (not persisted, and -// independent of the cron intake hours above). +// Board-display windows: the REAL booking-window cycles derived from the +// train_scheduling_global_rules config (window open hour, lead days, duration, +// reopen delay) — NOT a fixed clock grid. Import shows each booking-window cycle +// (opens at windowOpenHour EAT, lasts windowDurationHours, reopens after +// reopenDelayMinutes until departure). Export shows the single FCFS lead window. // --------------------------------------------------------------------------- -/** Midnight-based 3-hour slot starts (00–03, 03–06, … 21–24). */ -export const BOARD_WINDOW_HOURS = [0, 3, 6, 9, 12, 15, 18, 21] as const; - /** A board window carries an EAT calendar date in addition to the slot times. */ export interface BoardWindow extends BatchWindow { /** EAT calendar day as ISO `YYYY-MM-DD`. */ @@ -246,6 +245,15 @@ export interface BoardWindow extends BatchWindow { dateLabel: string; } +/** Config fields the board needs to reconstruct booking-window cycles. */ +export interface BoardWindowConfig { + importWindowLeadDays: number; + windowOpenHour: number; + windowDurationHours: number; + reopenDelayMinutes: number; + exportBookingLeadHours: number; +} + const dayLabelFmt = new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: '2-digit', @@ -257,119 +265,124 @@ function pad2(n: number): string { return String(n).padStart(2, '0'); } -/** Build a midnight-based 3h board window for an EAT calendar day + slot start hour. */ -function boardWindowFromEatStart( - year: number, - month: number, - day: number, - startHour: number, -): BoardWindow { - const start = eatToUtc(year, month, day, startHour); - const endHour = startHour + 3; // 21 -> 24 (handled by Date.UTC roll-over) - const end = eatToUtc(year, month, day, endHour); - const endLabel = endHour >= 24 ? '24:00' : `${pad2(endHour)}:00`; +/** Wrap a [start, end] interval as a labelled BoardWindow keyed on its EAT day. */ +function boardWindowFromInterval(start: Date, end: Date): BoardWindow { + const { year, month, day } = eatParts(start); return { key: start.toISOString(), start, end, - label: formatWindowLabel(start, end, endLabel), + label: formatWindowLabel(start, end), date: `${year}-${pad2(month)}-${pad2(day)}`, dateLabel: dayLabelFmt.format(start), }; } -/** Which midnight-based 3h EAT slot a timestamp falls in. */ -export function boardWindowForTimestamp(date: Date): BoardWindow { - const { year, month, day, hour } = eatParts(date); - let startHour: (typeof BOARD_WINDOW_HOURS)[number] = 0; - for (const h of BOARD_WINDOW_HOURS) { - if (hour >= h) startHour = h; - } - return boardWindowFromEatStart(year, month, day, startHour); -} - /** - * Continuous list of board windows from `openDate` to `departureDate` (inclusive), - * clamped to the slot containing `openDate` on the first day and the slot - * containing `departureDate` on the last day. Returned in chronological order. + * The real booking-window cycles for a schedule, straight from config. + * + * IMPORT: first window opens at `windowOpenHour` EAT on `departure − importWindowLeadDays` + * for `windowDurationHours`; if the train isn't full it reopens `reopenDelayMinutes` + * after each close, on the same booking day, until departure. This mirrors + * `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the + * exact windows the engine runs. + * EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure. */ -export function listBoardWindowsForRange( - openDate: Date, - departureDate: Date, +export function listConfigBookingWindows( + direction: string | null | undefined, + departure: Date, + cfg: BoardWindowConfig, ): BoardWindow[] { - const startWin = boardWindowForTimestamp(openDate); - const endWin = boardWindowForTimestamp(departureDate); - // Guard against an inverted range (departure before open). - if (endWin.start.getTime() < startWin.start.getTime()) { - return [startWin]; + if (direction === 'EXPORT') { + const start = new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000); + return [boardWindowFromInterval(start, departure)]; } const windows: BoardWindow[] = []; - const seen = new Set(); - // Walk day-by-day in EAT, emitting each day's slots, stepping via UTC noon to - // avoid any boundary ambiguity, then filter to [startWin.start, endWin.start]. - let cursor = new Date(eatToUtc( - Number(startWin.date.slice(0, 4)), - Number(startWin.date.slice(5, 7)), - Number(startWin.date.slice(8, 10)), - 12, - )); - const lastDayMs = eatToUtc( - Number(endWin.date.slice(0, 4)), - Number(endWin.date.slice(5, 7)), - Number(endWin.date.slice(8, 10)), - 12, - ).getTime(); + const durationMs = cfg.windowDurationHours * 3_600_000; + const reopenMs = cfg.reopenDelayMinutes * 60_000; + const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays); - while (cursor.getTime() <= lastDayMs) { - const { year, month, day } = eatParts(cursor); - for (const h of BOARD_WINDOW_HOURS) { - const w = boardWindowFromEatStart(year, month, day, h); - if ( - w.start.getTime() >= startWin.start.getTime() && - w.start.getTime() <= endWin.start.getTime() && - !seen.has(w.key) - ) { - seen.add(w.key); - windows.push(w); - } + let opensAt = eatDayToUtc(windowDay, cfg.windowOpenHour); + // Reopen stays on the same EAT booking day and before departure; cap at 12 cycles. + for (let cycle = 0; cycle < 12; cycle += 1) { + if (opensAt.getTime() >= departure.getTime()) break; + let closesAt = new Date(opensAt.getTime() + durationMs); + if (closesAt.getTime() > departure.getTime()) closesAt = departure; + windows.push(boardWindowFromInterval(opensAt, closesAt)); + + const nextOpensAt = new Date(closesAt.getTime() + reopenMs); + if ( + nextOpensAt.getTime() >= departure.getTime() || + eatDay(nextOpensAt) !== eatDay(opensAt) + ) { + break; } - cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000); + opensAt = nextOpensAt; } - windows.sort(compareBatchWindows); + // Degenerate config (no window before departure) — surface a single window + // clamped to departure so the board still renders something meaningful. + if (windows.length === 0) { + windows.push(boardWindowFromInterval(new Date(departure.getTime() - durationMs), departure)); + } return windows; } +/** Which config booking-window a timestamp falls in; null if before/after all of them. */ +function configWindowForTimestamp( + windows: BoardWindow[], + date: Date, +): BoardWindow | null { + const ms = date.getTime(); + for (const w of windows) { + if (ms >= w.start.getTime() && ms < w.end.getTime()) return w; + } + return null; +} + /** - * Group items into board windows spanning [openDate, departureDate]. Empty - * windows are kept so the UI shows every slot. Items whose timestamp falls - * outside the range still get their own window (nothing hidden). Items without - * a timestamp go to `pendingKey`. + * Group items into the real config booking-window cycles for a schedule. Empty + * windows are kept so the UI shows every cycle. Items whose timestamp falls + * outside every window (e.g. a booking created before the window opened) are + * attached to the nearest window by start time so nothing is hidden. Items + * without a timestamp go to `pendingKey`. */ export function groupBookingsIntoBoardWindows( items: T[], getTimestamp: (item: T) => Date | null | undefined, - openDate: Date, - departureDate: Date, + direction: string | null | undefined, + departure: Date, + cfg: BoardWindowConfig, pendingKey = 'pending-contract', ): Map { + const windows = listConfigBookingWindows(direction, departure, cfg); const map = new Map(); - - for (const w of listBoardWindowsForRange(openDate, departureDate)) { + for (const w of windows) { map.set(w.key, { window: w, items: [] }); } map.set(pendingKey, { window: null, items: [] }); + const firstWindow = windows[0] ?? null; + const lastWindow = windows[windows.length - 1] ?? null; + for (const item of items) { const ts = getTimestamp(item); if (!ts) { map.get(pendingKey)!.items.push(item); continue; } - const w = boardWindowForTimestamp(ts); - if (!map.has(w.key)) { - map.set(w.key, { window: w, items: [] }); + let w = configWindowForTimestamp(windows, ts); + if (!w) { + // Booked before the window opened → first cycle; after it closed → last cycle. + w = + firstWindow && ts.getTime() < firstWindow.start.getTime() + ? firstWindow + : lastWindow; + } + if (!w) { + map.get(pendingKey)!.items.push(item); + continue; } map.get(w.key)!.items.push(item); } 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 2c30a5964..efa6b3259 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 @@ -590,6 +590,9 @@ export class BookingBatchService implements OnModuleInit { const board: BatchBoardSchedule[] = []; for (const s of schedules) { if (s.status === "ARRIVED" || s.status === "CANCELLED") continue; + // Batch board is IMPORT-only: export is FCFS with no batch/priority calc, + // and domestic/legacy schedules run the legacy fill, not the window batch. + if (s.direction !== "IMPORT") continue; const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const linkedIds = new Set(links.map((l) => l.bookingId)); @@ -630,6 +633,12 @@ export class BookingBatchService implements OnModuleInit { if (s.status === "ARRIVED" || s.status === "CANCELLED") { throw new BadRequestException("Schedule is no longer active"); } + // Batch board is IMPORT-only (export is FCFS, no batch/priority calc). + if (s.direction !== "IMPORT") { + throw new BadRequestException( + "The batch board only covers import schedules", + ); + } const wagonLengths = await this.loadWagonLengths(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); @@ -710,15 +719,18 @@ export class BookingBatchService implements OnModuleInit { const loco = s.trainSet?.locomotive ?? null; - // Display windows span the whole booking window: from when it opened - // (schedule creation) through the scheduled departure, in 3-hour EAT slots. - const openDate = s.createdAt ?? s.scheduledDepartureDate ?? new Date(); + // Display windows are the REAL booking-window cycles from the global-rules + // config (import: opens at windowOpenHour EAT importWindowLeadDays before + // departure, lasts windowDurationHours, reopens per reopenDelayMinutes; + // export: single FCFS lead window) — not a fixed clock grid. + const windowCfg = await this.trainSchedulingService.getWindowConfig(); const departureDate = s.scheduledDepartureDate ?? new Date(); const windowBuckets = groupBookingsIntoBoardWindows( items, (item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null), - openDate, + s.direction ?? null, departureDate, + windowCfg, ); const emptyCounts = () => ({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index ce835e1e4..fbcb82142 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -3174,6 +3174,53 @@ export class TrainSchedulingService { return days.includes(day); } + /** + * Enforce the config-driven booking window at booking-create time. + * + * A booking is only allowed when the route has an OPEN departure the customer + * can join for the requested day — which, because the window engine keeps + * `bookingWindowStatus === 'OPEN'` in lockstep with the live window, means: + * - IMPORT: the day's window is currently open (opens at `windowOpenHour` EAT, + * `importWindowLeadDays` before departure, for `windowDurationHours`). + * - EXPORT: now is within `exportBookingLeadHours` before that departure (FCFS). + * + * `getBookableScheduleEntities` filters on `bookingWindowStatus === 'OPEN'`, so + * both gates are satisfied by checking that route for open departures. When a + * specific day is requested, require an open departure on that EAT day; when no + * day is given, require at least one open departure on the route at all. + * Throws `BadRequestException` when the window is closed. No-ops when the route + * yards are unknown (nothing to gate against). + */ + async assertBookingWindowOpen(input: { + originYardId?: string | null; + destinationYardId?: string | null; + scheduledDate?: Date | string | null; + direction?: string | null; + }): Promise { + const { originYardId, destinationYardId } = input; + if (!originYardId || !destinationYardId) return; + + const { days } = await this.getAvailableDays(originYardId, destinationYardId); + if (days.length === 0) { + throw new BadRequestException( + input.direction === 'EXPORT' + ? 'The export booking window for this route is not open yet' + : 'The import booking window for this route is closed right now', + ); + } + + if (input.scheduledDate) { + const day = eatDay(new Date(input.scheduledDate)); + if (!days.includes(day)) { + throw new BadRequestException( + input.direction === 'EXPORT' + ? 'No departure is within the export booking window on the selected day' + : 'The import booking window is not open for the selected day', + ); + } + } + } + private async mapScheduleDetail( schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, ) { diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 53d636145..e7e567c7a 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -178,12 +178,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ FREIGHT_PERMS.contracts.clearanceEtActions, ], }, - // { - // label: "Shipment Requests", - // href: "/dashboard/shipment-requests", - // icon: , - // permission: FREIGHT_PERMS.contracts.createBooking, - // }, + { + label: "Shipment Requests", + href: "/dashboard/shipment-requests", + icon: , + permission: FREIGHT_PERMS.contracts.createBooking, + }, { label: "GL Djibouti Clearance", href: "/dashboard/gl-djibouti/clearance", diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx index 97e35de23..d5209886d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx @@ -10,7 +10,10 @@ export default function TrainSchedulingGlobalRulesPage() { const { toast } = useToast(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [form, setForm] = useState>({}); + // Fields hold raw NumberInput values (number | string) while editing; coerced to Number on save. + const [form, setForm] = useState< + Partial> + >({}); useEffect(() => { void (async () => { @@ -65,7 +68,7 @@ export default function TrainSchedulingGlobalRulesPage() { description="Sum of all wagon lengths must not exceed this" value={form.maxTrainLengthMeters ?? ""} onChange={(value) => - setForm((current) => ({ ...current, maxTrainLengthMeters: Number(value) })) + setForm((current) => ({ ...current, maxTrainLengthMeters: value })) } min={1} disabled={loading} @@ -75,7 +78,7 @@ export default function TrainSchedulingGlobalRulesPage() { description="Total container and bulk cargo weight must not exceed this" value={form.maxTrainWeightTons ?? ""} onChange={(value) => - setForm((current) => ({ ...current, maxTrainWeightTons: Number(value) })) + setForm((current) => ({ ...current, maxTrainWeightTons: value })) } min={1} disabled={loading} @@ -84,7 +87,7 @@ export default function TrainSchedulingGlobalRulesPage() { label="Max wagons per train" value={form.maxWagonsPerTrain ?? ""} onChange={(value) => - setForm((current) => ({ ...current, maxWagonsPerTrain: Number(value) })) + setForm((current) => ({ ...current, maxWagonsPerTrain: value })) } min={1} disabled={loading} @@ -96,7 +99,7 @@ export default function TrainSchedulingGlobalRulesPage() { onChange={(value) => setForm((current) => ({ ...current, - max20ftContainerWeightTons: Number(value), + max20ftContainerWeightTons: value, })) } min={0.001} @@ -109,7 +112,7 @@ export default function TrainSchedulingGlobalRulesPage() { onChange={(value) => setForm((current) => ({ ...current, - max20ftPairWeightDiffTons: Number(value), + max20ftPairWeightDiffTons: value, })) } min={0} @@ -129,7 +132,7 @@ export default function TrainSchedulingGlobalRulesPage() { description="The single booking day opens this many days before departure" value={form.importWindowLeadDays ?? ""} onChange={(value) => - setForm((current) => ({ ...current, importWindowLeadDays: Number(value) })) + setForm((current) => ({ ...current, importWindowLeadDays: value })) } min={0} disabled={loading} @@ -139,7 +142,7 @@ export default function TrainSchedulingGlobalRulesPage() { description="Export bookings are accepted first-come-first-serve starting this many hours before departure" value={form.exportBookingLeadHours ?? ""} onChange={(value) => - setForm((current) => ({ ...current, exportBookingLeadHours: Number(value) })) + setForm((current) => ({ ...current, exportBookingLeadHours: value })) } min={1} disabled={loading} @@ -149,7 +152,7 @@ export default function TrainSchedulingGlobalRulesPage() { description="Local hour the import window opens on its booking day (e.g. 8 = 08:00)" value={form.windowOpenHour ?? ""} onChange={(value) => - setForm((current) => ({ ...current, windowOpenHour: Number(value) })) + setForm((current) => ({ ...current, windowOpenHour: value })) } min={0} max={23} @@ -159,7 +162,7 @@ export default function TrainSchedulingGlobalRulesPage() { label="Window duration (hours)" value={form.windowDurationHours ?? ""} onChange={(value) => - setForm((current) => ({ ...current, windowDurationHours: Number(value) })) + setForm((current) => ({ ...current, windowDurationHours: value })) } min={0.25} max={12} @@ -171,7 +174,7 @@ export default function TrainSchedulingGlobalRulesPage() { description="Max staff time to accept booking documents after the window closes" value={form.docReviewMinutes ?? ""} onChange={(value) => - setForm((current) => ({ ...current, docReviewMinutes: Number(value) })) + setForm((current) => ({ ...current, docReviewMinutes: value })) } min={0} disabled={loading} @@ -181,7 +184,7 @@ export default function TrainSchedulingGlobalRulesPage() { description="Time a selected customer has to pay before the slot expires" value={form.paymentWindowMinutes ?? ""} onChange={(value) => - setForm((current) => ({ ...current, paymentWindowMinutes: Number(value) })) + setForm((current) => ({ ...current, paymentWindowMinutes: value })) } min={1} disabled={loading} @@ -191,7 +194,7 @@ export default function TrainSchedulingGlobalRulesPage() { description="Delay after window close before reopening when the train is not full (90 = 11:00 close → 12:30 reopen)" value={form.reopenDelayMinutes ?? ""} onChange={(value) => - setForm((current) => ({ ...current, reopenDelayMinutes: Number(value) })) + setForm((current) => ({ ...current, reopenDelayMinutes: value })) } min={1} disabled={loading} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx index 1b75ab65c..b2f14b16f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx @@ -147,6 +147,31 @@ async function searchPlaces( return found; } +/** + * Build the address label for a picked place. + * + * For an establishment / POI (e.g. "Bole Medhanialem") Google's + * `formatted_address` is the *postal* address, which for many Ethiopian places + * collapses to just the city ("Addis Ababa, Ethiopia") — so taking it verbatim + * silently replaces the specific place the user picked with a broad city. The + * place `name` carries the specific label, so we lead with it and only append + * the formatted address for context when it doesn't already contain the name. + * Falls back to the prediction's own description (what the user saw and clicked). + */ +function placeDisplayName( + place: google.maps.places.PlaceResult | null, + prediction: PlacePrediction, +): string { + const name = place?.name?.trim(); + const formatted = place?.formatted_address?.trim(); + if (name && formatted) { + return formatted.toLowerCase().includes(name.toLowerCase()) + ? formatted + : `${name}, ${formatted}`; + } + return name || formatted || prediction.displayName; +} + /** * Resolve a picked prediction to its coordinates via Place Details. Runs once * per selection (closes the Autocomplete session), so billing stays on the @@ -174,10 +199,7 @@ async function resolvePrediction( return; } resolve({ - displayName: - place?.formatted_address || - place?.name || - prediction.displayName, + displayName: placeDisplayName(place, prediction), lat: loc.lat(), lng: loc.lng(), }); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index d245cd20c..34705395b 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -371,16 +371,41 @@ function PriceConfirmModal({ onConfirm: () => void; onReject: () => void; }) { - const total = useMemo( + const baseTotal = useMemo( () => (values ? computeShipmentTotal(contract, values) : null), [contract, values], ); const overweightLines = validation?.overweightLines ?? []; + const overweightSurchargeAmount = validation?.overweightSurchargeAmount ?? 0; const pairingErrors = validation?.pairingErrors ?? []; const hasPairingBlock = pairingErrors.length > 0; const confirmDisabled = loading || validationLoading || hasPairingBlock; + // The contract's frozen unit rates (computeShipmentTotal) don't carry an + // overweight line — that surcharge only exists in the live rule engine. Fold + // the real amount from validateShipment into the displayed total so the + // customer sees the actual charge the overweight warning refers to, not just + // the warning text. + const total = useMemo(() => { + if (!baseTotal) return null; + if (!(overweightSurchargeAmount > 0)) return baseTotal; + return { + ...baseTotal, + lines: [ + ...baseTotal.lines, + { + label: "Overweight surcharge", + unitPrice: overweightSurchargeAmount, + unit: "flat" as const, + quantity: 1, + amount: overweightSurchargeAmount, + }, + ], + total: baseTotal.total + overweightSurchargeAmount, + }; + }, [baseTotal, overweightSurchargeAmount]); + return ( ))} - An overweight surcharge applies. You can still submit, or go - back and adjust weights. + {overweightSurchargeAmount > 0 + ? `An overweight surcharge of ${overweightSurchargeAmount.toLocaleString()} ${ + validation?.currency ?? total?.currency ?? "" + } applies (included in the total below). You can still submit, or go back and adjust weights.` + : "An overweight surcharge applies. You can still submit, or go back and adjust weights."} diff --git a/apps/edr-freight-web/portal/src/services/contracts.service.ts b/apps/edr-freight-web/portal/src/services/contracts.service.ts index 3af87ee42..7c981eee2 100644 --- a/apps/edr-freight-web/portal/src/services/contracts.service.ts +++ b/apps/edr-freight-web/portal/src/services/contracts.service.ts @@ -46,9 +46,13 @@ export interface OverweightLine { * `overweightLines` are WARNINGS only (an overweight surcharge applies — the * customer may still submit); `pairingErrors` are HARD BLOCKS (20ft containers * that cannot be balanced onto wagons) and must prevent booking. + * `overweightSurchargeAmount` is the real overweight charge (same rate the + * booking is billed at on submit) so the confirm-modal total can include it. */ export interface ShipmentValidation { overweightLines: OverweightLine[]; + overweightSurchargeAmount: number; + currency: string | null; pairingErrors: string[]; } From a63a16a0b7931e154a560ece22fc6425ab92c490 Mon Sep 17 00:00:00 2001 From: yaschalew Date: Fri, 3 Jul 2026 16:36:08 +0300 Subject: [PATCH 005/122] fix --- .../src/modules/verifayda/fayda-callback.controller.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts b/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts index 71b0dc4b0..1c5569750 100644 --- a/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts +++ b/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts @@ -29,3 +29,5 @@ export class FaydaCallbackController { }; } } + +// return res.redirect(url.toString()); From 52f48b688d9ad6cb26fe2ba618604d7e104c63d2 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 13:40:10 +0000 Subject: [PATCH 006/122] fix issues --- ...000000-SimplifyRatesAndWeightLimitRules.ts | 121 ++++++++++++++++++ .../rule-engine/dto/create-rate.dto.ts | 11 +- .../dto/create-weight-limit-rule.dto.ts | 13 +- .../rule-engine/entities/rate-unit.util.ts | 71 ++++++++++ .../rule-engine/entities/rate.entity.ts | 7 - .../entities/weight-limit-rule.entity.ts | 7 - .../interfaces/rates.repository.interface.ts | 6 + ...weight-limit-rules.repository.interface.ts | 5 + .../repositories/rates.repository.ts | 39 +++++- .../weight-limit-rules.repository.ts | 21 ++- .../rule-engine/services/rates.service.ts | 101 ++++++++++++--- 11 files changed, 343 insertions(+), 59 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts diff --git a/apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts b/apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts new file mode 100644 index 000000000..c03e70bbb --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts @@ -0,0 +1,121 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Simplify the rate + weight-limit configuration model: + * + * 1. Drop the effective_from / effective_to validity window from both + * `rates` and `weight_limit_rules`. Rates are now activated purely by + * the approval workflow (status = LIVE) and weight limits are always + * active for their container + direction. No time-travel scheduling. + * + * 2. Enforce "one rate per pattern" with partial unique indexes so the same + * configuration (e.g. FIRST_MILE for a given container type) cannot be + * duplicated. NULL scope columns are COALESCE-normalised because Postgres + * treats NULLs as distinct in a plain unique index. + * + * This migration is destructive on the date columns — existing effective_* + * values are dropped. + */ +export class SimplifyRatesAndWeightLimitRules1900000000000 implements MigrationInterface { + name = 'SimplifyRatesAndWeightLimitRules1900000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // ── 1. De-duplicate existing data so the unique indexes can be created ── + // Keep the most recently-created row per pattern, soft-delete the rest. + await queryRunner.query(` + WITH ranked AS ( + SELECT id, + row_number() OVER ( + PARTITION BY rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, '') + ORDER BY created_at DESC, id DESC + ) AS rn + FROM freight.rates + WHERE deleted_at IS NULL AND status <> 'SUPERSEDED' + ) + UPDATE freight.rates r + SET deleted_at = now() + FROM ranked + WHERE r.id = ranked.id AND ranked.rn > 1; + `); + + await queryRunner.query(` + WITH ranked AS ( + SELECT id, + row_number() OVER ( + PARTITION BY container_type_id, trade_direction + ORDER BY created_at DESC, id DESC + ) AS rn + FROM freight.weight_limit_rules + WHERE deleted_at IS NULL + ) + UPDATE freight.weight_limit_rules w + SET deleted_at = now() + FROM ranked + WHERE w.id = ranked.id AND ranked.rn > 1; + `); + + // ── 2. Drop the effective-date indexes + columns ─────────────────────── + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_effective_from";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_weight_limit_rules_effective_from";`); + // Indexes created by TypeORM's @Index carry generated hashed names — drop + // any index that references the effective_from column defensively. + await queryRunner.query(` + DO $$ + DECLARE idx record; + BEGIN + FOR idx IN + SELECT indexname FROM pg_indexes + WHERE schemaname = 'freight' + AND tablename IN ('rates', 'weight_limit_rules') + AND indexdef ILIKE '%effective_from%' + LOOP + EXECUTE format('DROP INDEX IF EXISTS freight.%I', idx.indexname); + END LOOP; + END $$; + `); + + await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS effective_from;`); + await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS effective_to;`); + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_from;`); + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_to;`); + + // ── 3. One-rate-per-pattern partial unique indexes ───────────────────── + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" + ON freight.rates ( + rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, '') + ) + WHERE deleted_at IS NULL AND status <> 'SUPERSEDED'; + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_weight_limit_rules_pattern" + ON freight.weight_limit_rules (container_type_id, trade_direction) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_weight_limit_rules_pattern";`); + + await queryRunner.query(`ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS effective_from date;`); + await queryRunner.query(`UPDATE freight.rates SET effective_from = COALESCE(effective_from, created_at::date);`); + await queryRunner.query(`ALTER TABLE freight.rates ALTER COLUMN effective_from SET NOT NULL;`); + await queryRunner.query(`ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS effective_to date;`); + + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules ADD COLUMN IF NOT EXISTS effective_from date;`); + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules ADD COLUMN IF NOT EXISTS effective_to date;`); + + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_rates_effective_from" ON freight.rates (effective_from);`); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_weight_limit_rules_effective_from" ON freight.weight_limit_rules (effective_from);`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index 995718135..9a4cd642b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; import { RATE_APPLIES_TO, RATE_TRIGGERS, @@ -51,15 +51,6 @@ export class CreateRateDto { @ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' }) @IsIn([...RATE_UNITS]) rateUnit!: string; - - @ApiProperty({ description: 'Date from which this rate is effective (ISO date)', example: '2025-01-01' }) - @IsDateString() - effectiveFrom!: string; - - @ApiPropertyOptional({ description: 'Date when this rate expires. Null = currently active', example: '2025-12-31' }) - @IsOptional() - @IsDateString() - effectiveTo?: string; } export class SubmitRateForApprovalDto { diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts index 6be37214b..eea223ae3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts @@ -1,6 +1,6 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsDateString, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; +import { IsIn, IsNumber, IsUUID, Min } from 'class-validator'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const; @@ -21,13 +21,4 @@ export class CreateWeightLimitRuleDto { @Min(0) @Transform(({ value }) => Number(value)) maxVgmTons!: number; - - @ApiProperty({ description: 'Date from which this rule is active (ISO date)', example: '2024-01-01' }) - @IsDateString() - effectiveFrom!: string; - - @ApiPropertyOptional({ description: 'Date when this rule expires (ISO date). Null = currently active', example: '2025-12-31' }) - @IsOptional() - @IsDateString() - effectiveTo?: string; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts new file mode 100644 index 000000000..cef613412 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -0,0 +1,71 @@ +import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity'; + +/** + * Which rate units make sense for a given rate shape. The weighting basis is + * driven by the *type* of thing being billed — a container leg bills per + * container, bulk freight per ton, an intercity move can be per-km, a + * cancellation is a flat/per-invoice fee, and overweight is always per excess + * ton. This keeps the rate table dynamic yet non-conflicting: the admin can + * only pick a unit the pricing engine knows how to apply. + * + * Returned lists are ordered with the most natural/default unit first. + */ +export function allowedRateUnits(input: { + appliesTo: RateAppliesTo; + trigger: RateTrigger; +}): RateUnit[] { + const { appliesTo, trigger } = input; + + // Surcharges (Applies to = Other) are governed by their trigger. + if (appliesTo === 'OTHER') { + switch (trigger) { + case 'OVERWEIGHT': + // Overweight always bills the excess tonnage — per ton, nothing else. + return ['PER_TON']; + case 'REEFER': + case 'HAZARDOUS': + // Scale with the freight shape: per container for boxes, per ton for bulk. + return ['PER_CONTAINER', 'PER_TON']; + case 'DEMURRAGE': + return ['PER_CONTAINER', 'PER_TON']; + case 'CANCELLATION': + return ['FLAT', 'PER_INVOICE']; + case 'CONSOLIDATION': + return ['PER_CONTAINER', 'FLAT']; + case 'SHIPPING_LINE': + case 'PIL_EXTRA_FEE': + return ['PER_CONTAINER', 'FLAT']; + default: + return ['FLAT', 'PER_TON', 'PER_CONTAINER']; + } + } + + // Base freight + first/last mile scale with the cargo type. + switch (appliesTo) { + case 'CONTAINER': + return ['PER_CONTAINER', 'PER_WAGON']; + case 'BULK': + return ['PER_TON', 'PER_WAGON']; + case 'INTERCITY': + return ['PER_CONTAINER', 'PER_TON', 'PER_WAGON', 'PER_KM']; + case 'FIRST_MILE': + case 'LAST_MILE': + return ['PER_CONTAINER', 'PER_TON', 'PER_KM', 'FLAT']; + default: + return ['FLAT']; + } +} + +/** The default (first / most natural) unit for a rate shape. */ +export function defaultRateUnit(input: { appliesTo: RateAppliesTo; trigger: RateTrigger }): RateUnit { + return allowedRateUnits(input)[0]; +} + +/** True when `unit` is a valid weighting basis for the given rate shape. */ +export function isRateUnitAllowed(input: { + appliesTo: RateAppliesTo; + trigger: RateTrigger; + unit: RateUnit; +}): boolean { + return allowedRateUnits(input).includes(input.unit); +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index b57b48cd8..50f8b3b99 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -81,7 +81,6 @@ export type RateTrigger = typeof RATE_TRIGGERS[number]; @Entity({ schema: 'freight', name: 'rates' }) @Index(['rateType']) @Index(['status']) -@Index(['effectiveFrom']) @Index(['containerTypeId']) @Index(['trigger']) export class Rate extends BaseEntity { @@ -131,10 +130,4 @@ export class Rate extends BaseEntity { @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) approvedAt?: Date | null; - - @Column({ name: 'effective_from', type: 'date' }) - effectiveFrom!: Date; - - @Column({ name: 'effective_to', type: 'date', nullable: true }) - effectiveTo?: Date | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts index 39557eec9..b6b87b285 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts @@ -5,7 +5,6 @@ import { ContainerType } from './container-type.entity'; @Entity({ schema: 'freight', name: 'weight_limit_rules' }) @Index(['containerTypeId']) @Index(['tradeDirection']) -@Index(['effectiveFrom']) export class WeightLimitRule extends BaseEntity { @Column({ name: 'container_type_id', type: 'uuid' }) containerTypeId!: string; @@ -19,10 +18,4 @@ export class WeightLimitRule extends BaseEntity { @Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true }) maxVgmTons!: number; - - @Column({ name: 'effective_from', type: 'date', nullable: true }) - effectiveFrom!: Date; - - @Column({ name: 'effective_to', type: 'date', nullable: true }) - effectiveTo?: Date | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts index 52b991155..24b3c3626 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -4,6 +4,12 @@ import { Rate } from '../entities/rate.entity'; export interface IRatesRepository { findById(id: string): Promise; findLiveRates(): Promise; + findByPattern(pattern: { + rateType: string; + containerTypeId?: string | null; + cargoTypeId?: string | null; + tradeDirection?: string | null; + }): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[Rate[], number]>; create(data: Partial): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts index cedbd1eee..3c175df4e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts @@ -7,6 +7,11 @@ export interface IWeightLimitRulesRepository { containerTypeId: string, tradeDirection: string, ): Promise; + findByPattern( + containerTypeId: string, + tradeDirection: string, + excludeId?: string, + ): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[WeightLimitRule[], number]>; create(data: Partial): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index 0d49a0bf3..bbb5f2386 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -16,15 +16,48 @@ export class RatesRepository implements IRatesRepository { } findLiveRates(): Promise { - const now = new Date(); return this.repo .createQueryBuilder('rate') .where('rate.status = :status', { status: 'LIVE' }) - .andWhere('rate.effective_from <= :now', { now }) - .andWhere('(rate.effective_to IS NULL OR rate.effective_to > :now)', { now }) .getMany(); } + /** + * Find a non-superseded rate matching an identity pattern — the same tuple the + * `UQ_rates_pattern` unique index enforces. Used to reject duplicates before + * insert so the admin gets a friendly error instead of a raw constraint fault. + * NULL scope columns are matched with IS NULL, mirroring the COALESCE index. + */ + findByPattern(pattern: { + rateType: string; + containerTypeId?: string | null; + cargoTypeId?: string | null; + tradeDirection?: string | null; + }): Promise { + const qb = this.repo + .createQueryBuilder('rate') + .where('rate.rate_type = :rateType', { rateType: pattern.rateType }) + .andWhere('rate.status <> :superseded', { superseded: 'SUPERSEDED' }); + + if (pattern.containerTypeId) { + qb.andWhere('rate.container_type_id = :containerTypeId', { containerTypeId: pattern.containerTypeId }); + } else { + qb.andWhere('rate.container_type_id IS NULL'); + } + if (pattern.cargoTypeId) { + qb.andWhere('rate.cargo_type_id = :cargoTypeId', { cargoTypeId: pattern.cargoTypeId }); + } else { + qb.andWhere('rate.cargo_type_id IS NULL'); + } + if (pattern.tradeDirection) { + qb.andWhere('rate.trade_direction = :tradeDirection', { tradeDirection: pattern.tradeDirection }); + } else { + qb.andWhere('rate.trade_direction IS NULL'); + } + + return qb.getOne(); + } + findAll(options?: FindManyOptions): Promise { return this.repo.find(options); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts index 0d151c561..87d2febba 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts @@ -22,7 +22,6 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository { containerTypeId: string, tradeDirection: string, ): Promise { - const now = new Date(); return this.repo .createQueryBuilder('rule') .innerJoinAndSelect('rule.containerType', 'ct') @@ -31,11 +30,27 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository { dir: tradeDirection, both: 'BOTH', }) - .andWhere('rule.effective_from <= :now', { now }) - .andWhere('(rule.effective_to IS NULL OR rule.effective_to > :now)', { now }) .getMany(); } + /** + * Find a rule matching the (containerType, tradeDirection) identity — the + * tuple enforced by `UQ_weight_limit_rules_pattern`. Used to reject duplicates + * before insert. Optionally excludes a row by id so updates don't self-collide. + */ + findByPattern( + containerTypeId: string, + tradeDirection: string, + excludeId?: string, + ): Promise { + const qb = this.repo + .createQueryBuilder('rule') + .where('rule.container_type_id = :containerTypeId', { containerTypeId }) + .andWhere('rule.trade_direction = :tradeDirection', { tradeDirection }); + if (excludeId) qb.andWhere('rule.id <> :excludeId', { excludeId }); + return qb.getOne(); + } + findAll(options?: FindManyOptions): Promise { return this.repo.find(options); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index c3ab6bab0..2a717366d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -1,8 +1,15 @@ -import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { CreateRateDto } from '../dto/create-rate.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; import { Rate } from '../entities/rate.entity'; import { deriveRateType } from '../entities/rate-type.util'; +import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util'; import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface'; @Injectable() @@ -27,7 +34,7 @@ export class RatesService { const [data, total] = await this.repository.findAndCount({ where, - order: { effectiveFrom: 'DESC' }, + order: { createdAt: 'DESC' }, skip: (page - 1) * pageSize, take: pageSize, }); @@ -46,6 +53,49 @@ export class RatesService { return entity; } + /** + * Normalise + validate the weighting unit for a rate shape. Overweight is + * always billed per excess ton, so its unit is forced to PER_TON regardless + * of what the client sent. Every other shape must pick a unit the pricing + * engine can actually apply (see `allowedRateUnits`). + */ + private resolveRateUnit( + appliesTo: Rate['appliesTo'], + trigger: Rate['trigger'], + requestedUnit: Rate['rateUnit'], + ): Rate['rateUnit'] { + // Overweight is per-ton, full stop. + if (trigger === 'OVERWEIGHT') return 'PER_TON'; + + if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) { + const allowed = allowedRateUnits({ appliesTo, trigger }).join(', '); + throw new BadRequestException( + `Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed}.`, + ); + } + return requestedUnit; + } + + /** + * Reject a second rate with the same identity pattern (rateType + scope). With + * effective-date windows gone, two LIVE/DRAFT rates for the same pattern would + * make pricing ambiguous — so we allow exactly one per pattern. + */ + private async assertNoDuplicatePattern(pattern: { + rateType: string; + containerTypeId: string | null; + cargoTypeId: string | null; + tradeDirection: string | null; + ignoreId?: string; + }): Promise { + const existing = await this.repository.findByPattern(pattern); + if (existing && existing.id !== pattern.ignoreId) { + throw new ConflictException( + 'A rate for this exact combination already exists. Edit or delete the existing rate instead of creating a duplicate.', + ); + } + } + /** Create a rate in DRAFT status. */ async create(dto: CreateRateDto, proposedByStaffId: string): Promise { const appliesTo = dto.appliesTo as Rate['appliesTo']; @@ -57,25 +107,28 @@ export class RatesService { const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null); const tradeDirection = isSurcharge ? null : (dto.tradeDirection ?? null); + const rateType = deriveRateType({ + appliesTo, + trigger, + tradeDirection, + isBulk: Boolean(cargoTypeId), + }); + const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']); + + await this.assertNoDuplicatePattern({ rateType, containerTypeId, cargoTypeId, tradeDirection }); + return this.repository.create({ appliesTo, trigger, - rateType: deriveRateType({ - appliesTo, - trigger, - tradeDirection, - isBulk: Boolean(cargoTypeId), - }), + rateType, containerTypeId, cargoTypeId, tradeDirection, currency: dto.currency ?? 'USD', rateValue: dto.rateValue, - rateUnit: dto.rateUnit as Rate['rateUnit'], + rateUnit, status: 'DRAFT', proposedByStaffId, - effectiveFrom: new Date(dto.effectiveFrom), - effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : undefined, }); } @@ -110,22 +163,34 @@ export class RatesService { ? dto.tradeDirection : existing.tradeDirection; - updates.containerTypeId = containerTypeId; - updates.cargoTypeId = cargoTypeId; - updates.tradeDirection = tradeDirection; + updates.containerTypeId = containerTypeId ?? null; + updates.cargoTypeId = cargoTypeId ?? null; + updates.tradeDirection = tradeDirection ?? null; // Keep the derived rateType in sync with whatever changed. - updates.rateType = deriveRateType({ + const rateType = deriveRateType({ appliesTo, trigger, tradeDirection, isBulk: Boolean(cargoTypeId), }); + updates.rateType = rateType; + + // Re-validate the unit against the (possibly changed) shape; overweight is + // forced to PER_TON. + const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit; + updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit); + + // Guard the pattern uniqueness for the new identity, ignoring this row. + await this.assertNoDuplicatePattern({ + rateType, + containerTypeId: updates.containerTypeId, + cargoTypeId: updates.cargoTypeId, + tradeDirection: updates.tradeDirection, + ignoreId: id, + }); updates.currency = dto.currency ?? existing.currency ?? 'USD'; if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue; - if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit']; - if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom); - if (dto.effectiveTo) updates.effectiveTo = new Date(dto.effectiveTo); const updated = await this.repository.update(id, updates); if (!updated) throw new NotFoundException(`Rate ${id} not found`); return updated; From e2867e30864a7c3ab2061b24417f089517c8ee66 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 3 Jul 2026 13:47:45 +0000 Subject: [PATCH 007/122] import loading backend --- ...AddLoadingStatusToTrainScheduleBookings.ts | 25 ++++++ .../entities/train-schedule-booking.entity.ts | 9 ++ .../train-schedule-bookings.repository.ts | 23 +++++ .../dto/update-import-loading-status.dto.ts | 15 ++++ .../train-scheduling.controller.ts | 23 +++++ .../train-scheduling.service.ts | 83 +++++++++++++++++++ .../warehouses/dto/filter-inventory.dto.ts | 5 ++ .../warehouses/warehouse-inventory.service.ts | 1 + apps/edr-freight-web/backoffice/src/App.tsx | 4 +- .../backoffice/src/constants/QUERY_KEYS.ts | 2 + .../backoffice/src/constants/URLS.ts | 4 + .../warehouses/WarehouseInventoryPage.tsx | 13 ++- .../backoffice/src/services/api.ts | 21 +++++ .../src/services/trainScheduling.service.ts | 22 +++++ .../backoffice/src/types/trainScheduling.ts | 15 ++++ .../backoffice/src/types/warehouse.ts | 1 + packages/types/src/freight/index.ts | 5 ++ 17 files changed, 266 insertions(+), 5 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts diff --git a/apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts b/apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts new file mode 100644 index 000000000..6c7235e3b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Track per-booking loading confirmation (LOADED/UNLOADED) on train_schedule_bookings. + * Tracking only — does not gate dispatch. + */ +export class AddLoadingStatusToTrainScheduleBookings1900000000000 + implements MigrationInterface +{ + name = "AddLoadingStatusToTrainScheduleBookings1900000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedule_bookings + ADD COLUMN IF NOT EXISTS loading_status varchar(20) NOT NULL DEFAULT 'UNLOADED' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedule_bookings + DROP COLUMN IF EXISTS loading_status + `); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts index 4ffecea26..352704eea 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts @@ -1,9 +1,15 @@ import { BaseEntity } from '@edr/api-common'; +import { LoadingStatus } from '@edr/types'; import { Entity, Index, JoinColumn, ManyToOne, Column } from 'typeorm'; import { Booking } from '../../bookings/entities/booking.entity'; import { TrainSchedule } from './train-schedule.entity'; +export const TRAIN_SCHEDULE_BOOKING_LOADING_STATUSES = [ + LoadingStatus.Unloaded, + LoadingStatus.Loaded, +] as const; + @Entity({ schema: 'freight', name: 'train_schedule_bookings' }) @Index(['trainScheduleId', 'bookingId'], { unique: true }) @Index(['bookingId'], { unique: true }) @@ -23,4 +29,7 @@ export class TrainScheduleBooking extends BaseEntity { @ManyToOne(() => Booking) @JoinColumn({ name: 'booking_id' }) booking?: Booking; + + @Column({ name: 'loading_status', type: 'varchar', length: 20, default: 'UNLOADED' }) + loadingStatus!: string; } diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts index 4ccfcd469..64607ecb8 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts @@ -47,4 +47,27 @@ export class TrainScheduleBookingsRepository extends BaseRepository { + return this.repo(manager).find({ + where: { trainScheduleId }, + select: { id: true, bookingId: true, trainScheduleId: true, loadingStatus: true }, + }); + } + + async updateLoadingStatusMany( + trainScheduleId: string, + bookingIds: string[], + loadingStatus: string, + manager?: EntityManager, + ): Promise { + if (!bookingIds.length) return; + await this.repo(manager).update( + { trainScheduleId, bookingId: In(bookingIds) }, + { loadingStatus }, + ); + } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts new file mode 100644 index 000000000..25943050c --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts @@ -0,0 +1,15 @@ +import { LoadingStatus } from '@edr/types'; +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsEnum, IsUUID } from 'class-validator'; + +export class UpdateImportLoadingStatusDto { + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + bookingIds!: string[]; + + @ApiProperty({ enum: LoadingStatus }) + @IsEnum(LoadingStatus) + loadingStatus!: LoadingStatus; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 8887fb4c6..d0595c0da 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -28,6 +28,7 @@ import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto"; import { PinWagonsDto } from "./dto/pin-wagons.dto"; import { UpdateContainerItemDto } from "./dto/update-container-item.dto"; +import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto"; import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto"; import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto"; import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto"; @@ -325,6 +326,28 @@ export class TrainSchedulingController { return this.trainSchedulingService.getCompositionRemovals(id); } + @Get("schedules/:id/import-loading-bookings") + @TrainSchedulingView() + @ApiOperation({ + summary: "List import bookings eligible for loading confirmation on this schedule", + }) + getImportLoadingBookings(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getImportLoadingBookings(id); + } + + @Patch("schedules/:id/import-loading-status") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)", + }) + updateImportLoadingStatus( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateImportLoadingStatusDto, + ) { + return this.trainSchedulingService.updateImportLoadingStatus(id, dto); + } + @Post("schedules/:id/pin-wagons") @TrainSchedulingManage() @ApiOperation({ summary: "Pin physical wagons to train set slots" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index ce835e1e4..cdcd8aca8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1,5 +1,6 @@ import { AllocationLoadType, + LoadingStatus, SchedulingStatus, TrainCheckpointKind, TrainScheduleStatus as TrainScheduleStatusEnum, @@ -45,6 +46,7 @@ import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; import { PinWagonsDto } from './dto/pin-wagons.dto'; import { UpdateContainerItemDto } from './dto/update-container-item.dto'; +import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto'; import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto'; import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; @@ -744,6 +746,87 @@ export class TrainSchedulingService { ); } + async getImportLoadingBookings(scheduleId: string) { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + const [scheduleBookings, allocations] = await Promise.all([ + this.trainScheduleBookingsRepository.findByScheduleId(scheduleId), + this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId), + ]); + if (!scheduleBookings.length) { + return { count: 0, items: [] }; + } + + const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId)); + const statusByBookingId = new Map( + scheduleBookings.map((sb) => [sb.bookingId, sb.loadingStatus]), + ); + const candidateIds = scheduleBookings + .map((sb) => sb.bookingId) + .filter((id) => allocatedBookingIds.has(id)); + if (!candidateIds.length) { + return { count: 0, items: [] }; + } + + const bookings = await this.bookingsRepository.findByIdsForScheduling(candidateIds); + const items = bookings + .filter((b) => b.tradeDirection === 'IMPORT' && b.paymentStatus === 'PAID') + .map((b) => ({ + id: b.id, + reference: b.reference ?? null, + customer: b.company?.name ?? null, + weightTons: b.cargoTotalWeightVgm, + loadingStatus: statusByBookingId.get(b.id) ?? LoadingStatus.Unloaded, + })); + return { count: items.length, items }; + } + + async updateImportLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + const [scheduleBookings, allocations, bookings] = await Promise.all([ + this.trainScheduleBookingsRepository.findByScheduleId(scheduleId), + this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId), + this.bookingsRepository.findByIdsForScheduling(dto.bookingIds), + ]); + + const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId)); + const allocatedIds = new Set(allocations.map((a) => a.bookingId)); + const bookingById = new Map(bookings.map((b) => [b.id, b])); + + const invalid: string[] = []; + for (const id of dto.bookingIds) { + const booking = bookingById.get(id); + if ( + !scheduledIds.has(id) || + !allocatedIds.has(id) || + !booking || + booking.tradeDirection !== 'IMPORT' || + booking.paymentStatus !== 'PAID' + ) { + invalid.push(id); + } + } + if (invalid.length) { + throw new BadRequestException( + `Not eligible for import loading confirmation on this schedule: ${invalid.join(', ')}`, + ); + } + + await this.trainScheduleBookingsRepository.updateLoadingStatusMany( + scheduleId, + dto.bookingIds, + dto.loadingStatus, + ); + return this.getImportLoadingBookings(scheduleId); + } + async pinWagons(scheduleId: string, dto: PinWagonsDto) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts index 9f89e7c2c..49fb22fde 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts @@ -52,6 +52,11 @@ export class FilterWarehouseInventoryDto { @IsEnum(WAREHOUSE_INVENTORY_STATUSES) status?: WarehouseInventoryStatus; + @ApiPropertyOptional({ enum: ['IMPORT', 'EXPORT'] }) + @IsOptional() + @IsEnum(['IMPORT', 'EXPORT']) + direction?: 'IMPORT' | 'EXPORT'; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 11f34c892..3b27c7213 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -430,6 +430,7 @@ export class WarehouseInventoryService { ...(filter.status ? { status: filter.status } : {}), ...(createdAt ? { createdAt } : {}), ...(filter.facilityId ? { warehouse: { stationId: filter.facilityId } } : {}), + ...(filter.direction ? { booking: { tradeDirection: filter.direction } } : {}), }; const search = filter.search?.trim(); diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 8c2453e4d..1558eb727 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -309,7 +309,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, { label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory", + href: "/dashboard/warehouse-inventory?direction=IMPORT", icon: , }, { @@ -356,7 +356,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, { label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory", + href: "/dashboard/warehouse-inventory?direction=EXPORT", icon: , }, ], diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index eecee533b..8ee0a53be 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -94,6 +94,8 @@ export const QUERY_KEYS = { ["train-scheduling", "unassigned", id] as const, compositionRemovals: (id: string) => ["train-scheduling", "removals", id] as const, + importLoadingBookings: (id: string) => + ["train-scheduling", "import-loading-bookings", id] as const, }, FLEET: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index c59be5d7d..d33603d24 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -301,6 +301,10 @@ export const URL_CONSTANTS = { PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`, FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`, DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`, + IMPORT_LOADING_BOOKINGS: (id: string) => + `/train-scheduling/schedules/${id}/import-loading-bookings`, + IMPORT_LOADING_STATUS: (id: string) => + `/train-scheduling/schedules/${id}/import-loading-status`, IMPORT_DJIBOUTI: (id: string) => `/train-scheduling/schedules/${id}/import-djibouti`, IMPORT_DJIBOUTI_DOCUMENTS: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx index 7133c47ab..6f0d27580 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx @@ -21,6 +21,7 @@ export default function WarehouseInventoryPage() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined; + const direction = (searchParams.get('direction') as 'IMPORT' | 'EXPORT' | null) ?? undefined; const [filter, setFilter] = useState( initialStatus ? { status: initialStatus } : {}, ); @@ -28,8 +29,8 @@ export default function WarehouseInventoryPage() { const [debouncedSearch] = useDebouncedValue(search, 300); const queryFilter = useMemo( - () => ({ ...filter, search: debouncedSearch || undefined }), - [filter, debouncedSearch], + () => ({ ...filter, direction, search: debouncedSearch || undefined }), + [filter, direction, debouncedSearch], ); const warehousesQuery = useWarehouses(); @@ -53,7 +54,13 @@ export default function WarehouseInventoryPage() { return ( diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index a239b7ee4..fb7d733e3 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -43,6 +43,8 @@ import type { CreateTrainSchedulePayload, EligibleContainerBookingsResponse, FreightType, + ImportLoadingBookingsResponse, + LoadingStatus, LocomotiveRecord, PinWagonsPayload, RecordCheckpointPayload, @@ -343,6 +345,25 @@ export const api = { QUERY_KEYS.TRAIN_SCHEDULING.compositionRemovals(scheduleId), ), + importLoadingBookings: endpoint<{ id: string }, ImportLoadingBookingsResponse>( + "train-scheduling", + "import-loading-bookings", + ({ id }) => trainSchedulingService.getImportLoadingBookings(id), + ({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.importLoadingBookings(id), + ), + + updateImportLoadingStatus: endpoint< + { id: string; bookingIds: string[]; loadingStatus: LoadingStatus }, + ImportLoadingBookingsResponse + >( + "train-scheduling", + "update-import-loading-status", + ({ id, bookingIds, loadingStatus }) => + trainSchedulingService.updateImportLoadingStatus(id, { bookingIds, loadingStatus }), + undefined, + ({ id }) => [QUERY_KEYS.TRAIN_SCHEDULING.importLoadingBookings(id)], + ), + // ── Mutations ────────────────────────────────────────────────────────── runAllocation: endpoint<{ scheduleId: string }, WagonAllocationAttemptResult>( "train-scheduling", diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts index 0d6ef6cc9..5e0310bbe 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -15,6 +15,8 @@ import type { ImportDjiboutiActionPayload, ImportDjiboutiLoadList, ImportDjiboutiOperation, + ImportLoadingBookingsResponse, + LoadingStatus, LocomotiveRecord, PinWagonsPayload, RecordCheckpointPayload, @@ -298,6 +300,26 @@ export const trainSchedulingService = { return unwrap(response.data); }, + getImportLoadingBookings: async ( + scheduleId: string, + ): Promise => { + const response = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_LOADING_BOOKINGS(scheduleId), + ); + return unwrap(response.data); + }, + + updateImportLoadingStatus: async ( + scheduleId: string, + payload: { bookingIds: string[]; loadingStatus: LoadingStatus }, + ): Promise => { + const response = await client.patch( + URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_LOADING_STATUS(scheduleId), + payload, + ); + return unwrap(response.data); + }, + getImportDjiboutiOperation: async ( scheduleId: string, ): Promise => { diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 2c9a54dbd..19926a69e 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -453,6 +453,21 @@ export interface ImportDjiboutiDocumentRecord { notes?: string | null; } +export type LoadingStatus = "LOADED" | "UNLOADED"; + +export interface ImportLoadingBooking { + id: string; + reference: string | null; + customer: string | null; + weightTons: number; + loadingStatus: LoadingStatus; +} + +export interface ImportLoadingBookingsResponse { + count: number; + items: ImportLoadingBooking[]; +} + export interface ImportDjiboutiOperation { trainScheduleId: string; trainNumber: string | null; diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index fdaeee556..5e116d1ca 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -1013,6 +1013,7 @@ export interface InventoryFilter { containerId?: string; goodsId?: string; status?: InventoryStatus; + direction?: 'IMPORT' | 'EXPORT'; search?: string; dateFrom?: string; dateTo?: string; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 261f22d0b..3afc21a43 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -198,6 +198,11 @@ export enum TrainSetWagonStatus { Departed = "DEPARTED", } +export enum LoadingStatus { + Loaded = "LOADED", + Unloaded = "UNLOADED", +} + export enum WagonStatus { Available = "AVAILABLE", Assigned = "ASSIGNED", From 782f4184ef0e344df6c7a1fc9643f5289886120c Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 13:49:55 +0000 Subject: [PATCH 008/122] fix --- ...000000004-AddLastMileVehicleAssignments.ts | 39 +++++++++++++++++++ .../1890000000005-AddDriverGender.ts | 24 ++++++++++++ .../modules/drivers/dto/create-driver.dto.ts | 6 ++- .../modules/drivers/entities/driver.entity.ts | 9 +++++ .../last-mile-vehicle-assignment.entity.ts | 30 ++++++++++++++ .../last-mile/entities/last-mile.entity.ts | 4 ++ .../src/components/fleet/FleetFormDialog.tsx | 23 ++++++++++- .../src/pages/fleet/config/drivers.ts | 9 +++++ 8 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1890000000004-AddLastMileVehicleAssignments.ts create mode 100644 apps/edr-freight-api/src/migrations/1890000000005-AddDriverGender.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts diff --git a/apps/edr-freight-api/src/migrations/1890000000004-AddLastMileVehicleAssignments.ts b/apps/edr-freight-api/src/migrations/1890000000004-AddLastMileVehicleAssignments.ts new file mode 100644 index 000000000..7c1413617 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000004-AddLastMileVehicleAssignments.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Allow more than one vehicle per last-mile delivery. Junction table joins + * last_mile ⇄ vehicles; existing single vehicle_id values are backfilled as + * the first assignment so nothing is lost. + */ +export class AddLastMileVehicleAssignments1890000000004 implements MigrationInterface { + name = "AddLastMileVehicleAssignments1890000000004"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.last_mile_vehicle_assignments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + last_mile_id uuid NOT NULL REFERENCES freight.last_mile(id) ON DELETE CASCADE, + vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT "UQ_LAST_MILE_VEHICLE" UNIQUE (last_mile_id, vehicle_id) + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_LM_VEHICLE_ASSIGNMENTS_VEHICLE" + ON freight.last_mile_vehicle_assignments (vehicle_id) + `); + // Backfill: existing single-vehicle assignments become the first row + await queryRunner.query(` + INSERT INTO freight.last_mile_vehicle_assignments (last_mile_id, vehicle_id) + SELECT id, vehicle_id FROM freight.last_mile + WHERE vehicle_id IS NOT NULL AND deleted_at IS NULL + ON CONFLICT (last_mile_id, vehicle_id) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.last_mile_vehicle_assignments`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000005-AddDriverGender.ts b/apps/edr-freight-api/src/migrations/1890000000005-AddDriverGender.ts new file mode 100644 index 000000000..2ec26e034 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000005-AddDriverGender.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Store the driver's gender. Prefilled from the Fayda VERIFY response + * (Male/Female) but editable; nullable so existing rows and manual, + * non-Fayda driver records stay valid. + */ +export class AddDriverGender1890000000005 implements MigrationInterface { + name = "AddDriverGender1890000000005"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.drivers + ADD COLUMN IF NOT EXISTS gender varchar + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.drivers + DROP COLUMN IF EXISTS gender + `); + } +} diff --git a/apps/edr-freight-api/src/modules/drivers/dto/create-driver.dto.ts b/apps/edr-freight-api/src/modules/drivers/dto/create-driver.dto.ts index 4bf1d640d..c2f0bca81 100644 --- a/apps/edr-freight-api/src/modules/drivers/dto/create-driver.dto.ts +++ b/apps/edr-freight-api/src/modules/drivers/dto/create-driver.dto.ts @@ -1,5 +1,5 @@ import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray, IsBoolean } from 'class-validator'; -import { DriverStatus } from '../entities/driver.entity'; +import { DriverStatus, DriverGender } from '../entities/driver.entity'; export class CreateDriverDto { @IsString() @@ -20,6 +20,10 @@ export class CreateDriverDto { @IsDateString() dateOfBirth!: string; + @IsOptional() + @IsEnum(DriverGender) + gender?: DriverGender; + @IsDateString() licenseExpiryDate!: string; diff --git a/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts b/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts index c345938a4..889b688cb 100644 --- a/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts +++ b/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts @@ -8,6 +8,12 @@ export enum DriverStatus { ON_LEAVE = 'ON_LEAVE', } +export enum DriverGender { + MALE = 'MALE', + FEMALE = 'FEMALE', + OTHER = 'OTHER', +} + @Entity({ name: 'drivers', schema: 'freight' }) export class Driver extends BaseEntity { @Column({ name: 'license_number', unique: true, nullable: true }) @@ -28,6 +34,9 @@ export class Driver extends BaseEntity { @Column({ name: 'date_of_birth', type: 'date', nullable: true }) dateOfBirth?: Date; + @Column({ type: 'varchar', nullable: true }) + gender?: DriverGender | null; + @Column({ name: 'license_expiry_date', type: 'date', nullable: true }) licenseExpiryDate?: Date; diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts new file mode 100644 index 000000000..0ba0f7d06 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts @@ -0,0 +1,30 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm'; + +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { LastMile } from './last-mile.entity'; + +/** + * One row per vehicle assigned to a last-mile delivery. A delivery can be + * served by several vehicles at once (multi-truck bookings); the legacy + * `last_mile.vehicle_id` column keeps pointing at the first assignment for + * backward compatibility. + */ +@Entity({ name: 'last_mile_vehicle_assignments', schema: 'freight' }) +@Unique(['lastMileId', 'vehicleId']) +@Index(['vehicleId']) +export class LastMileVehicleAssignment extends BaseEntity { + @Column({ name: 'last_mile_id', type: 'uuid' }) + lastMileId!: string; + + @ManyToOne(() => LastMile, (lm) => lm.vehicleAssignments, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'last_mile_id' }) + lastMile?: LastMile; + + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { nullable: false, eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index 85d01b7f0..1f8bda8fc 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm import { Booking } from '../../bookings/entities/booking.entity'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; import { LastMileContainerAllocation } from './last-mile-container-allocation.entity'; +import { LastMileVehicleAssignment } from './last-mile-vehicle-assignment.entity'; export const LAST_MILE_STATUSES = [ 'PAYMENT_PENDING', @@ -57,4 +58,7 @@ export class LastMile extends BaseEntity { @OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile) containerAllocations?: LastMileContainerAllocation[]; + + @OneToMany(() => LastMileVehicleAssignment, (va) => va.lastMile) + vehicleAssignments?: LastMileVehicleAssignment[]; } diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx index c75e8c7b3..82a89c33e 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx @@ -45,6 +45,24 @@ export interface FleetFormDialogProps { verifyWithFayda?: boolean; } +// Fayda returns gender as "Male"/"Female"; snap it onto the form's uppercase +// option values (MALE/FEMALE/OTHER) so the Select prefills instead of rendering +// blank. Unknown/empty values fall through to undefined (field left untouched). +const normalizeGender = (raw?: string): string | undefined => { + const up = (raw ?? "").trim().toUpperCase(); + if (up === "MALE" || up === "M") return "MALE"; + if (up === "FEMALE" || up === "F") return "FEMALE"; + return up ? "OTHER" : undefined; +}; + +// Fayda may return the birthdate as "2001/12/01" (slashes), but the date input +// and validator expect ISO "2001-12-01". Normalize separators + trim to 10 chars +// so the DOB field prefills instead of silently staying blank. +const normalizeBirthdate = (raw?: string): string | undefined => { + const iso = (raw ?? "").trim().replace(/\//g, "-").slice(0, 10); + return /^\d{4}-\d{2}-\d{2}$/.test(iso) ? iso : undefined; +}; + const buildInitialValues = ( fields: FleetFormFieldDef[], emptyValues: Record, @@ -131,13 +149,16 @@ const FleetFormDialog = ({ } const nameParts = (result.fullName ?? "").trim().split(/\s+/).filter(Boolean); const [firstName, ...rest] = nameParts; + const gender = normalizeGender(result.gender); + const dateOfBirth = normalizeBirthdate(result.birthdate); setValues((current) => ({ ...current, ...(firstName ? { firstName } : {}), ...(rest.length ? { lastName: rest.join(" ") } : {}), ...(result.email ? { email: result.email } : {}), ...(result.phoneNumber ? { phoneNumber: result.phoneNumber } : {}), - ...(result.birthdate ? { dateOfBirth: result.birthdate } : {}), + ...(dateOfBirth ? { dateOfBirth } : {}), + ...(gender ? { gender } : {}), faydaVerified: true, ...(result.iamUserId ? { faydaSub: result.iamUserId } : {}), })); diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts index 6331e5a50..2f3fb5aeb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts @@ -8,6 +8,12 @@ const DRIVER_STATUS_OPTIONS = [ { label: "On leave", value: "ON_LEAVE" }, ]; +const DRIVER_GENDER_OPTIONS = [ + { label: "Male", value: "MALE" }, + { label: "Female", value: "FEMALE" }, + { label: "Other", value: "OTHER" }, +]; + export const driversConfig: FleetResourceConfig = { slug: "drivers", label: "Drivers", @@ -37,6 +43,7 @@ export const driversConfig: FleetResourceConfig = { { id: "lastName", header: "Last Name", accessorKey: "lastName", format: "code", size: 120 }, { id: "email", header: "Email", accessorKey: "email", format: "code", size: 180 }, { id: "phoneNumber", header: "Phone", accessorKey: "phoneNumber", format: "code", size: 120 }, + { id: "gender", header: "Gender", accessorKey: "gender", format: "code", size: 90 }, { id: "licenseExpiryDate", header: "License Expiry", accessorKey: "licenseExpiryDate", format: "code", size: 130 }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 }, { id: "faydaVerified", header: "Fayda", accessorKey: "faydaVerified", format: "verifiedBadge", size: 90 }, @@ -48,6 +55,7 @@ export const driversConfig: FleetResourceConfig = { { name: "email", label: "Email", type: "email", required: true }, { name: "phoneNumber", label: "Phone Number", type: "text", required: true }, { name: "dateOfBirth", label: "Date of Birth", type: "date", required: true }, + { name: "gender", label: "Gender", type: "select", options: DRIVER_GENDER_OPTIONS }, { name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true }, { name: "status", label: "Status", type: "select", required: true, options: DRIVER_STATUS_OPTIONS }, { name: "vehicleTypesAuthorized", label: "Authorized Vehicle Types", type: "multiselect", options: VEHICLE_TYPE_OPTIONS }, @@ -62,6 +70,7 @@ export const driversConfig: FleetResourceConfig = { email: "", phoneNumber: "", dateOfBirth: "", + gender: "", licenseExpiryDate: "", status: "ACTIVE", vehicleTypesAuthorized: [], From 57d3752ea5f4ec6d9d9f13bc109fd3b6c428f17b Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 13:53:17 +0000 Subject: [PATCH 009/122] fix issues --- ...000000-SimplifyRatesAndWeightLimitRules.ts | 9 +- .../interfaces/rates.repository.interface.ts | 1 + .../repositories/rates.repository.ts | 2 + .../rule-engine/services/rates.service.ts | 4 +- .../services/weight-limit-rules.service.ts | 39 +++++++-- .../src/seed/pricing-data.seeder.ts | 41 ++-------- .../ruleEngine/RuleEngineFormDialog.tsx | 16 +++- .../src/pages/ruleEngine/config/resources.ts | 82 +++++++++++++++---- .../backoffice/src/services/rates.service.ts | 2 - 9 files changed, 132 insertions(+), 64 deletions(-) diff --git a/apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts b/apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts index c03e70bbb..7a661bc7c 100644 --- a/apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts +++ b/apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts @@ -29,7 +29,8 @@ export class SimplifyRatesAndWeightLimitRules1900000000000 implements MigrationI PARTITION BY rate_type, COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), - COALESCE(trade_direction, '') + COALESCE(trade_direction, ''), + rate_unit ORDER BY created_at DESC, id DESC ) AS rn FROM freight.rates @@ -83,13 +84,17 @@ export class SimplifyRatesAndWeightLimitRules1900000000000 implements MigrationI await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_to;`); // ── 3. One-rate-per-pattern partial unique indexes ───────────────────── + // The unit is part of the identity so a surcharge can legitimately carry two + // rows that bill different ways (e.g. reefer PER_CONTAINER + reefer PER_TON), + // while still blocking a true duplicate (same rateType + scope + unit). await queryRunner.query(` CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates ( rate_type, COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), - COALESCE(trade_direction, '') + COALESCE(trade_direction, ''), + rate_unit ) WHERE deleted_at IS NULL AND status <> 'SUPERSEDED'; `); diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts index 24b3c3626..96db214c4 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -6,6 +6,7 @@ export interface IRatesRepository { findLiveRates(): Promise; findByPattern(pattern: { rateType: string; + rateUnit: string; containerTypeId?: string | null; cargoTypeId?: string | null; tradeDirection?: string | null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index bbb5f2386..a7260f7f1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -30,6 +30,7 @@ export class RatesRepository implements IRatesRepository { */ findByPattern(pattern: { rateType: string; + rateUnit: string; containerTypeId?: string | null; cargoTypeId?: string | null; tradeDirection?: string | null; @@ -37,6 +38,7 @@ export class RatesRepository implements IRatesRepository { const qb = this.repo .createQueryBuilder('rate') .where('rate.rate_type = :rateType', { rateType: pattern.rateType }) + .andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit }) .andWhere('rate.status <> :superseded', { superseded: 'SUPERSEDED' }); if (pattern.containerTypeId) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 2a717366d..0027e18c1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -83,6 +83,7 @@ export class RatesService { */ private async assertNoDuplicatePattern(pattern: { rateType: string; + rateUnit: string; containerTypeId: string | null; cargoTypeId: string | null; tradeDirection: string | null; @@ -115,7 +116,7 @@ export class RatesService { }); const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']); - await this.assertNoDuplicatePattern({ rateType, containerTypeId, cargoTypeId, tradeDirection }); + await this.assertNoDuplicatePattern({ rateType, rateUnit, containerTypeId, cargoTypeId, tradeDirection }); return this.repository.create({ appliesTo, @@ -183,6 +184,7 @@ export class RatesService { // Guard the pattern uniqueness for the new identity, ignoring this row. await this.assertNoDuplicatePattern({ rateType, + rateUnit: updates.rateUnit, containerTypeId: updates.containerTypeId, cargoTypeId: updates.cargoTypeId, tradeDirection: updates.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts index d171f55aa..bbd042296 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto'; import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto'; import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; @@ -30,7 +30,7 @@ export class WeightLimitRulesService { const [data, total] = await this.repository.findAndCount({ where, relations: { containerType: true }, - order: { effectiveFrom: 'DESC' }, + order: { createdAt: 'DESC' }, skip: (page - 1) * pageSize, take: pageSize, }); @@ -44,26 +44,51 @@ export class WeightLimitRulesService { return entity; } + /** + * Reject a second rule for the same container + direction. One VGM limit per + * (container, direction) — otherwise the booking engine can't tell which + * applies. + */ + private async assertNoDuplicate( + containerTypeId: string, + tradeDirection: string, + ignoreId?: string, + ): Promise { + const existing = await this.repository.findByPattern(containerTypeId, tradeDirection, ignoreId); + if (existing) { + throw new ConflictException( + 'A weight limit rule for this container type and trade direction already exists. Edit the existing rule instead.', + ); + } + } + /** Create a new weight limit rule. */ async create(dto: CreateWeightLimitRuleDto): Promise { + await this.assertNoDuplicate(dto.containerTypeId, dto.tradeDirection); return this.repository.create({ containerTypeId: dto.containerTypeId, tradeDirection: dto.tradeDirection, maxVgmTons: dto.maxVgmTons, - effectiveFrom: new Date(dto.effectiveFrom), - effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : null, }); } /** Update an existing weight limit rule. */ async update(id: string, dto: UpdateWeightLimitRuleDto): Promise { - await this.findById(id); + const existing = await this.findById(id); const patch: Partial = {}; if (dto.containerTypeId !== undefined) patch.containerTypeId = dto.containerTypeId; if (dto.tradeDirection !== undefined) patch.tradeDirection = dto.tradeDirection; if (dto.maxVgmTons !== undefined) patch.maxVgmTons = dto.maxVgmTons; - if (dto.effectiveFrom !== undefined) patch.effectiveFrom = new Date(dto.effectiveFrom); - if (dto.effectiveTo !== undefined) patch.effectiveTo = new Date(dto.effectiveTo); + + // Re-check uniqueness when the identity (container/direction) changes. + if (dto.containerTypeId !== undefined || dto.tradeDirection !== undefined) { + await this.assertNoDuplicate( + patch.containerTypeId ?? existing.containerTypeId, + patch.tradeDirection ?? existing.tradeDirection, + id, + ); + } + const updated = await this.repository.update(id, patch); if (!updated) throw new NotFoundException(`Weight limit rule ${id} not found`); return updated; diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 02a05602e..14ccb3efb 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -319,37 +319,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { const twenty = await ctRepo.findOneByOrFail({ code: "20FT" }); const forty = await ctRepo.findOneByOrFail({ code: "40FT" }); - const base = new Date("2026-01-01"); - const rules = [ - { - containerTypeId: twenty.id, - tradeDirection: "IMPORT", - maxVgmTons: 26, - effectiveFrom: base, - isActive: true, - }, - { - containerTypeId: twenty.id, - tradeDirection: "EXPORT", - maxVgmTons: 26, - effectiveFrom: base, - isActive: true, - }, - { - containerTypeId: forty.id, - tradeDirection: "IMPORT", - maxVgmTons: 28, - effectiveFrom: base, - isActive: true, - }, - { - containerTypeId: forty.id, - tradeDirection: "EXPORT", - maxVgmTons: 28, - effectiveFrom: base, - isActive: true, - }, + { containerTypeId: twenty.id, tradeDirection: "IMPORT", maxVgmTons: 26 }, + { containerTypeId: twenty.id, tradeDirection: "EXPORT", maxVgmTons: 26 }, + { containerTypeId: forty.id, tradeDirection: "IMPORT", maxVgmTons: 28 }, + { containerTypeId: forty.id, tradeDirection: "EXPORT", maxVgmTons: 28 }, ]; for (const rule of rules) { @@ -361,10 +335,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { }); if (existing) { - await wlRepo.update(existing.id, { - maxVgmTons: rule.maxVgmTons, - effectiveFrom: rule.effectiveFrom, - }); + await wlRepo.update(existing.id, { maxVgmTons: rule.maxVgmTons }); } else { await wlRepo.insert(rule); } @@ -409,7 +380,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { ctByCode: Map, cargoByCode: Map, ): Promise { - const effectiveFrom = new Date("2026-01-01"); const now = new Date(); // Each rate is self-describing: `appliesTo` + `trigger` decide how the @@ -479,7 +449,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { proposedByStaffId: STAFF_USER_ID, approvedByCeoId: CEO_USER_ID, approvedAt: now, - effectiveFrom, })) .filter((d) => !existingBySignature.has(signature(d))); diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index 6ae71cdc0..8553bcf0b 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -179,7 +179,16 @@ const RuleEngineFormDialog = ({ const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]); const setField = (name: string, value: unknown) => { - setValues((current) => ({ ...current, [name]: value })); + setValues((current) => { + const next = { ...current, [name]: value }; + // Changing what a rate applies to (or its surcharge trigger) can invalidate + // the previously-chosen unit — reset it so the admin re-picks from the new + // allowed set instead of submitting a stale, rejected unit. + if ((name === "appliesTo" || name === "trigger") && "rateUnit" in current) { + next.rateUnit = ""; + } + return next; + }); }; const handleSubmit = (event: React.FormEvent) => { @@ -250,6 +259,9 @@ const RuleEngineFormDialog = ({ const label = ; if (field.type === "select") { + // Dynamic options (e.g. rate unit) resolve from the live form values so + // the choices track the other fields the admin has picked. + const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []); return ( { + setStatusFilter(v); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + clearable + searchable + radius="lg" + style={{ minWidth: 200 }} + /> + { + setFreightTypeFilter(v); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + clearable + radius="lg" + style={{ minWidth: 170 }} + /> + + - {isOperationsTab ? ( - - - - setOperationsSubTab((value as OperationsSubTab) ?? "ready") - } - > - - Ready to allocate - On train / scheduled - - - {isError ? ( - - ) : operationsSubTab === "ready" ? ( - - ) : ( - - )} - - - ) : showEmpty ? ( + {showEmpty ? ( Date: Fri, 3 Jul 2026 14:03:02 +0000 Subject: [PATCH 011/122] =?UTF-8?q?approve-delivery=20exit-gate=20fix=20+?= =?UTF-8?q?=20Import=20Loading=20Confirmation=20frontend=20panel=20?= =?UTF-8?q?=E2=80=94=20done=20this=20session,=20not=20yet=20committed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../warehouses/warehouse-inventory.service.ts | 16 ++ .../ImportLoadingConfirmationPanel.tsx | 172 ++++++++++++++++++ .../TrainScheduleV2DetailPage.tsx | 25 +++ 3 files changed, 213 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/ImportLoadingConfirmationPanel.tsx diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 3b27c7213..ac6b71c89 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -2033,6 +2033,22 @@ export class WarehouseInventoryService { const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime); if (isTruckLeaving) { await this.invoices.assertClearanceAllowed(id); + + if (item.bookingId) { + const [truckInfo]: Array<{ customerTruckAssignedAt: string | null }> = + await this.dataSource.query( + `SELECT customer_truck_assigned_at AS "customerTruckAssignedAt" + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [item.bookingId], + ); + const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt); + if (usesCustomerTruck && !this.extractCustomerDeliveryApproval(item.notes)) { + throw new BadRequestException( + 'Customer must approve delivery (sign the handover) before the exit paper can be generated', + ); + } + } } const releaseDate = isTruckLeaving ? dto.releaseDate ? new Date(dto.releaseDate) : new Date() diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ImportLoadingConfirmationPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ImportLoadingConfirmationPanel.tsx new file mode 100644 index 000000000..ab1d907e6 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ImportLoadingConfirmationPanel.tsx @@ -0,0 +1,172 @@ +import { useMemo, useState } from "react"; +import { PackageCheck } from "lucide-react"; +import { Badge, Button, Checkbox, Group, Loader, Paper, Stack, Text } from "@mantine/core"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import toast from "react-hot-toast"; + +import { api } from "@/services/api"; +import type { + ImportLoadingBooking, + ImportLoadingBookingsResponse, + LoadingStatus, +} from "@/types/trainScheduling"; + +function ImportLoadingBookingRow({ + booking, + selected, + onToggle, +}: { + booking: ImportLoadingBooking; + selected: boolean; + onToggle: () => void; +}) { + return ( + + + + + + + {booking.reference ?? booking.id} + + + {booking.loadingStatus} + + + + {booking.customer ?? "Unknown customer"} + + + {booking.weightTons}T + + + + ); +} + +export function ImportLoadingConfirmationPanel({ + scheduleId, + items, + isLoading, +}: { + scheduleId: string; + items: ImportLoadingBooking[]; + isLoading?: boolean; +}) { + const [selectedIds, setSelectedIds] = useState([]); + const queryClient = useQueryClient(); + + const updateStatus = useMutation< + ImportLoadingBookingsResponse, + Error, + { id: string; bookingIds: string[]; loadingStatus: LoadingStatus } + >({ + ...api.trainScheduling.updateImportLoadingStatus.mutationOptions(), + onSuccess: () => { + setSelectedIds([]); + queryClient.invalidateQueries({ + queryKey: api.trainScheduling.importLoadingBookings.queryKey({ id: scheduleId }), + }); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Could not update loading status"); + }, + }); + + const toggle = (id: string) => { + setSelectedIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], + ); + }; + + const allIds = useMemo(() => items.map((b) => b.id), [items]); + + if (isLoading) { + return ( + + + + Loading import bookings… + + + ); + } + + if (!items.length) { + return ( + + + No paid import bookings with wagons allocated on this schedule + + + ); + } + + return ( + + + + Import bookings ({items.length}) + + + + + + + + + {items.map((booking) => ( + toggle(booking.id)} + /> + ))} + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 9319e6a01..c02eb2c39 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -42,6 +42,7 @@ import { } from "@/components/trainScheduling/containerPlacement.util"; import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid"; import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; +import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel"; import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep"; @@ -141,6 +142,13 @@ export default function TrainScheduleV2DetailPage() { }, }); + const importLoadingQuery = useQuery( + api.trainScheduling.importLoadingBookings.queryOptions({ + input: { id: scheduleId ?? "" }, + enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"), + }), + ); + const eligibleFilters = useMemo( () => schedule @@ -951,6 +959,23 @@ export default function TrainScheduleV2DetailPage() { ]} /> + {schedule?.direction === "IMPORT" ? ( + + + Import loading confirmation + + Paid import bookings with a wagon allocated on this schedule. Marking loaded/unloaded + is tracking only — it does not block dispatch. + + + + + ) : null} + {gatepassApplies ? ( From e14e02e7f2dc4a2512f7ec0ce36d8e4714859a0f Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 14:07:43 +0000 Subject: [PATCH 012/122] fix --- .../1890000000006-AddDriverFaydaSubUnique.ts | 23 ++++++++++++ .../src/modules/drivers/drivers.service.ts | 37 ++++++++++++++++++- .../modules/drivers/entities/driver.entity.ts | 5 ++- .../src/components/fleet/FleetFormDialog.tsx | 25 +++++++++---- .../src/pages/fleet/config/drivers.ts | 12 +++--- .../src/pages/fleet/config/resources.ts | 5 +++ 6 files changed, 91 insertions(+), 16 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1890000000006-AddDriverFaydaSubUnique.ts diff --git a/apps/edr-freight-api/src/migrations/1890000000006-AddDriverFaydaSubUnique.ts b/apps/edr-freight-api/src/migrations/1890000000006-AddDriverFaydaSubUnique.ts new file mode 100644 index 000000000..04c9a7000 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000006-AddDriverFaydaSubUnique.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Enforce one driver record per verified Fayda identity. A unique index on + * fayda_sub blocks a second driver from being created against the same Fayda + * OIDC subject; NULLs stay distinct so legacy/unverified rows are unaffected. + */ +export class AddDriverFaydaSubUnique1890000000006 implements MigrationInterface { + name = "AddDriverFaydaSubUnique1890000000006"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB" + ON freight.drivers (fayda_sub) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB" + `); + } +} diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts index 2464a26ac..cbfb6787d 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { CreateDriverDto } from './dto/create-driver.dto'; @@ -13,6 +13,12 @@ export class DriversService { ) {} async create(dto: CreateDriverDto): Promise { + if (dto.faydaVerified !== true) { + throw new BadRequestException( + 'Driver identity must be verified with Fayda before saving', + ); + } + const existing = await this.driverRepo.findOne({ where: [ { licenseNumber: dto.licenseNumber }, @@ -33,6 +39,17 @@ export class DriversService { } } + if (dto.faydaSub) { + const dupe = await this.driverRepo.findOne({ + where: { faydaSub: dto.faydaSub }, + }); + if (dupe) { + throw new ConflictException( + 'A driver is already registered for this Fayda identity', + ); + } + } + const driver = this.driverRepo.create(dto); return this.driverRepo.save(driver); } @@ -106,7 +123,25 @@ export class DriversService { } } + if (dto.faydaSub && dto.faydaSub !== driver.faydaSub) { + const dupe = await this.driverRepo.findOne({ + where: { faydaSub: dto.faydaSub }, + }); + if (dupe) { + throw new ConflictException( + 'A driver is already registered for this Fayda identity', + ); + } + } + Object.assign(driver, dto); + + if (driver.faydaVerified !== true) { + throw new BadRequestException( + 'Driver identity must be verified with Fayda before saving', + ); + } + return this.driverRepo.save(driver); } diff --git a/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts b/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts index 889b688cb..58a08b849 100644 --- a/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts +++ b/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts @@ -64,7 +64,8 @@ export class Driver extends BaseEntity { @Column({ name: 'fayda_verified', type: 'boolean', default: false, nullable: true }) faydaVerified?: boolean; - /** Fayda OIDC subject the identity was verified against. */ - @Column({ name: 'fayda_sub', type: 'varchar', nullable: true }) + /** Fayda OIDC subject the identity was verified against. Unique — one driver + * record per verified Fayda identity (NULLs allowed for legacy/unverified). */ + @Column({ name: 'fayda_sub', type: 'varchar', unique: true, nullable: true }) faydaSub?: string | null; } diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx index 82a89c33e..f07659dd8 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx @@ -258,6 +258,12 @@ const FleetFormDialog = ({ }, [fields]); const handleSubmit = () => { + // Hard gate: a driver record cannot be saved until its identity is verified + // with Fayda. Mirrored server-side in DriversService. + if (verifyWithFayda && !faydaVerified) { + setFaydaError("Verify the driver's identity with Fayda before saving."); + return; + } if (!validate()) return; const payload = Object.fromEntries( Object.entries(values) @@ -278,6 +284,9 @@ const FleetFormDialog = ({ const renderField = (field: FleetFormFieldDef) => { const value = values[field.name]; const error = errors[field.name]; + // Fayda-owned identity fields (name/email/phone/DOB/gender) are populated + // only by verification and never hand-edited. + const isDisabled = Boolean(field.disabled || field.faydaLocked); if (field.type === "select") { return ( @@ -297,7 +306,7 @@ const FleetFormDialog = ({ } error={error} searchable - disabled={selectOptionsLoading} + disabled={selectOptionsLoading || isDisabled} rightSection={ selectOptionsLoading ? ( @@ -327,7 +336,7 @@ const FleetFormDialog = ({ error={error} searchable clearable - disabled={selectOptionsLoading} + disabled={selectOptionsLoading || isDisabled} rightSection={ selectOptionsLoading ? ( @@ -351,7 +360,7 @@ const FleetFormDialog = ({ })) } error={error} - disabled={field.disabled} + disabled={isDisabled} /> ); } @@ -371,7 +380,7 @@ const FleetFormDialog = ({ } error={error} minRows={3} - disabled={field.disabled} + disabled={isDisabled} /> ); } @@ -391,7 +400,7 @@ const FleetFormDialog = ({ })) } error={error} - disabled={field.disabled} + disabled={isDisabled} description={field.description || "Select a date"} rightSection={ @@ -429,7 +438,7 @@ const FleetFormDialog = ({ })) } error={error} - disabled={field.disabled} + disabled={isDisabled} /> ); }; @@ -457,7 +466,8 @@ const FleetFormDialog = ({ ) : ( - Verify the driver's identity with Fayda to prefill their details. + Identity must be verified with Fayda before this driver can be + saved. )} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts index 2f3fb5aeb..ec242906e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts @@ -50,12 +50,12 @@ export const driversConfig: FleetResourceConfig = { ], formFields: [ { name: "licenseNumber", label: "License Number", type: "text", required: true }, - { name: "firstName", label: "First Name", type: "text", required: true }, - { name: "lastName", label: "Last Name", type: "text", required: true }, - { name: "email", label: "Email", type: "email", required: true }, - { name: "phoneNumber", label: "Phone Number", type: "text", required: true }, - { name: "dateOfBirth", label: "Date of Birth", type: "date", required: true }, - { name: "gender", label: "Gender", type: "select", options: DRIVER_GENDER_OPTIONS }, + { name: "firstName", label: "First Name", type: "text", required: true, faydaLocked: true }, + { name: "lastName", label: "Last Name", type: "text", required: true, faydaLocked: true }, + { name: "email", label: "Email", type: "email", required: true, faydaLocked: true }, + { name: "phoneNumber", label: "Phone Number", type: "text", required: true, faydaLocked: true }, + { name: "dateOfBirth", label: "Date of Birth", type: "date", required: true, faydaLocked: true }, + { name: "gender", label: "Gender", type: "select", options: DRIVER_GENDER_OPTIONS, faydaLocked: true }, { name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true }, { name: "status", label: "Status", type: "select", required: true, options: DRIVER_STATUS_OPTIONS }, { name: "vehicleTypesAuthorized", label: "Authorized Vehicle Types", type: "multiselect", options: VEHICLE_TYPE_OPTIONS }, 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 c38c0c118..29a8795e7 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 @@ -40,6 +40,11 @@ export interface FleetResourceColumn { export interface FleetFormFieldDef extends FormFieldDef { dynamicOptions?: FleetDynamicOptions; noneOption?: boolean; + /** + * Field is owned by the Fayda identity — populated only by verification and + * never hand-edited. Rendered disabled in the form. + */ + faydaLocked?: boolean; } export interface FleetListFilterDef { From c2535bec5e6063ca7c47a16f6e572cace333791e Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 14:14:35 +0000 Subject: [PATCH 013/122] fix --- .../src/components/fleet/FleetFormDialog.tsx | 14 +++++++------- .../backoffice/src/pages/fleet/config/drivers.ts | 6 +++--- .../backoffice/src/pages/fleet/config/resources.ts | 5 +++++ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx index f07659dd8..f314ab35e 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { Loader2, Calendar, ShieldCheck } from "lucide-react"; +import { Loader2, ShieldCheck } from "lucide-react"; import { Alert, Badge, @@ -14,7 +14,6 @@ import { Text, Textarea, TextInput, - ActionIcon, } from "@mantine/core"; import { @@ -238,6 +237,12 @@ const FleetFormDialog = ({ const date = new Date(stringValue + "T00:00:00Z"); if (isNaN(date.getTime())) { next[field.name] = `${field.label} is not a valid date`; + } else if (field.dateBound === "future") { + const startOfToday = new Date(); + startOfToday.setUTCHours(0, 0, 0, 0); + if (date <= startOfToday) { + next[field.name] = `${field.label} must be in the future`; + } } else if (date > new Date()) { next[field.name] = `${field.label} cannot be in the future`; } @@ -402,11 +407,6 @@ const FleetFormDialog = ({ error={error} disabled={isDisabled} description={field.description || "Select a date"} - rightSection={ - - - - } size="sm" radius="md" styles={{ diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts index ec242906e..f240bda09 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts @@ -38,7 +38,7 @@ export const driversConfig: FleetResourceConfig = { faydaVerification: true, searchKeys: ["firstName", "lastName", "email", "phoneNumber", "licenseNumber", "status"], columns: [ - { id: "licenseNumber", header: "License Number", accessorKey: "licenseNumber", format: "code", size: 140 }, + { id: "licenseNumber", header: "Driver's License Number", accessorKey: "licenseNumber", format: "code", size: 180 }, { id: "firstName", header: "First Name", accessorKey: "firstName", format: "code", size: 120 }, { id: "lastName", header: "Last Name", accessorKey: "lastName", format: "code", size: 120 }, { id: "email", header: "Email", accessorKey: "email", format: "code", size: 180 }, @@ -49,14 +49,14 @@ export const driversConfig: FleetResourceConfig = { { id: "faydaVerified", header: "Fayda", accessorKey: "faydaVerified", format: "verifiedBadge", size: 90 }, ], formFields: [ - { name: "licenseNumber", label: "License Number", type: "text", required: true }, + { name: "licenseNumber", label: "Driver's License Number", type: "text", required: true }, { name: "firstName", label: "First Name", type: "text", required: true, faydaLocked: true }, { name: "lastName", label: "Last Name", type: "text", required: true, faydaLocked: true }, { name: "email", label: "Email", type: "email", required: true, faydaLocked: true }, { name: "phoneNumber", label: "Phone Number", type: "text", required: true, faydaLocked: true }, { name: "dateOfBirth", label: "Date of Birth", type: "date", required: true, faydaLocked: true }, { name: "gender", label: "Gender", type: "select", options: DRIVER_GENDER_OPTIONS, faydaLocked: true }, - { name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true }, + { name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true, dateBound: "future" }, { name: "status", label: "Status", type: "select", required: true, options: DRIVER_STATUS_OPTIONS }, { name: "vehicleTypesAuthorized", label: "Authorized Vehicle Types", type: "multiselect", options: VEHICLE_TYPE_OPTIONS }, { name: "address", label: "Address", type: "textarea" }, 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 29a8795e7..0e7c9934d 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 @@ -45,6 +45,11 @@ export interface FleetFormFieldDef extends FormFieldDef { * never hand-edited. Rendered disabled in the form. */ faydaLocked?: boolean; + /** + * Direction a `date` field is constrained to. "future" = must be after today + * (e.g. a license expiry); "past" (default) = cannot be in the future. + */ + dateBound?: "past" | "future"; } export interface FleetListFilterDef { From 4a6e42ccd97960e2aee77e080ab2dfec0000f789 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 3 Jul 2026 17:18:07 +0300 Subject: [PATCH 014/122] fix: logger --- apps/edr-freight-api/src/app.module.ts | 39 ++++++++++++------- apps/edr-freight-api/src/logger.middleware.ts | 21 ++++++++++ 2 files changed, 45 insertions(+), 15 deletions(-) create mode 100644 apps/edr-freight-api/src/logger.middleware.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index f40e4f40f..f20184e59 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -1,4 +1,8 @@ -import { Module, OnApplicationBootstrap } from "@nestjs/common"; +import { + MiddlewareConsumer, + Module, + OnApplicationBootstrap, +} from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; import { ScheduleModule } from "@nestjs/schedule"; @@ -61,20 +65,21 @@ import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; -import { WagonsModule } from './modules/wagons/wagons.module'; -import { ContainersModule } from './modules/container-management/containers.module'; -import { CargoesModule } from './modules/cargoes/cargoes.module'; -import { RoutesModule } from './modules/routes/routes.module'; -import { WarehousesModule } from './modules/warehouses/warehouses.module'; -import { OverviewModule } from './modules/overview/overview.module'; -import { VehiclesModule } from './modules/vehicles/vehicles.module'; -import { DriversModule } from './modules/drivers/drivers.module'; -import { FuelModule } from './modules/fuel/fuel.module'; -import { MaintenanceModule } from './modules/maintenance/maintenance.module'; -import { FirstMileModule } from './modules/first-mile/first-mile.module'; -import { LastMileModule } from './modules/last-mile/last-mile.module'; -import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module'; -import { ImportOperationsModule } from './modules/import-operations/import-operations.module'; +import { WagonsModule } from "./modules/wagons/wagons.module"; +import { ContainersModule } from "./modules/container-management/containers.module"; +import { CargoesModule } from "./modules/cargoes/cargoes.module"; +import { RoutesModule } from "./modules/routes/routes.module"; +import { WarehousesModule } from "./modules/warehouses/warehouses.module"; +import { OverviewModule } from "./modules/overview/overview.module"; +import { VehiclesModule } from "./modules/vehicles/vehicles.module"; +import { DriversModule } from "./modules/drivers/drivers.module"; +import { FuelModule } from "./modules/fuel/fuel.module"; +import { MaintenanceModule } from "./modules/maintenance/maintenance.module"; +import { FirstMileModule } from "./modules/first-mile/first-mile.module"; +import { LastMileModule } from "./modules/last-mile/last-mile.module"; +import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module"; +import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; +import { LoggerMiddleware } from "./logger.middleware"; @Module({ imports: [ @@ -211,4 +216,8 @@ export class AppModule implements OnApplicationBootstrap { // bookings bill to. Idempotent — keyed by fixed IDs. await this.govCompaniesSeeder.run(); } + + configure(consumer: MiddlewareConsumer) { + consumer.apply(LoggerMiddleware).forRoutes("*"); + } } diff --git a/apps/edr-freight-api/src/logger.middleware.ts b/apps/edr-freight-api/src/logger.middleware.ts new file mode 100644 index 000000000..dd7532ec8 --- /dev/null +++ b/apps/edr-freight-api/src/logger.middleware.ts @@ -0,0 +1,21 @@ +import { Injectable, NestMiddleware, Logger } from "@nestjs/common"; +import { Request, Response, NextFunction } from "express"; + +@Injectable() +export class LoggerMiddleware implements NestMiddleware { + private readonly logger = new Logger("HTTP"); + + use(req: Request, res: Response, next: NextFunction) { + const start = Date.now(); + + res.on("finish", () => { + const duration = Date.now() - start; + + this.logger.log( + `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`, + ); + }); + + next(); + } +} From e881e8de842f194fc91b3a0c0e1cda219a3b723f Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 14:21:46 +0000 Subject: [PATCH 015/122] fix --- ...000000007-DriverUniquePartialSoftDelete.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/1890000000007-DriverUniquePartialSoftDelete.ts diff --git a/apps/edr-freight-api/src/migrations/1890000000007-DriverUniquePartialSoftDelete.ts b/apps/edr-freight-api/src/migrations/1890000000007-DriverUniquePartialSoftDelete.ts new file mode 100644 index 000000000..77d0a3043 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000007-DriverUniquePartialSoftDelete.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Make driver uniqueness soft-delete aware. The original table used plain + * column UNIQUE constraints (drivers_email_key, etc.) which count soft-deleted + * rows, so deleting a driver then re-adding the same email/phone/license/Fayda + * identity failed at the DB with a raw 500 — even though the service's own + * (deleted_at-excluding) duplicate check saw nothing. Replace them with partial + * unique indexes scoped to live rows (deleted_at IS NULL) so uniqueness matches + * what the service enforces and freed values become reusable after deletion. + */ +export class DriverUniquePartialSoftDelete1890000000007 implements MigrationInterface { + name = "DriverUniquePartialSoftDelete1890000000007"; + + public async up(queryRunner: QueryRunner): Promise { + // Drop the full-table unique constraints from CreateDriversTable... + await queryRunner.query(` + ALTER TABLE freight.drivers + DROP CONSTRAINT IF EXISTS drivers_email_key, + DROP CONSTRAINT IF EXISTS drivers_phone_number_key, + DROP CONSTRAINT IF EXISTS drivers_license_number_key + `); + // ...and the plain fayda_sub unique index from 1890000000006. + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB"`); + + // Re-add each as a partial unique index scoped to non-deleted rows. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_EMAIL_ACTIVE" + ON freight.drivers (email) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_PHONE_ACTIVE" + ON freight.drivers (phone_number) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_LICENSE_ACTIVE" + ON freight.drivers (license_number) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB_ACTIVE" + ON freight.drivers (fayda_sub) WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_EMAIL_ACTIVE"`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_PHONE_ACTIVE"`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_LICENSE_ACTIVE"`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB_ACTIVE"`); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB" + ON freight.drivers (fayda_sub) + `); + await queryRunner.query(` + ALTER TABLE freight.drivers + ADD CONSTRAINT drivers_email_key UNIQUE (email), + ADD CONSTRAINT drivers_phone_number_key UNIQUE (phone_number), + ADD CONSTRAINT drivers_license_number_key UNIQUE (license_number) + `); + } +} From 83887471a4fbc5ca4c198db2df95a753edc134be Mon Sep 17 00:00:00 2001 From: yaschalew Date: Fri, 3 Jul 2026 17:25:33 +0300 Subject: [PATCH 016/122] fix --- apps/edr-freight-api/.env.example | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 79fea03e9..49ea7c637 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -61,6 +61,7 @@ RABBITMQ_ENABLED=false RABBITMQ_URL=amqp://localhost:5672 SMS_QUEUE=sms_queue + # ── VeriFayda 2.0 (eSignet OIDC) identity verification ────────────────────── # Disabled by default; /fayda/verification/start returns 503 until enabled. FAYDA_ENABLED=false @@ -71,10 +72,13 @@ FAYDA_USERINFO_ENDPOINT= # Base64-encoded RSA private JWK used for the private_key_jwt client assertion FAYDA_PRIVATE_KEY_BASE64= # OAuth redirect_uri for MOBILE clients (must be registered with eSignet) -FAYDA_REDIRECT_URI= +FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete # OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset. -FAYDA_WEB_REDIRECT_URI= +FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback +CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer FAYDA_SCOPE=openid profile email phone address FAYDA_ACR_VALUES=mosip:idp:acr:generated-code FAYDA_CLAIMS_LOCALES=en am FAYDA_SESSION_TTL_MINUTES=10 +EXPIRATION_TIME=15 +ALGORITHM=RS256 \ No newline at end of file From 44317a6bbcd13eeb2c41f5e1a330258a0db12965 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 3 Jul 2026 14:39:47 +0000 Subject: [PATCH 017/122] Naming convention no digit or symbol allowed --- .../src/modules/warehouses/dto/allocation-rule.dto.ts | 3 ++- .../src/modules/warehouses/dto/create-warehouse.dto.ts | 3 ++- .../src/modules/warehouses/dto/fee-rule.dto.ts | 3 ++- .../src/components/warehouses/CreateWarehouseModal.tsx | 4 ++-- .../backoffice/src/components/warehouses/options.ts | 3 +++ .../backoffice/src/pages/warehouses/WarehouseRulesPage.tsx | 6 +++--- 6 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts index 43cd6f61a..8d18d2a1f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts @@ -1,9 +1,10 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsInt, IsOptional, IsString, Matches } from 'class-validator'; export class CreateAllocationRuleDto { @ApiProperty() @IsString() + @Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' }) name!: string; @ApiPropertyOptional({ default: 100 }) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts index 788c798bf..5a8025948 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator'; import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity'; @@ -7,6 +7,7 @@ export class CreateWarehouseDto { @ApiProperty() @IsString() @MaxLength(160) + @Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' }) name!: string; @ApiProperty() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts index e2c20901d..22e9f9491 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min, ValidateNested } from 'class-validator'; +import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Matches, Min, ValidateNested } from 'class-validator'; import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity'; @@ -25,6 +25,7 @@ export class FeeRuleTierDto { export class CreateFeeRuleDto { @ApiProperty() @IsString() + @Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' }) name!: string; @ApiProperty({ enum: FEE_RULE_TYPES }) diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx index ad8b4109d..6494906b4 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx @@ -14,7 +14,7 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse'; -import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options'; +import { extractErrorMessage, lettersOnly, statusOptions, warehouseTypeOptions } from './options'; interface CreateWarehouseModalProps { opened: boolean; @@ -120,7 +120,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh placeholder="Modjo Open Warehouse" required value={form.name} - onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }} + onChange={(e) => { const v = lettersOnly(e.currentTarget.value); setForm((f) => ({ ...f, name: v })); }} /> { }); }; +// Name fields (warehouse / fee rule / allocation rule) accept letters and spaces only — no numbers. +export const lettersOnly = (value: string) => value.replace(/[^A-Za-z\s]/g, ''); + export const extractErrorMessage = (error: unknown, fallback = 'Something went wrong') => { const responseData = (error as { response?: { data?: unknown } })?.response?.data; const data = responseData && typeof responseData === 'object' ? (responseData as Record) : undefined; diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx index 7d1dbc555..b5c44e4d4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx @@ -33,7 +33,7 @@ import { } from '@/hooks/useWarehouses'; import { api } from '@/services/api'; import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse'; -import { extractErrorMessage } from '@/components/warehouses/options'; +import { extractErrorMessage, lettersOnly } from '@/components/warehouses/options'; const FREIGHT = [ { value: 'CONTAINER', label: 'Container' }, @@ -253,7 +253,7 @@ function AllocationRules() { required value={form.name} onChange={(e) => { - const value = e.currentTarget.value; + const value = lettersOnly(e.currentTarget.value); setForm((f) => ({ ...f, name: value })); }} /> @@ -569,7 +569,7 @@ function FeeRules() { required value={form.name} onChange={(e) => { - const value = e.currentTarget.value; + const value = lettersOnly(e.currentTarget.value); setForm((f) => ({ ...f, name: value })); }} /> From 7eae1a920ed169a7a2285360e8c99a95bbf18a19 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 15:01:16 +0000 Subject: [PATCH 018/122] Add contract booking windows feature --- .../bookings/booking-transition.service.ts | 13 +- .../booking-window.service.ts | 77 +++++++- .../train-scheduling.controller.ts | 11 ++ .../train-scheduling.service.ts | 169 +++++++++++++++--- .../contracts/GlCreateBookingForm.tsx | 78 +++++++- .../backoffice/src/constants/URLS.ts | 2 + .../pages/customers/CustomerDetailPage.tsx | 8 +- .../TrainSchedulingGlobalRulesPage.tsx | 58 ++++-- .../backoffice/src/services/api.ts | 13 ++ .../src/services/trainScheduling.service.ts | 14 ++ .../backoffice/src/types/trainScheduling.ts | 19 ++ .../portal/src/constants/URLS.ts | 2 + .../components/UpcomingWindowsSection.tsx | 27 ++- .../pages/contracts/ContractDetailPage.tsx | 72 ++++++-- .../src/pages/contracts/NewShipmentPage.tsx | 69 ++++++- .../src/pages/contracts/booking-window.ts | 63 +++++++ .../portal/src/services/api.ts | 6 + .../portal/src/services/bookings.service.ts | 16 ++ 18 files changed, 637 insertions(+), 80 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 3e1ea3cd4..8423e9606 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -1052,16 +1052,11 @@ export class BookingTransitionService { // only reserve once both partners are FULLY_EXECUTED (handled inside). const fresh = await this.bookingsService.findById(booking.id); await this.bookingBatchService.acceptExportBooking(fresh); - } else if (booking.tradeDirection === "IMPORT") { - // Import bookings wait for their booking-day window cycle — the batch runs - // after staff document review, never at accept time. - } else if (booking.scheduledDate) { - this.bookingBatchService.enqueueRouteDayProcessing( - booking.originYardId, - booking.destinationYardId, - eatDay(new Date(booking.scheduledDate)), - ); } + // IMPORT and DOMESTIC bookings wait for their booking-day window cycle — the + // batch runs after the window closes + staff document review, never at accept + // time. (Legacy pre-migration schedules with no window phase are still served + // by the periodic legacy fill.) return this.bookingsService.findById(booking.id); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index f2fe5db07..da235c562 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -7,6 +7,7 @@ import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { NotificationsService } from '../notifications/notifications.service'; import { BookingBatchService } from './booking-batch.service'; import { TrainSchedulingService } from './train-scheduling.service'; import { BATCH_TIMEZONE } from './booking-batch.constants'; @@ -19,12 +20,13 @@ import { type BookingWindowConfig } from './booking-window.config'; * schedule row, so every transition is derived purely from the clock — a restart * resumes mid-phase with no loss (onModuleInit runs one tick immediately). * - * Import phases: PRE_WINDOW → OPEN (customers book) → DOC_REVIEW (staff accept - * documents) → PAYMENT (batch reserves in priority order, customers pay) → - * reopen same day | CLOSED_FOR_DAY | DONE (full → auto-finalized). + * Import & domestic phases: PRE_WINDOW → OPEN (customers book) → DOC_REVIEW + * (staff accept documents) → PAYMENT (batch reserves in priority order, customers + * pay) → reopen same day | CLOSED_FOR_DAY | DONE (full → auto-finalized). * Export phases: PRE_WINDOW → OPEN → DONE (no batch, no priority). - * Legacy/DOMESTIC schedules have windowPhase NULL and are served by the legacy - * fill (runBatchFill), which this tick invokes every 5th minute. + * Only PRE-MIGRATION rows have windowPhase NULL; those are served by the legacy + * fill (runBatchFill), which this tick invokes every 5th minute. New schedules of + * every direction get a window phase. */ @Injectable() export class BookingWindowService implements OnModuleInit { @@ -37,6 +39,7 @@ export class BookingWindowService implements OnModuleInit { private readonly trainSchedulesRepository: TrainSchedulesRepository, private readonly bookingBatchService: BookingBatchService, private readonly trainSchedulingService: TrainSchedulingService, + private readonly notifications: NotificationsService, ) {} async onModuleInit(): Promise { @@ -156,6 +159,7 @@ export class BookingWindowService implements OnModuleInit { await this.bookingBatchService.setWindow(schedule.id, 'OPEN'); schedule.bookingWindowStatus = 'OPEN'; } + await this.notifyWindowOpened(schedule); this.logger.log(`Export booking window opened for schedule ${schedule.id}`); return true; } @@ -193,6 +197,8 @@ export class BookingWindowService implements OnModuleInit { await this.bookingBatchService.setWindow(schedule.id, 'OPEN'); schedule.bookingWindowStatus = 'OPEN'; } + // Only announce the first opening of the day; reopen cycles don't re-notify. + if (schedule.bookingCycleNo === 1) await this.notifyWindowOpened(schedule); this.logger.log( `Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`, ); @@ -325,6 +331,67 @@ export class BookingWindowService implements OnModuleInit { } } + /** + * SMS + email every active-contract customer on this schedule's route when its + * booking window opens, so they can book from the portal home before it closes. + * Fire-and-forget; a failed notification never blocks the window transition. + */ + private async notifyWindowOpened(schedule: TrainSchedule): Promise { + try { + const rows: Array<{ phone: string | null; email: string | null }> = + await this.dataSource.query( + `SELECT DISTINCT + COALESCE(co.contact_person_phone, co.phone) AS phone, + COALESCE(co.email, co.general_manager_email) AS email + FROM freight.contract_routes cr + JOIN freight.contracts c + ON c.id = cr.contract_id + AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') + AND c.deleted_at IS NULL + JOIN freight.companies co ON co.id = c.company_id + WHERE cr.origin_yard_id = $1 + AND cr.destination_yard_id = $2 + AND cr.deleted_at IS NULL`, + [schedule.originStationId, schedule.destinationStationId], + ); + if (!rows.length) return; + + const closes = schedule.windowClosesAt + ? schedule.windowClosesAt.toLocaleString('en-GB', { timeZone: BATCH_TIMEZONE }) + : 'later today'; + const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', { + timeZone: BATCH_TIMEZONE, + }); + const msg = + `Booking is now open for the train departing ${depart}. ` + + `Book your shipment from the portal home page before ${closes} EAT.`; + + const seenPhone = new Set(); + const seenEmail = new Set(); + for (const r of rows) { + if (r.phone && !seenPhone.has(r.phone)) { + seenPhone.add(r.phone); + await this.notifications + .directSend('sms', r.phone, msg) + .catch((e) => this.logger.warn(`Window-open SMS failed: ${(e as Error).message}`)); + } + if (r.email && !seenEmail.has(r.email)) { + seenEmail.add(r.email); + await this.notifications + .directSend('email', r.email, msg) + .catch((e) => this.logger.warn(`Window-open email failed: ${(e as Error).message}`)); + } + } + this.logger.log( + `Notified ${seenPhone.size} phone / ${seenEmail.size} email contacts of open window for schedule ${schedule.id}`, + ); + } catch (err) { + this.logger.warn( + `notifyWindowOpened failed for ${schedule.id}: ${(err as Error).message}`, + ); + } + } + private async setPhase( schedule: TrainSchedule, patch: Partial< diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 8887fb4c6..268b0a0b9 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -70,6 +70,17 @@ export class TrainSchedulingController { return this.trainSchedulingService.getBookingWindowsForCompany(companyId); } + @Get("contracts/:contractId/booking-windows") + @ApiOperation({ + summary: + "Upcoming/open booking windows on a contract's routes — gates the booking form for customer + Ethiopian GL", + }) + getContractBookingWindows( + @Param("contractId", ParseUUIDPipe) contractId: string, + ) { + return this.trainSchedulingService.getBookingWindowsForContract(contractId); + } + @Get("global-rules") @TrainSchedulingView() @ApiOperation({ summary: "Get global train scheduling rules (singleton)" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index fbcb82142..3c4c614f8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -9,6 +9,7 @@ import { BadRequestException, ConflictException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -168,8 +169,27 @@ const DEFAULT_TRAIN_LIMITS: Required = { max20ftPairWeightDiffTons: 10, }; +/** Raw row shape for the booking-window queries (company- and contract-scoped). */ +interface BookingWindowRow { + schedule_id: string; + contract_id: string | null; + direction: string | null; + window_phase: string | null; + window_opens_at: Date | null; + window_closes_at: Date | null; + booking_window_status: string; + booking_cycle_no: number; + scheduled_departure_date: Date; + origin_label: string | null; + origin_code: string | null; + destination_label: string | null; + destination_code: string | null; +} + @Injectable() export class TrainSchedulingService { + private readonly logger = new Logger(TrainSchedulingService.name); + constructor( @InjectDataSource() private readonly dataSource: DataSource, @@ -248,7 +268,68 @@ export class TrainSchedulingService { if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes; if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes; if (dto.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes; - return this.dataSource.getRepository(TrainSchedulingGlobalRules).save(row); + + // Fields that change the STAMPED open/close times of a schedule. docReview/ + // payment/reopen are read live by the cron each tick, so they need no + // re-stamp; only the four below feed computeImport/ExportWindowTimes. + const windowTimingChanged = + dto.importWindowLeadDays != null || + dto.windowOpenHour != null || + dto.windowDurationHours != null || + dto.exportBookingLeadHours != null; + + const saved = await this.dataSource + .getRepository(TrainSchedulingGlobalRules) + .save(row); + + // The cron reads config fresh every tick, so derived timings (doc review, + // payment, reopen) take effect on the next tick with no restart. But each + // schedule's initial open/close times were FROZEN at creation — re-stamp the + // ones whose window has not opened yet so a config edit applies to them too. + if (windowTimingChanged) { + await this.restampPendingWindows(); + } + + return saved; + } + + /** + * Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has + * not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure + * in the future) using the CURRENT global-rules config. Schedules already OPEN or + * past their window are left untouched — customers may have booked against the + * times they were shown, so those stay frozen. Returns the count re-stamped. + */ + async restampPendingWindows(): Promise { + const cfg = await this.getWindowConfig(); + const now = new Date(); + const schedules = await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft, windowPhase: 'PRE_WINDOW' }, + { status: TrainScheduleStatusEnum.Scheduled, windowPhase: 'PRE_WINDOW' }, + ], + }); + + const repo = this.dataSource.getRepository(TrainSchedule); + let restamped = 0; + for (const s of schedules) { + if (!s.scheduledDepartureDate || s.scheduledDepartureDate <= now) continue; + const times = + s.direction === 'EXPORT' + ? computeExportWindowTimes(s.scheduledDepartureDate, cfg) + : computeImportWindowTimes(s.scheduledDepartureDate, cfg, now); + await repo.update(s.id, { + windowOpensAt: times.windowOpensAt, + windowClosesAt: times.windowClosesAt, + }); + restamped += 1; + } + if (restamped > 0) { + this.logger.log( + `Re-stamped booking windows for ${restamped} pending schedule(s) after a global-rules change`, + ); + } + return restamped; } /** @@ -383,24 +464,24 @@ export class TrainSchedulingService { // Effective capacity is capped by the weakest locomotive in the set. const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined; const departure = new Date(dto.scheduleDate); - // IMPORT/EXPORT trains start with a CLOSED customer window; the window engine - // opens it on schedule (import: booking day at 08:00 EAT; export: 24h lead). - // DOMESTIC keeps the legacy always-OPEN behavior (windowPhase stays NULL). + // Every schedule starts with a CLOSED customer window; the window engine opens + // it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT + // (opens at 08:00 EAT `importWindowLeadDays` before departure); EXPORT opens + // 24h before departure (FCFS). No schedule is ever always-open now. const windowCfg = await this.getWindowConfig(); const windowFields = - direction === 'IMPORT' + direction === 'EXPORT' ? { bookingWindowStatus: 'CLOSED', windowPhase: 'PRE_WINDOW', - ...computeImportWindowTimes(departure, windowCfg, new Date()), + ...computeExportWindowTimes(departure, windowCfg), } - : direction === 'EXPORT' - ? { - bookingWindowStatus: 'CLOSED', - windowPhase: 'PRE_WINDOW', - ...computeExportWindowTimes(departure, windowCfg), - } - : {}; + : { + // IMPORT and DOMESTIC share the import booking-day window cycle. + bookingWindowStatus: 'CLOSED', + windowPhase: 'PRE_WINDOW', + ...computeImportWindowTimes(departure, windowCfg, new Date()), + }; const schedule = manager.getRepository(TrainSchedule).create({ trainSetId: trainSet.id, routeId: route.id, @@ -2921,21 +3002,9 @@ export class TrainSchedulingService { * always open and need no announcement. */ async getBookingWindowsForCompany(companyId: string) { - const rows: Array<{ - schedule_id: string; - direction: string | null; - window_phase: string | null; - window_opens_at: Date | null; - window_closes_at: Date | null; - booking_window_status: string; - booking_cycle_no: number; - scheduled_departure_date: Date; - origin_label: string | null; - origin_code: string | null; - destination_label: string | null; - destination_code: string | null; - }> = await this.dataSource.query( + const rows: Array = await this.dataSource.query( `SELECT DISTINCT ts.id AS schedule_id, + cr.contract_id AS contract_id, ts.direction, ts.window_phase, ts.window_opens_at, @@ -2965,8 +3034,50 @@ export class TrainSchedulingService { ORDER BY ts.window_opens_at ASC NULLS LAST`, [companyId], ); - return rows.map((r) => ({ + return rows.map((r) => this.mapBookingWindowRow(r)); + } + + /** + * Upcoming/open booking windows on a single contract's routes. Used to gate the + * booking form for the customer AND Ethiopian GL (who books on the customer's + * behalf): no window row with isOpenNow=true → booking entry is hidden. + */ + async getBookingWindowsForContract(contractId: string) { + const rows: Array = await this.dataSource.query( + `SELECT DISTINCT ts.id AS schedule_id, + cr.contract_id AS contract_id, + ts.direction, + ts.window_phase, + ts.window_opens_at, + ts.window_closes_at, + ts.booking_window_status, + ts.booking_cycle_no, + ts.scheduled_departure_date, + oy.label AS origin_label, oy.code AS origin_code, + dy.label AS destination_label, dy.code AS destination_code + FROM freight.train_schedules ts + JOIN freight.contract_routes cr + ON cr.origin_yard_id = ts.origin_station_id + AND cr.destination_yard_id = ts.destination_station_id + AND cr.contract_id = $1 + AND cr.deleted_at IS NULL + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED') + AND ts.window_phase IS NOT NULL + AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') + AND ts.scheduled_departure_date >= now() + ORDER BY ts.window_opens_at ASC NULLS LAST`, + [contractId], + ); + return rows.map((r) => this.mapBookingWindowRow(r)); + } + + private mapBookingWindowRow(r: BookingWindowRow) { + return { scheduleId: r.schedule_id, + contractId: r.contract_id, direction: r.direction, windowPhase: r.window_phase, isOpenNow: r.window_phase === 'OPEN' && r.booking_window_status === 'OPEN', @@ -2977,7 +3088,7 @@ export class TrainSchedulingService { departureDate: r.scheduled_departure_date, origin: r.origin_label ?? r.origin_code ?? null, destination: r.destination_label ?? r.destination_code ?? null, - })); + }; } /** OPEN schedules a new booking may target (with rough remaining capacity). diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index f9bd7683d..ef9b2453f 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -58,6 +58,25 @@ import { StepLabel, } from "./gl-booking-form/form-ui"; +/** All booking-window times are communicated in East Africa Time. */ +const EAT_TZ = "Africa/Addis_Ababa"; + +function fmtWindowOpensAt(iso: string): string { + const date = new Date(iso).toLocaleDateString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + timeZone: EAT_TZ, + }); + const time = new Date(iso).toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: EAT_TZ, + }); + return `${date} · ${time}`; +} + interface UnitDraft { containerNumber: string; sealNumber: string; @@ -106,6 +125,33 @@ export default function GlCreateBookingForm() { enabled: Boolean(requestId), }); + // Same window-gating the customer sees: GL may only create a booking while a + // booking window is OPEN for one of the contract's routes. + const contractId = contract?.id ?? id; + const { data: bookingWindows, isLoading: windowsLoading } = useQuery({ + ...api.trainScheduling.contractBookingWindows.queryOptions({ + input: { contractId: contractId ?? "" }, + }), + enabled: Boolean(contractId), + }); + + const windowOpen = useMemo( + () => (bookingWindows ?? []).some((w) => w.isOpenNow), + [bookingWindows], + ); + + // Soonest future window across all routes, used for the "next window" notice. + const nextWindow = useMemo(() => { + const now = Date.now(); + return (bookingWindows ?? []) + .filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now) + .sort( + (a, b) => + new Date(a.windowOpensAt!).getTime() - + new Date(b.windowOpensAt!).getTime(), + )[0]; + }, [bookingWindows]); + const [scheduledDate, setScheduledDate] = useState(""); const [contractRouteId, setContractRouteId] = useState(null); const [notes, setNotes] = useState(""); @@ -314,12 +360,13 @@ export default function GlCreateBookingForm() { ); const canSubmit = + windowOpen && Boolean(scheduledDate) && (!needsRouteSelect || Boolean(contractRouteId)) && (isContainer ? containerLines.some((l) => l.units.length > 0) : bulkLines.length > 0); const handleSubmit = () => { - if (!scheduledDate || !contract) return; + if (!scheduledDate || !contract || !windowOpen) return; const payload: Freight.CreateBookingUnderContractDto = { scheduledDate, @@ -451,6 +498,33 @@ export default function GlCreateBookingForm() { ) : null} + {!windowsLoading && !windowOpen ? ( + } + title="Booking window is closed" + mb="lg" + > + GL can create a booking only while a window is open.{" "} + {nextWindow?.windowOpensAt ? ( + <> + Next window: {fmtWindowOpensAt(nextWindow.windowOpensAt)} EAT{" "} + for{" "} + + {nextWindow.origin ?? "Origin"} → {nextWindow.destination ?? "Destination"} + + . + + ) : ( + <>No upcoming booking window scheduled. + )} + + ) : null} + + {windowsLoading || windowOpen ? ( + <> ) : null} + + ) : null} ); } diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 083dd0efc..5bb9e3f7d 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -286,6 +286,8 @@ export const URL_CONSTANTS = { `/train-scheduling/schedules/${id}/assign-unassigned-booking`, BOOKING_WINDOW: (id: string) => `/train-scheduling/schedules/${id}/booking-window`, + CONTRACT_BOOKING_WINDOWS: (contractId: string) => + `/train-scheduling/contracts/${contractId}/booking-windows`, MARK_BOOKING_PAID: (bookingId: string) => `/train-scheduling/bookings/${bookingId}/mark-paid`, EXPIRE_BOOKING: (bookingId: string) => diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index 7f62ea3f5..ad888b1f6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -135,9 +135,11 @@ export default function CustomerDetailPage() { }), ); - const bookings = bookingsQuery.data ?? []; - const documents = documentsQuery.data ?? []; - const payments = paymentsQuery.data ?? []; + const bookings = Array.isArray(bookingsQuery.data) ? bookingsQuery.data : []; + const documents = Array.isArray(documentsQuery.data) + ? documentsQuery.data + : []; + const payments = Array.isArray(paymentsQuery.data) ? paymentsQuery.data : []; const invoices = invoicesQuery.data?.items ?? []; const invoiceTotal = invoicesQuery.data?.total ?? 0; const invoicePageCount = Math.max( diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx index d5209886d..efdeb9c2a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx @@ -29,22 +29,40 @@ export default function TrainSchedulingGlobalRulesPage() { }, [toast]); const handleSave = async () => { + // Every field must hold a real number — an empty box (cleared but not + // refilled) must not silently save as 0. Collect the numeric payload and + // reject if any value is blank or NaN. + const fields: (keyof TrainSchedulingGlobalRules)[] = [ + "maxTrainLengthMeters", + "maxTrainWeightTons", + "maxWagonsPerTrain", + "max20ftContainerWeightTons", + "max20ftPairWeightDiffTons", + "importWindowLeadDays", + "exportBookingLeadHours", + "windowOpenHour", + "windowDurationHours", + "docReviewMinutes", + "paymentWindowMinutes", + "reopenDelayMinutes", + ]; + const payload: Partial> = {}; + for (const key of fields) { + const raw = form[key]; + const num = raw === "" || raw == null ? NaN : Number(raw); + if (!Number.isFinite(num)) { + toast({ + title: "All fields are required — fill every value before saving.", + variant: "destructive", + }); + return; + } + payload[key] = num; + } + setSaving(true); try { - const updated = await trainSchedulingService.updateGlobalRules({ - maxTrainLengthMeters: Number(form.maxTrainLengthMeters), - maxTrainWeightTons: Number(form.maxTrainWeightTons), - maxWagonsPerTrain: Number(form.maxWagonsPerTrain), - max20ftContainerWeightTons: Number(form.max20ftContainerWeightTons), - max20ftPairWeightDiffTons: Number(form.max20ftPairWeightDiffTons), - importWindowLeadDays: Number(form.importWindowLeadDays), - exportBookingLeadHours: Number(form.exportBookingLeadHours), - windowOpenHour: Number(form.windowOpenHour), - windowDurationHours: Number(form.windowDurationHours), - docReviewMinutes: Number(form.docReviewMinutes), - paymentWindowMinutes: Number(form.paymentWindowMinutes), - reopenDelayMinutes: Number(form.reopenDelayMinutes), - }); + const updated = await trainSchedulingService.updateGlobalRules(payload); setForm(updated); toast({ title: "Train scheduling rules saved" }); } catch { @@ -71,6 +89,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, maxTrainLengthMeters: value })) } min={1} + clampBehavior="strict" disabled={loading} /> ({ ...current, maxTrainWeightTons: value })) } min={1} + clampBehavior="strict" disabled={loading} /> ({ ...current, maxWagonsPerTrain: value })) } min={1} + clampBehavior="strict" disabled={loading} /> @@ -135,6 +158,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, importWindowLeadDays: value })) } min={0} + clampBehavior="strict" disabled={loading} /> ({ ...current, exportBookingLeadHours: value })) } min={1} + clampBehavior="strict" disabled={loading} /> ({ ...current, docReviewMinutes: value })) } min={0} + clampBehavior="strict" disabled={loading} /> ({ ...current, paymentWindowMinutes: value })) } min={1} + clampBehavior="strict" disabled={loading} /> ({ ...current, reopenDelayMinutes: value })) } min={1} + clampBehavior="strict" disabled={loading} /> diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 0edb591ac..a49ec0617 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -44,6 +44,7 @@ import type { BatchBoardSchedule, BatchBoardScheduleDetail, BookableSchedule, + BookingWindow, CompositionRemovalEntry, CreateTrainSchedulePayload, EligibleContainerBookingsResponse, @@ -281,6 +282,18 @@ export const api = { ], ), + contractBookingWindows: endpoint<{ contractId: string }, BookingWindow[]>( + "train-scheduling", + "contract-booking-windows", + ({ contractId }) => + trainSchedulingService.getContractBookingWindows(contractId), + ({ contractId }) => [ + ...QUERY_KEYS.TRAIN_SCHEDULING.ROOT, + "contract-booking-windows", + contractId, + ], + ), + availableDays: endpoint< { originYardId?: string | null; destinationYardId?: string | null }, string[] diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts index 0d6ef6cc9..dd8784f25 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -6,6 +6,7 @@ import type { BatchBoardSchedule, BatchBoardScheduleDetail, BookableSchedule, + BookingWindow, AssignBookingsPayload, CompositionRemovalEntry, UnassignedBookingsResponse, @@ -106,6 +107,19 @@ export const trainSchedulingService = { return unwrap(response.data); }, + /** + * Booking windows for every route/schedule of a contract. A window with + * `isOpenNow === true` means GL may create a booking right now for that route. + */ + getContractBookingWindows: async ( + contractId: string, + ): Promise => { + const response = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.CONTRACT_BOOKING_WINDOWS(contractId), + ); + return unwrap(response.data); + }, + getBookableSchedules: async ( originYardId?: string, destinationYardId?: string, diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 2e8ec2b21..0cb30fe77 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -325,6 +325,25 @@ export interface BatchBoardScheduleDetail { allocationViolations: string[]; } +/** + * A booking window for one of a contract's routes/schedules. `isOpenNow === true` + * means a booking may be created right now for that route. Times are ISO strings; + * render them in EAT (Africa/Addis_Ababa). + */ +export interface BookingWindow { + scheduleId: string; + direction: string | null; + windowPhase: BookingWindowPhase | null; + isOpenNow: boolean; + windowOpensAt: string | null; + windowClosesAt: string | null; + bookingWindowStatus: string; + bookingCycleNo: number; + departureDate: string; + origin: string | null; + destination: string | null; +} + export interface WagonAllocationAttemptResult { assignedBookingIds: string[]; deferred: Array<{ id: string; reference: string; reason: string }>; diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index ce2c35c1a..a4e20b029 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -148,6 +148,8 @@ export const URL_CONSTANTS = { AVAILABLE_DAYS: "/api/train-scheduling/available-days", AVAILABLE_DAYS_FOR_CARGO: "/api/train-scheduling/available-days-for-cargo", MY_BOOKING_WINDOWS: "/api/train-scheduling/my-booking-windows", + CONTRACT_BOOKING_WINDOWS: (contractId: string) => + `/api/train-scheduling/contracts/${contractId}/booking-windows`, }, PAYMENTS: { diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx index fbb704292..2aa9adbbb 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx @@ -1,7 +1,7 @@ -import { Box, Group, Skeleton, Stack, Text } from "@mantine/core"; +import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core"; import { memo } from "react"; import { useNavigate } from "react-router-dom"; -import { ArrowRight, CalendarClock } from "lucide-react"; +import { ArrowRight, CalendarClock, PackagePlus } from "lucide-react"; import type { MyBookingWindow } from "@/services/bookings.service"; import { Card } from "./Card"; @@ -162,11 +162,7 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ borderRadius: 12, border: `1px solid ${w.isOpenNow ? "#CDEBDD" : BORDER}`, backgroundColor: w.isOpenNow ? "#F4FBF7" : undefined, - cursor: w.isOpenNow ? "pointer" : "default", }} - onClick={ - w.isOpenNow ? () => navigate("/contracts") : undefined - } > @@ -189,6 +185,25 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ + {w.isOpenNow && ( + + )} ))} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index f15200d49..97b7afb8b 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -61,6 +61,7 @@ import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBann import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; import { formatRateUnit } from "./new-contract-form/unit-rates"; import { getContractBookingAction } from "./contract-booking-action"; +import { closedWindowMessage, hasOpenWindow } from "./booking-window"; import { BORDER, ContractStatusBadge, @@ -198,6 +199,18 @@ export default function ContractDetailPage() { (r) => r.status === "PENDING" || r.status === "ACCEPTED", ); + // Booking windows for this contract's routes — gates the direct "New shipment + // booking" entry so the customer only sees it while a window is open. + // Refetched every minute so "Open now" flips without a manual reload. + const { data: bookingWindows = [] } = useQuery({ + ...api.bookings.getContractBookingWindows.queryOptions({ + input: { contractId: id! }, + refetchInterval: 60_000, + }), + enabled: !!id, + }); + const bookingWindowOpen = hasOpenWindow(bookingWindows); + const contractBookings = useMemo( () => (bookingsPage?.items ?? []).filter( @@ -370,17 +383,40 @@ export default function ContractDetailPage() { Request shipment )} - {canBookShipment && ( - - )} + {canBookShipment && + (bookingWindowOpen ? ( + + ) : ( + + + + + {closedWindowMessage(bookingWindows)} + + + + ))} {glPreparingBooking && ( Bookings under this contract - {canBookShipment && ( + {canBookShipment && bookingWindowOpen && ( + + + } + title="Booking is not open right now" + > + {closedWindowMessage(bookingWindows)} + + Come back when the booking window opens to book your shipment. + + + + + ); + } + return ; } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts b/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts new file mode 100644 index 000000000..44b97c741 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts @@ -0,0 +1,63 @@ +import type { MyBookingWindow } from "@/services/bookings.service"; + +/** All booking-window times are communicated in East Africa Time. */ +const TZ = "Africa/Addis_Ababa"; + +/** "Thu, 10 Jul, 08:00 EAT" — a full opening date/time in Addis Ababa time. */ +export function formatWindowOpensAt(iso: string): string { + const day = new Date(iso).toLocaleDateString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + timeZone: TZ, + }); + const time = new Date(iso).toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: TZ, + }); + return `${day}, ${time}`; +} + +/** True when at least one of the contract's windows is bookable right now. */ +export function hasOpenWindow(windows: MyBookingWindow[]): boolean { + return windows.some((w) => w.isOpenNow); +} + +/** + * The soonest upcoming (not-yet-open) window with a known opening time, so the + * customer can be told when to come back. Returns `null` when nothing upcoming + * carries an opening time. + */ +export function soonestUpcomingWindow( + windows: MyBookingWindow[], +): MyBookingWindow | null { + const upcoming = windows + .filter((w) => !w.isOpenNow && w.windowOpensAt) + .sort( + (a, b) => + new Date(a.windowOpensAt!).getTime() - + new Date(b.windowOpensAt!).getTime(), + ); + return upcoming[0] ?? null; +} + +/** + * The closed-state message shown when no booking window is open: the soonest + * upcoming window's opening time + lane, or a generic notice when nothing is + * scheduled. + */ +export function closedWindowMessage(windows: MyBookingWindow[]): string { + const next = soonestUpcomingWindow(windows); + if (!next || !next.windowOpensAt) { + return "No upcoming booking window scheduled."; + } + const lane = + next.origin && next.destination + ? ` for ${next.origin}→${next.destination}` + : ""; + return `Booking is not open right now. Next window: ${formatWindowOpensAt( + next.windowOpensAt, + )} EAT${lane}.`; +} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index ed359d998..81a2488a9 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -376,6 +376,12 @@ export const api = { "myBookingWindows", () => bookingsService.getMyBookingWindows(), ), + + getContractBookingWindows: endpoint<{ contractId: string }, MyBookingWindow[]>( + "train-scheduling", + "contractBookingWindows", + ({ contractId }) => bookingsService.getContractBookingWindows(contractId), + ), }, contracts: { diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 225f14e15..f92e20cf4 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -53,6 +53,8 @@ export interface PriceLineItem { */ export interface MyBookingWindow { scheduleId: string; + /** Contract whose route this window belongs to, when the row carries it. */ + contractId: string | null; direction: "IMPORT" | "EXPORT" | null; windowPhase: string | null; isOpenNow: boolean; @@ -371,4 +373,18 @@ export const bookingsService = { ); return data.data ?? data; }, + + /** + * Booking windows for a single contract's routes (same row shape as + * `getMyBookingWindows`). Used to gate the direct "New shipment booking" + * entry on the contract detail page and the new-shipment form. + */ + getContractBookingWindows: async ( + contractId: string, + ): Promise => { + const { data } = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.CONTRACT_BOOKING_WINDOWS(contractId), + ); + return data.data ?? data; + }, }; From 0e6ebda6f6427eaca4a0d4d83b7b02b86c62ab8a Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 15:21:23 +0000 Subject: [PATCH 019/122] fix --- apps/edr-freight-api/src/app.module.ts | 2 + .../1890000000008-AddFleetEvents.ts | 43 +++++ .../src/modules/drivers/drivers.controller.ts | 12 +- .../src/modules/drivers/drivers.service.ts | 14 +- .../modules/first-mile/first-mile.service.ts | 69 +++++++ .../entities/fleet-event.entity.ts | 55 ++++++ .../fleet-history/fleet-history.module.ts | 17 ++ .../fleet-history/fleet-history.service.ts | 54 ++++++ .../modules/last-mile/last-mile.service.ts | 66 ++++++- .../modules/vehicles/vehicles.controller.ts | 12 +- .../src/modules/vehicles/vehicles.service.ts | 85 ++++++++- .../components/fleet/FleetHistoryModal.tsx | 176 ++++++++++++++++++ .../components/fleet/FleetRecordActions.tsx | 15 +- .../src/pages/fleet/FleetResourcePage.tsx | 10 + .../src/services/fleet-history.service.ts | 38 ++++ 15 files changed, 661 insertions(+), 7 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1890000000008-AddFleetEvents.ts create mode 100644 apps/edr-freight-api/src/modules/fleet-history/entities/fleet-event.entity.ts create mode 100644 apps/edr-freight-api/src/modules/fleet-history/fleet-history.module.ts create mode 100644 apps/edr-freight-api/src/modules/fleet-history/fleet-history.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/fleet-history.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index d72c8b720..b5d63194c 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -77,6 +77,7 @@ import { LastMileModule } from './modules/last-mile/last-mile.module'; import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module'; import { ImportOperationsModule } from './modules/import-operations/import-operations.module'; import { VerifaydaModule } from './modules/verifayda/verifayda.module'; +import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module'; @Module({ imports: [ @@ -144,6 +145,7 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module'; InterchangeDocumentsModule, ImportOperationsModule, VerifaydaModule, + FleetHistoryModule, ], providers: [ EdrOrgSeeder, diff --git a/apps/edr-freight-api/src/migrations/1890000000008-AddFleetEvents.ts b/apps/edr-freight-api/src/migrations/1890000000008-AddFleetEvents.ts new file mode 100644 index 000000000..8fbb688d3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000008-AddFleetEvents.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Append-only audit log for fleet activity (driver↔vehicle assignments, vehicle + * status/availability transitions, first/last-mile vehicle assignments + mile + * status changes). Queried by vehicle_id or driver_id to build a per-record + * timeline. Populated going forward — existing records have no back-history. + */ +export class AddFleetEvents1890000000008 implements MigrationInterface { + name = "AddFleetEvents1890000000008"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.fleet_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + event_type varchar NOT NULL, + vehicle_id uuid, + driver_id uuid, + first_mile_id uuid, + last_mile_id uuid, + from_value varchar, + to_value varchar, + label varchar, + metadata jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FLEET_EVENTS_VEHICLE" + ON freight.fleet_events (vehicle_id, created_at) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FLEET_EVENTS_DRIVER" + ON freight.fleet_events (driver_id, created_at) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.fleet_events`); + } +} diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts index eb0628b96..b4da558e2 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts @@ -14,13 +14,17 @@ import { FleetManage, FleetView } from '../../common/booking-guards'; import { DriversService } from './drivers.service'; import { CreateDriverDto } from './dto/create-driver.dto'; import { UpdateDriverDto } from './dto/update-driver.dto'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; @ApiTags('drivers') @ApiBearerAuth() @Controller('drivers') @FleetView() export class DriversController { - constructor(private readonly driversService: DriversService) {} + constructor( + private readonly driversService: DriversService, + private readonly fleetHistory: FleetHistoryService, + ) {} @Post() @FleetManage() @@ -55,6 +59,12 @@ export class DriversController { return this.driversService.findById(id); } + @Get(':id/history') + @ApiOperation({ summary: 'Get driver assignment & activity history' }) + history(@Param('id', ParseUUIDPipe) id: string) { + return this.fleetHistory.getDriverHistory(id); + } + @Patch(':id') @FleetManage() @ApiOperation({ summary: 'Update a driver' }) diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts index cbfb6787d..6e7c1f69c 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts @@ -4,12 +4,15 @@ import { Repository } from 'typeorm'; import { CreateDriverDto } from './dto/create-driver.dto'; import { UpdateDriverDto } from './dto/update-driver.dto'; import { Driver, DriverStatus } from './entities/driver.entity'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; +import { FleetEventType } from '../fleet-history/entities/fleet-event.entity'; @Injectable() export class DriversService { constructor( @InjectRepository(Driver) private readonly driverRepo: Repository, + private readonly history: FleetHistoryService, ) {} async create(dto: CreateDriverDto): Promise { @@ -51,7 +54,16 @@ export class DriversService { } const driver = this.driverRepo.create(dto); - return this.driverRepo.save(driver); + const saved = await this.driverRepo.save(driver); + + await this.history.record({ + eventType: FleetEventType.DRIVER_REGISTERED, + driverId: saved.id, + label: `${saved.firstName ?? ''} ${saved.lastName ?? ''}`.trim() || null, + toValue: saved.status ?? null, + }); + + return saved; } async findAll(query: { diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 40d163220..419b69969 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -14,6 +14,8 @@ import { FirstMileContainerAllocation } from "./entities/first-mile-container-al import { FirstMileRepository } from "./first-mile.repository"; import { OnEvent } from "@nestjs/event-emitter"; import { InvoiceEventPayload } from "../billing/billing.service"; +import { FleetHistoryService } from "../fleet-history/fleet-history.service"; +import { FleetEventType } from "../fleet-history/entities/fleet-event.entity"; type FirstMileListFilter = { status?: FirstMileStatus; @@ -43,8 +45,21 @@ export class FirstMileService { private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly smsClient: SmsClientService, + private readonly history: FleetHistoryService, ) { } + /** Resolve the driver currently assigned to a vehicle, for stamping mile + * events onto that driver's timeline. Best-effort — never throws. */ + private async resolveDriverId(vehicleId?: string | null): Promise { + if (!vehicleId) return null; + try { + const vehicle = await this.vehiclesService.findById(vehicleId); + return vehicle.assignedDriverId ?? null; + } catch { + return null; + } + } + /** * Look up a booking by its human-readable reference and confirm it has been * paid before any first-mile work proceeds. Throws if the reference is @@ -210,6 +225,14 @@ export class FirstMileService { if (dto.vehicleId) { await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + firstMileId: record.id, + driverId: await this.resolveDriverId(dto.vehicleId), + label: record.status, + metadata: { mile: 'FIRST' }, + }); } return record; @@ -278,6 +301,26 @@ export class FirstMileService { if (existing.vehicleId) { await this.vehiclesService.releaseIfUnused([existing.vehicleId]); } + // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. + if (existing.vehicleId) { + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId: existing.vehicleId, + firstMileId: id, + driverId: await this.resolveDriverId(existing.vehicleId), + metadata: { mile: 'FIRST' }, + }); + } + if (dto.vehicleId) { + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + firstMileId: id, + driverId: await this.resolveDriverId(dto.vehicleId), + label: updated.status, + metadata: { mile: 'FIRST' }, + }); + } } // Notify assigned driver on every explicit vehicle assignment or reassignment @@ -285,6 +328,19 @@ export class FirstMileService { void this.notifyDriverAssignment(dto.vehicleId, existing); } + if (dto.status !== undefined && dto.status !== existing.status) { + const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + await this.history.record({ + eventType: FleetEventType.MILE_STATUS_CHANGED, + firstMileId: id, + vehicleId, + driverId: await this.resolveDriverId(vehicleId), + fromValue: existing.status, + toValue: dto.status, + metadata: { mile: 'FIRST' }, + }); + } + // Trip finished — release the vehicles it was holding if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') { await this.releaseVehicles(updated); @@ -301,6 +357,19 @@ export class FirstMileService { throw new NotFoundException(`First-mile record ${id} not found`); } + if (status !== existing.status) { + const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + await this.history.record({ + eventType: FleetEventType.MILE_STATUS_CHANGED, + firstMileId: id, + vehicleId, + driverId: await this.resolveDriverId(vehicleId), + fromValue: existing.status, + toValue: status, + metadata: { mile: 'FIRST' }, + }); + } + if (status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') { await this.releaseVehicles(updated); } diff --git a/apps/edr-freight-api/src/modules/fleet-history/entities/fleet-event.entity.ts b/apps/edr-freight-api/src/modules/fleet-history/entities/fleet-event.entity.ts new file mode 100644 index 000000000..5096adbc6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fleet-history/entities/fleet-event.entity.ts @@ -0,0 +1,55 @@ +import { Entity, Column, Index } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; + +/** + * Append-only audit log for fleet activity. One row per transition. Queried by + * `vehicleId` (vehicle timeline) or `driverId` (driver timeline); an event may + * carry both so a driver↔vehicle assignment or a mile assignment shows on both. + * `createdAt` (from BaseEntity) is the event time. + */ +export enum FleetEventType { + DRIVER_REGISTERED = 'DRIVER_REGISTERED', + VEHICLE_REGISTERED = 'VEHICLE_REGISTERED', + DRIVER_ASSIGNED = 'DRIVER_ASSIGNED', + DRIVER_UNASSIGNED = 'DRIVER_UNASSIGNED', + VEHICLE_STATUS_CHANGED = 'VEHICLE_STATUS_CHANGED', + VEHICLE_AVAILABILITY_CHANGED = 'VEHICLE_AVAILABILITY_CHANGED', + MILE_VEHICLE_ASSIGNED = 'MILE_VEHICLE_ASSIGNED', + MILE_VEHICLE_RELEASED = 'MILE_VEHICLE_RELEASED', + MILE_STATUS_CHANGED = 'MILE_STATUS_CHANGED', +} + +@Entity({ name: 'fleet_events', schema: 'freight' }) +export class FleetEvent extends BaseEntity { + @Column({ name: 'event_type', type: 'varchar' }) + eventType!: FleetEventType; + + @Index() + @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) + vehicleId?: string | null; + + @Index() + @Column({ name: 'driver_id', type: 'uuid', nullable: true }) + driverId?: string | null; + + @Column({ name: 'first_mile_id', type: 'uuid', nullable: true }) + firstMileId?: string | null; + + @Column({ name: 'last_mile_id', type: 'uuid', nullable: true }) + lastMileId?: string | null; + + /** Previous value for a transition (e.g. old status/availability). */ + @Column({ name: 'from_value', type: 'varchar', nullable: true }) + fromValue?: string | null; + + /** New value for a transition (e.g. new status/availability). */ + @Column({ name: 'to_value', type: 'varchar', nullable: true }) + toValue?: string | null; + + /** Human-readable summary token (driver name, plate, booking ref, mile). */ + @Column({ name: 'label', type: 'varchar', nullable: true }) + label?: string | null; + + @Column({ name: 'metadata', type: 'jsonb', nullable: true }) + metadata?: Record | null; +} diff --git a/apps/edr-freight-api/src/modules/fleet-history/fleet-history.module.ts b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.module.ts new file mode 100644 index 000000000..14828e0a9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.module.ts @@ -0,0 +1,17 @@ +import { Global, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FleetEvent } from './entities/fleet-event.entity'; +import { FleetHistoryService } from './fleet-history.service'; + +/** + * Global so any fleet-touching service (vehicles, drivers, first/last-mile) can + * inject FleetHistoryService to append audit events without each module having + * to import this one. + */ +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([FleetEvent])], + providers: [FleetHistoryService], + exports: [FleetHistoryService], +}) +export class FleetHistoryModule {} diff --git a/apps/edr-freight-api/src/modules/fleet-history/fleet-history.service.ts b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.service.ts new file mode 100644 index 000000000..9c61119b9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.service.ts @@ -0,0 +1,54 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { FleetEvent, FleetEventType } from './entities/fleet-event.entity'; + +export interface FleetEventInput { + eventType: FleetEventType; + vehicleId?: string | null; + driverId?: string | null; + firstMileId?: string | null; + lastMileId?: string | null; + fromValue?: string | null; + toValue?: string | null; + label?: string | null; + metadata?: Record | null; +} + +@Injectable() +export class FleetHistoryService { + private readonly logger = new Logger(FleetHistoryService.name); + + constructor( + @InjectRepository(FleetEvent) + private readonly eventRepo: Repository, + ) {} + + /** + * Append an audit event. Best-effort: recording history must never break the + * business operation that triggered it, so failures are logged and swallowed. + */ + async record(input: FleetEventInput): Promise { + try { + await this.eventRepo.save(this.eventRepo.create(input)); + } catch (err) { + this.logger.error( + `Failed to record fleet event ${input.eventType}: ${String(err)}`, + ); + } + } + + getVehicleHistory(vehicleId: string): Promise { + return this.eventRepo.find({ + where: { vehicleId }, + order: { createdAt: 'DESC' }, + }); + } + + getDriverHistory(driverId: string): Promise { + return this.eventRepo.find({ + where: { driverId }, + order: { createdAt: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 69eec29ae..f0d39d51c 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -12,6 +12,8 @@ import { LastMileContainerAllocation } from './entities/last-mile-container-allo import { LastMileRepository } from './last-mile.repository'; import { InvoiceEventPayload } from '../billing/billing.service'; import { OnEvent } from '@nestjs/event-emitter'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; +import { FleetEventType } from '../fleet-history/entities/fleet-event.entity'; type LastMileListFilter = { status?: LastMileStatus; @@ -41,8 +43,21 @@ export class LastMileService { private readonly driversService: DriversService, private readonly smsClient: SmsClientService, private readonly dataSource: DataSource, + private readonly history: FleetHistoryService, ) {} + /** Resolve the driver currently assigned to a vehicle, for stamping mile + * events onto that driver's timeline. Best-effort — never throws. */ + private async resolveDriverId(vehicleId?: string | null): Promise { + if (!vehicleId) return null; + try { + const vehicle = await this.vehiclesService.findById(vehicleId); + return vehicle.assignedDriverId ?? null; + } catch { + return null; + } + } + async acceptBooking(bookingReference: string): Promise { const booking = await this.bookingsRepository.findByReference(bookingReference); @@ -132,7 +147,7 @@ export class LastMileService { } async create(dto: CreateLastMileDto): Promise { - return this.lastMileRepository.create({ + const record = await this.lastMileRepository.create({ bookingId: dto.bookingId, status: dto.status ?? 'READY_TO_TRANSIT', advancedPayment: dto.advancedPayment ?? 0, @@ -142,6 +157,19 @@ export class LastMileService { vehicleId: dto.vehicleId ?? null, paid: (dto as any).paid ?? false, }); + + if (dto.vehicleId) { + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + lastMileId: record.id, + driverId: await this.resolveDriverId(dto.vehicleId), + label: record.status, + metadata: { mile: 'LAST' }, + }); + } + + return record; } @OnEvent("lastmile.invoice.paid") @@ -180,6 +208,42 @@ export class LastMileService { void this.notifyDriverAssignment(dto.vehicleId, existing); } + // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. + if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { + if (existing.vehicleId) { + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId: existing.vehicleId, + lastMileId: id, + driverId: await this.resolveDriverId(existing.vehicleId), + metadata: { mile: 'LAST' }, + }); + } + if (dto.vehicleId) { + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + lastMileId: id, + driverId: await this.resolveDriverId(dto.vehicleId), + label: updated.status, + metadata: { mile: 'LAST' }, + }); + } + } + + if (dto.status !== undefined && dto.status !== existing.status) { + const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + await this.history.record({ + eventType: FleetEventType.MILE_STATUS_CHANGED, + lastMileId: id, + vehicleId, + driverId: await this.resolveDriverId(vehicleId), + fromValue: existing.status, + toValue: dto.status, + metadata: { mile: 'LAST' }, + }); + } + return updated; } diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts index 8e6d8a0a8..f0a77791a 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts @@ -14,13 +14,17 @@ import { FleetManage, FleetView } from '../../common/booking-guards'; import { VehiclesService } from './vehicles.service'; import { CreateVehicleDto } from './dto/create-vehicle.dto'; import { UpdateVehicleDto } from './dto/update-vehicle.dto'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; @ApiTags('vehicles') @ApiBearerAuth() @Controller('vehicles') @FleetView() export class VehiclesController { - constructor(private readonly vehiclesService: VehiclesService) {} + constructor( + private readonly vehiclesService: VehiclesService, + private readonly fleetHistory: FleetHistoryService, + ) {} @Post() @FleetManage() @@ -57,6 +61,12 @@ export class VehiclesController { return this.vehiclesService.findById(id); } + @Get(':id/history') + @ApiOperation({ summary: 'Get vehicle assignment, status & mile history' }) + history(@Param('id', ParseUUIDPipe) id: string) { + return this.fleetHistory.getVehicleHistory(id); + } + @Patch(':id') @FleetManage() @ApiOperation({ summary: 'Update a vehicle' }) diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts index 69568e483..345d4f896 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -9,12 +9,15 @@ import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile- import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity'; import { LastMileContainerAllocation } from '../last-mile/entities/last-mile-container-allocation.entity'; import { BookingContainerAllocation } from '../bookings/entities/booking-container-allocation.entity'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; +import { FleetEventType } from '../fleet-history/entities/fleet-event.entity'; @Injectable() export class VehiclesService { constructor( @InjectRepository(Vehicle) private readonly vehicleRepo: Repository, + private readonly history: FleetHistoryService, ) {} async create(dto: CreateVehicleDto): Promise { @@ -34,7 +37,24 @@ export class VehiclesService { registrationNumber, }); - return this.vehicleRepo.save(vehicle); + const saved = await this.vehicleRepo.save(vehicle); + + await this.history.record({ + eventType: FleetEventType.VEHICLE_REGISTERED, + vehicleId: saved.id, + label: saved.plateNumber ?? saved.code ?? null, + toValue: saved.availability ?? null, + }); + if (saved.assignedDriverId) { + await this.history.record({ + eventType: FleetEventType.DRIVER_ASSIGNED, + vehicleId: saved.id, + driverId: saved.assignedDriverId, + label: saved.assignedDriverName ?? null, + }); + } + + return saved; } async findAll(query: { @@ -97,12 +117,73 @@ export class VehiclesService { } } + const prev = { + assignedDriverId: vehicle.assignedDriverId, + assignedDriverName: vehicle.assignedDriverName, + status: vehicle.status, + availability: vehicle.availability, + }; + Object.assign(vehicle, dto); - return this.vehicleRepo.save(vehicle); + const saved = await this.vehicleRepo.save(vehicle); + + // Driver (re)assignment — emit an unassign for the old driver and/or an + // assign for the new one so both drivers' timelines and the vehicle's line up. + if ( + dto.assignedDriverId !== undefined && + dto.assignedDriverId !== prev.assignedDriverId + ) { + if (prev.assignedDriverId) { + await this.history.record({ + eventType: FleetEventType.DRIVER_UNASSIGNED, + vehicleId: id, + driverId: prev.assignedDriverId, + label: prev.assignedDriverName ?? null, + }); + } + if (saved.assignedDriverId) { + await this.history.record({ + eventType: FleetEventType.DRIVER_ASSIGNED, + vehicleId: id, + driverId: saved.assignedDriverId, + label: saved.assignedDriverName ?? null, + }); + } + } + if (dto.status !== undefined && dto.status !== prev.status) { + await this.history.record({ + eventType: FleetEventType.VEHICLE_STATUS_CHANGED, + vehicleId: id, + fromValue: prev.status ?? null, + toValue: saved.status ?? null, + }); + } + if (dto.availability !== undefined && dto.availability !== prev.availability) { + await this.history.record({ + eventType: FleetEventType.VEHICLE_AVAILABILITY_CHANGED, + vehicleId: id, + fromValue: prev.availability ?? null, + toValue: saved.availability ?? null, + }); + } + + return saved; } async setAvailability(id: string, availability: VehicleAvailability): Promise { + // Read the current value so the audit event records an accurate from→to and + // we skip logging no-op writes (setAvailability is called in release loops). + const vehicle = await this.vehicleRepo.findOne({ where: { id } }); + const previous = vehicle?.availability; await this.vehicleRepo.update(id, { availability }); + if (previous !== availability) { + await this.history.record({ + eventType: FleetEventType.VEHICLE_AVAILABILITY_CHANGED, + vehicleId: id, + fromValue: previous ?? null, + toValue: availability, + }); + } } /** diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx new file mode 100644 index 000000000..868ed347d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx @@ -0,0 +1,176 @@ +import { Center, Loader, Modal, Text, Timeline } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { + Activity, + CircleDot, + Route, + Truck, + UserCheck, + UserMinus, + UserPlus, +} from "lucide-react"; + +import { + fleetHistoryService, + type FleetHistoryEvent, +} from "@/services/fleet-history.service"; +import type { FleetRecord } from "@/services/fleet/fleet.service"; + +export interface FleetHistoryModalProps { + opened: boolean; + onClose: () => void; + entity: "driver" | "vehicle"; + record: FleetRecord | null; +} + +const asObj = (r: FleetRecord | null) => (r ?? {}) as Record; + +const titleFor = (entity: "driver" | "vehicle", record: FleetRecord | null) => { + const r = asObj(record); + if (entity === "vehicle") { + return `Vehicle history — ${r.plateNumber ?? r.code ?? ""}`.trim(); + } + return `Driver history — ${[r.firstName, r.lastName] + .filter(Boolean) + .join(" ")}`.trim(); +}; + +const mileLabel = (e: FleetHistoryEvent) => + e.metadata?.mile === "LAST" ? "Last-mile" : "First-mile"; + +const arrow = (from?: string | null, to?: string | null) => + `${from ?? "—"} → ${to ?? "—"}`; + +function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") { + switch (e.eventType) { + case "DRIVER_REGISTERED": + return { + icon: , + title: "Driver registered", + text: e.toValue ? `Status: ${e.toValue}` : "", + }; + case "VEHICLE_REGISTERED": + return { + icon: , + title: "Vehicle registered", + text: e.toValue ? `Availability: ${e.toValue}` : "", + }; + case "DRIVER_ASSIGNED": + return { + icon: , + title: + entity === "vehicle" + ? `Driver assigned${e.label ? `: ${e.label}` : ""}` + : "Assigned to a vehicle", + text: "", + }; + case "DRIVER_UNASSIGNED": + return { + icon: , + title: + entity === "vehicle" + ? `Driver unassigned${e.label ? `: ${e.label}` : ""}` + : "Unassigned from a vehicle", + text: "", + }; + case "VEHICLE_STATUS_CHANGED": + return { + icon: , + title: "Status changed", + text: arrow(e.fromValue, e.toValue), + }; + case "VEHICLE_AVAILABILITY_CHANGED": + return { + icon: , + title: `Marked ${e.toValue ?? ""}`.trim(), + text: e.fromValue ? arrow(e.fromValue, e.toValue) : "", + }; + case "MILE_VEHICLE_ASSIGNED": + return { + icon: , + title: `${mileLabel(e)}: vehicle assigned`, + text: e.label ? `Status: ${e.label}` : "", + }; + case "MILE_VEHICLE_RELEASED": + return { + icon: , + title: `${mileLabel(e)}: vehicle released`, + text: "", + }; + case "MILE_STATUS_CHANGED": + return { + icon: , + title: `${mileLabel(e)} status`, + text: arrow(e.fromValue, e.toValue), + }; + default: + return { icon: , title: e.eventType, text: "" }; + } +} + +const fmt = (iso: string) => { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); +}; + +const FleetHistoryModal = ({ + opened, + onClose, + entity, + record, +}: FleetHistoryModalProps) => { + const id = asObj(record).id ? String(asObj(record).id) : ""; + + const { data, isLoading } = useQuery({ + queryKey: ["fleet-history", entity, id], + queryFn: () => + entity === "vehicle" + ? fleetHistoryService.vehicle(id) + : fleetHistoryService.driver(id), + enabled: opened && Boolean(id), + }); + + const events = data ?? []; + + return ( + {titleFor(entity, record)}} + radius="lg" + size="lg" + centered + > + {isLoading ? ( +
+ +
+ ) : events.length === 0 ? ( + + No history recorded yet. Activity appears here as this{" "} + {entity} is assigned, reassigned, or its status changes. + + ) : ( + + {events.map((e) => { + const d = describe(e, entity); + return ( + + {d.text && ( + + {d.text} + + )} + + {fmt(e.createdAt)} + + + ); + })} + + )} +
+ ); +}; + +export default FleetHistoryModal; diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx index 9096f52a1..d499d030c 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx @@ -1,4 +1,4 @@ -import { Edit2, Trash2, Eye, Users, MoreVertical } from "lucide-react"; +import { Edit2, Trash2, Eye, Users, MoreVertical, History } from "lucide-react"; import { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core"; import { useNavigate } from "react-router-dom"; @@ -11,6 +11,7 @@ export interface FleetRecordActionsProps { onEdit: (record: FleetRecord) => void; onRemove: (record: FleetRecord) => void; onAssignDriver?: (record: FleetRecord) => void; + onHistory?: (record: FleetRecord) => void; layout?: "row" | "compact"; } @@ -20,12 +21,16 @@ const FleetRecordActions = ({ onEdit, onRemove, onAssignDriver, + onHistory, layout = "row", }: FleetRecordActionsProps) => { const navigate = useNavigate(); const removeLabel = config.removeActionLabel ?? "Delete"; const showDetail = Boolean(config.detailPath && "id" in record); const isVehicle = config.slug === "vehicles"; + const showHistory = + Boolean(onHistory) && + (config.slug === "drivers" || config.slug === "vehicles"); const handleDetail = () => { if (!config.detailPath || !("id" in record)) return; @@ -57,6 +62,14 @@ const FleetRecordActions = ({ > Edit + {showHistory ? ( + onHistory?.(record)} + leftSection={} + > + History + + ) : null} {showDetail ? ( { const [editing, setEditing] = useState(null); const [removeTarget, setRemoveTarget] = useState(null); const [assigningDriver, setAssigningDriver] = useState(null); + const [historyTarget, setHistoryTarget] = useState(null); const [selectedDriver, setSelectedDriver] = useState(""); const { viewMode, setViewMode } = useFleetViewMode(slug); @@ -273,6 +275,7 @@ const FleetResourcePage = () => { }} onRemove={setRemoveTarget} onAssignDriver={setAssigningDriver} + onHistory={setHistoryTarget} /> ), @@ -581,6 +584,13 @@ const FleetResourcePage = () => {
+ + setHistoryTarget(null)} + entity={slug === "vehicles" ? "vehicle" : "driver"} + record={historyTarget} + /> ); }; diff --git a/apps/edr-freight-web/backoffice/src/services/fleet-history.service.ts b/apps/edr-freight-web/backoffice/src/services/fleet-history.service.ts new file mode 100644 index 000000000..5ffbe4a26 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/fleet-history.service.ts @@ -0,0 +1,38 @@ +import { api as apiClient } from "../auth/http"; + +export type FleetEventType = + | "DRIVER_REGISTERED" + | "VEHICLE_REGISTERED" + | "DRIVER_ASSIGNED" + | "DRIVER_UNASSIGNED" + | "VEHICLE_STATUS_CHANGED" + | "VEHICLE_AVAILABILITY_CHANGED" + | "MILE_VEHICLE_ASSIGNED" + | "MILE_VEHICLE_RELEASED" + | "MILE_STATUS_CHANGED"; + +export interface FleetHistoryEvent { + id: string; + eventType: FleetEventType; + vehicleId?: string | null; + driverId?: string | null; + firstMileId?: string | null; + lastMileId?: string | null; + fromValue?: string | null; + toValue?: string | null; + label?: string | null; + metadata?: Record | null; + createdAt: string; +} + +/** Timeline of fleet events for a driver or a vehicle (newest first). */ +export const fleetHistoryService = { + driver: (id: string) => + apiClient + .get(`/drivers/${id}/history`) + .then((r) => r.data), + vehicle: (id: string) => + apiClient + .get(`/vehicles/${id}/history`) + .then((r) => r.data), +}; From 6dc691439784b5c1845693c81fb64b240fa569e3 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 15:24:12 +0000 Subject: [PATCH 020/122] Add migration to repair schema drift and implement contract step banner in UI --- .../1870000000000-RepairSynchronizeDrift.ts | 269 +++++++++++++++++ .../train-scheduling/batch-window.util.ts | 20 ++ .../train-scheduling.service.ts | 18 ++ .../pages/contracts/ContractStepBanner.tsx | 271 ++++++++++++++++++ .../src/pages/contracts/ContractsList.tsx | 68 ++++- 5 files changed, 640 insertions(+), 6 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1870000000000-RepairSynchronizeDrift.ts create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/ContractStepBanner.tsx diff --git a/apps/edr-freight-api/src/migrations/1870000000000-RepairSynchronizeDrift.ts b/apps/edr-freight-api/src/migrations/1870000000000-RepairSynchronizeDrift.ts new file mode 100644 index 000000000..32f1e6f0c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1870000000000-RepairSynchronizeDrift.ts @@ -0,0 +1,269 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Repairs schema drift on databases that were originally built by TypeORM + * `synchronize` (at an older entity snapshot) and never had their migration + * history recorded. Such databases have `freight.migrations` empty while most + * of the schema already exists, so a from-scratch migration run aborts on the + * first non-idempotent statement and never reaches the columns/tables added + * after synchronize was last used. + * + * The deployment procedure for those databases is: + * 1. Baseline every pre-existing migration into `freight.migrations`. + * 2. Run migrations — this file is the only pending one and back-fills the + * objects the drift scan found missing. + * + * Every statement is idempotent (IF NOT EXISTS / guarded CREATE TYPE), so it is + * also safe on a clean database where the earlier migrations already created + * these objects — it simply no-ops. + */ +export class RepairSynchronizeDrift1870000000000 + implements MigrationInterface +{ + name = 'RepairSynchronizeDrift1870000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // --- enum types (derived from entities that never had a source migration) --- + await queryRunner.query(`DO $$ BEGIN + CREATE TYPE freight.consignments_cargo_type_enum AS ENUM ( + 'CONTAINER', 'BULK_LIQUID', 'BULK_DRY', 'GENERAL', 'REFRIGERATED', 'HAZARDOUS' + ); + EXCEPTION WHEN duplicate_object THEN null; END $$;`); + await queryRunner.query(`DO $$ BEGIN + CREATE TYPE freight.consignments_status_enum AS ENUM ( + 'PENDING', 'LOADED', 'IN_TRANSIT', 'AT_DESTINATION', 'DELIVERED', 'RETURNED' + ); + EXCEPTION WHEN duplicate_object THEN null; END $$;`); + await queryRunner.query(`DO $$ BEGIN + CREATE TYPE freight.tracking_events_status_enum AS ENUM ( + 'PENDING', 'LOADED', 'IN_TRANSIT', 'AT_DESTINATION', 'DELIVERED', 'RETURNED' + ); + EXCEPTION WHEN duplicate_object THEN null; END $$;`); + + // --- missing tables --- + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.consignments ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL, + tracking_number varchar(64) NOT NULL, + cargo_type freight.consignments_cargo_type_enum NOT NULL, + weight_kg numeric(12, 2) NOT NULL, + status freight.consignments_status_enum NOT NULL DEFAULT 'PENDING', + origin_station varchar(128) NOT NULL, + destination_station varchar(128) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_consignments PRIMARY KEY (id), + CONSTRAINT uq_consignments_tracking_number UNIQUE (tracking_number) + );`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.tracking_events ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + consignment_id uuid NOT NULL, + location varchar(256) NOT NULL, + status freight.tracking_events_status_enum NOT NULL, + occurred_at timestamptz NOT NULL, + description text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_tracking_events PRIMARY KEY (id) + );`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.fuel_purchases ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + purchase_date timestamptz NOT NULL, + liters numeric(10, 2) NOT NULL, + cost_per_liter numeric(10, 2) NOT NULL, + total_cost numeric(14, 2) NOT NULL, + fuel_station varchar(255) NULL, + payment_method varchar(50) DEFAULT 'CASH', + odometer_reading numeric(10, 2) NULL, + driver_id uuid NULL, + receipt_number varchar(255) NULL, + notes text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CONSTRAINT pk_fuel_purchases PRIMARY KEY (id), + CONSTRAINT fk_fuel_purchases_vehicle FOREIGN KEY (vehicle_id) + REFERENCES freight.vehicles (id) ON DELETE CASCADE + );`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_purchases_vehicle ON freight.fuel_purchases (vehicle_id);`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_purchases_date ON freight.fuel_purchases (purchase_date);`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.fuel_consumption ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + month date NOT NULL, + total_liters numeric(10, 2) NOT NULL, + total_cost numeric(14, 2) NOT NULL, + total_distance_km numeric(10, 2) NOT NULL, + fuel_efficiency_km_per_l numeric(10, 2) NULL, + number_of_purchases integer DEFAULT 0, + average_cost_per_liter numeric(10, 2) NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CONSTRAINT pk_fuel_consumption PRIMARY KEY (id), + CONSTRAINT fk_fuel_consumption_vehicle FOREIGN KEY (vehicle_id) + REFERENCES freight.vehicles (id) ON DELETE CASCADE, + CONSTRAINT uq_fuel_consumption_vehicle_month UNIQUE (vehicle_id, month) + );`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_consumption_vehicle_month ON freight.fuel_consumption (vehicle_id, month);`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.maintenance_schedules ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + maintenance_type varchar NOT NULL, + description varchar NOT NULL, + scheduled_date timestamptz NOT NULL, + completed_date timestamptz, + estimated_cost numeric(14,2), + actual_cost numeric(14,2), + status varchar NOT NULL DEFAULT 'SCHEDULED', + odometer_reading numeric, + service_provider varchar, + notes text, + next_due_km numeric, + next_due_date timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + PRIMARY KEY (id) + );`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_maintenance_schedules_vehicle_date ON freight.maintenance_schedules (vehicle_id, scheduled_date);`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.maintenance_costs ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + maintenance_schedule_id uuid, + incurred_date timestamptz NOT NULL, + cost_amount numeric(14,2) NOT NULL, + cost_type varchar NOT NULL, + description varchar NOT NULL, + service_provider varchar, + invoice_number varchar, + notes text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + PRIMARY KEY (id), + CONSTRAINT fk_maintenance_schedule FOREIGN KEY (maintenance_schedule_id) + REFERENCES freight.maintenance_schedules (id) ON DELETE SET NULL + );`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_maintenance_costs_vehicle_date ON freight.maintenance_costs (vehicle_id, incurred_date);`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.otp_verifications ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + phone varchar NOT NULL, + otp varchar NOT NULL, + verified boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_otp_verifications PRIMARY KEY (id), + CONSTRAINT uq_otp_verifications_phone UNIQUE (phone) + );`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.booking_batch_offers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + train_schedule_id uuid NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE, + offered_wagons integer NOT NULL, + total_wagons integer NOT NULL, + offered_lines jsonb NULL, + offered_weight_tons numeric(12, 3) NOT NULL, + offered_amount numeric(14, 2) NOT NULL, + offered_pricing_breakdown jsonb NULL, + invoice_id uuid NULL, + payment_deadline timestamptz NOT NULL, + status varchar(10) NOT NULL DEFAULT 'OFFERED', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + );`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_booking ON freight.booking_batch_offers (booking_id);`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_schedule ON freight.booking_batch_offers (train_schedule_id);`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_status ON freight.booking_batch_offers (status);`); + + // --- missing columns on existing tables --- + await queryRunner.query(`ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_at timestamptz;`); + + await queryRunner.query(`ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS booking_type varchar(20) NOT NULL DEFAULT 'ONE_TIME', + ADD COLUMN IF NOT EXISTS customer_truck_plate_number varchar(32), + ADD COLUMN IF NOT EXISTS customer_truck_driver_name varchar(120), + ADD COLUMN IF NOT EXISTS customer_truck_type varchar(60), + ADD COLUMN IF NOT EXISTS customer_truck_container_number varchar(16), + ADD COLUMN IF NOT EXISTS customer_truck_assigned_at timestamptz, + ADD COLUMN IF NOT EXISTS customer_truck_arrived_at timestamptz, + ADD COLUMN IF NOT EXISTS bulk_hazardous_quantity numeric(12,3) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS bulk_reefer_quantity numeric(12,3) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS clearance_current_phase varchar(40), + ADD COLUMN IF NOT EXISTS duty_required boolean, + ADD COLUMN IF NOT EXISTS vessel_departure_date date, + ADD COLUMN IF NOT EXISTS ro_amendment_requested_at timestamptz, + ADD COLUMN IF NOT EXISTS ro_hold_reason text, + ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at timestamptz;`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_bookings_booking_type ON freight.bookings (booking_type);`); + + await queryRunner.query(`ALTER TABLE freight.cargoes + ADD COLUMN IF NOT EXISTS receiver_name varchar, + ADD COLUMN IF NOT EXISTS delivered_at timestamp, + ADD COLUMN IF NOT EXISTS delivery_remarks text;`); + + await queryRunner.query(`ALTER TABLE freight.contract_clearance_cycles + ADD COLUMN IF NOT EXISTS duty_required boolean, + ADD COLUMN IF NOT EXISTS vessel_departure_date date, + ADD COLUMN IF NOT EXISTS ro_amendment_requested_at timestamptz, + ADD COLUMN IF NOT EXISTS ro_hold_reason text, + ADD COLUMN IF NOT EXISTS current_phase varchar(40), + ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at timestamptz;`); + + await queryRunner.query(`ALTER TABLE freight.first_mile + ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;`); + await queryRunner.query(`ALTER TABLE freight.last_mile + ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;`); + + await queryRunner.query(`ALTER TABLE freight.route_milestones + ADD COLUMN IF NOT EXISTS distance_km numeric(10,2);`); + + await queryRunner.query(`ALTER TABLE freight.routes + ADD COLUMN IF NOT EXISTS status varchar(32) NOT NULL DEFAULT 'AVAILABLE';`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_routes_status" ON freight.routes (status);`); + + await queryRunner.query(`ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS window_phase varchar(20) NULL, + ADD COLUMN IF NOT EXISTS window_opens_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS window_closes_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS doc_review_ends_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS doc_review_completed_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS payment_phase_ends_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS booking_cycle_no integer NOT NULL DEFAULT 0;`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_train_schedules_window_phase + ON freight.train_schedules (window_phase) WHERE window_phase IS NOT NULL;`); + + await queryRunner.query(`ALTER TABLE freight.train_scheduling_global_rules + ADD COLUMN IF NOT EXISTS import_window_lead_days integer NOT NULL DEFAULT 3, + ADD COLUMN IF NOT EXISTS export_booking_lead_hours integer NOT NULL DEFAULT 24, + ADD COLUMN IF NOT EXISTS window_open_hour integer NOT NULL DEFAULT 8, + ADD COLUMN IF NOT EXISTS window_duration_hours numeric(4, 2) NOT NULL DEFAULT 3, + ADD COLUMN IF NOT EXISTS doc_review_minutes integer NOT NULL DEFAULT 30, + ADD COLUMN IF NOT EXISTS payment_window_minutes integer NOT NULL DEFAULT 60, + ADD COLUMN IF NOT EXISTS reopen_delay_minutes integer NOT NULL DEFAULT 90;`); + } + + public async down(): Promise { + // No-op: this migration only repairs drift by additively creating objects + // that other migrations own. Rolling it back would drop objects those + // migrations legitimately created. Revert individual feature migrations + // instead if needed. + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index 6d295c8fd..c60c316ac 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -181,6 +181,26 @@ export function computeExportWindowTimes( }; } +/** + * Earliest departure a train may be scheduled for — staff cannot schedule inside + * the lead window. IMPORT/DOMESTIC lead is in whole EAT days: with lead 3 and + * today the 11th, the 12th and 13th are blocked and the 14th is the first + * allowed departure day (00:00 EAT). EXPORT lead is in hours: earliest departure + * is `now + exportBookingLeadHours` (24h = 1 day). Mirrors the booking-window + * math so a schedulable date always has a real booking window before it. + */ +export function earliestSchedulableDeparture( + direction: string | null | undefined, + cfg: { importWindowLeadDays: number; exportBookingLeadHours: number }, + now: Date, +): Date { + if (direction === 'EXPORT') { + return new Date(now.getTime() + cfg.exportBookingLeadHours * 3_600_000); + } + const earliestDay = shiftEatDay(eatDay(now), cfg.importWindowLeadDays); + return eatDayToUtc(earliestDay, 0); +} + /** Which 3h EAT intake window a timestamp (e.g. fullyExecutedAt) belongs to. */ export function getBatchWindowForTimestamp(date: Date): BatchWindow { const { year, month, day, hour } = eatParts(date); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 3c4c614f8..0aadc62ab 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -103,6 +103,7 @@ import { import { computeExportWindowTimes, computeImportWindowTimes, + earliestSchedulableDeparture, eatDay, } from './batch-window.util'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; @@ -469,6 +470,23 @@ export class TrainSchedulingService { // (opens at 08:00 EAT `importWindowLeadDays` before departure); EXPORT opens // 24h before departure (FCFS). No schedule is ever always-open now. const windowCfg = await this.getWindowConfig(); + + // Staff cannot schedule inside the lead window — there must be room for a + // booking window before departure. IMPORT/DOMESTIC lead is in whole EAT + // days (lead 3, today 11th → first allowed departure is the 14th); EXPORT + // lead is in hours (24h = 1 day ahead). + const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date()); + if (departure.getTime() < earliest.getTime()) { + const detail = + direction === 'EXPORT' + ? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead` + : `at least ${windowCfg.importWindowLeadDays} day(s) ahead`; + throw new BadRequestException( + `Departure ${departure.toISOString()} is inside the booking lead window; ` + + `${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` + + `(earliest ${earliest.toISOString()})`, + ); + } const windowFields = direction === 'EXPORT' ? { diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractStepBanner.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractStepBanner.tsx new file mode 100644 index 000000000..a339c214a --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractStepBanner.tsx @@ -0,0 +1,271 @@ +import { Box, Group, Stack, Text } from "@mantine/core"; +import { + AlertTriangle, + Check, + CircleDot, + FileEdit, + FilePlus2, + Gavel, + PenLine, + Send, + ShieldCheck, + Truck, + XCircle, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import type { Freight } from "@edr/types"; + +import { BORDER, GREEN, GREEN_DARK, INK, MUTED } from "./contract-ui"; + +/** + * The customer-facing contract journey, in order. This is the *contract track* + * (establishing the agreement) — the per-shipment booking/clearance journey is a + * separate stepper (`ClearancePhaseStepper`) shown on a booking, not here. + */ +interface Stage { + key: string; + label: string; + icon: LucideIcon; +} + +const STAGES: Stage[] = [ + { key: "draft", label: "Draft", icon: FileEdit }, + { key: "submitted", label: "Submitted", icon: Send }, + { key: "accepted", label: "Accepted", icon: ShieldCheck }, + { key: "approval", label: "Approval", icon: Gavel }, + { key: "sign", label: "Signature", icon: PenLine }, + { key: "active", label: "Active", icon: FilePlus2 }, + { key: "shipping", label: "Shipping", icon: Truck }, +]; + +const STAGE_INDEX: Record = STAGES.reduce( + (acc, s, i) => ({ ...acc, [s.key]: i }), + {}, +); + +type Terminal = "REJECTED" | "CANCELLED" | "EXPIRED" | "CLOSED" | null; + +interface StepState { + /** Index into STAGES of the stage the contract is currently working on. */ + activeIdx: number; + /** Terminal state, if the contract ended off the happy path. */ + terminal: Terminal; + /** One-line "what happens next" helper for the customer. */ + next: string; +} + +/** + * Map any contract status onto the journey. Statuses that share a stage (e.g. + * every approval/signature sub-state) collapse onto that stage; the helper line + * is what disambiguates them for the customer. + */ +function resolveStep(status: string): StepState { + const at = (key: string): number => STAGE_INDEX[key] ?? 0; + + switch (status) { + case "DRAFT": + case "RENEWAL_DRAFT": + return { activeIdx: at("draft"), terminal: null, next: "Finish and submit this contract for review." }; + + case "SUBMITTED": + case "RENEWAL_SUBMITTED": + return { activeIdx: at("submitted"), terminal: null, next: "Waiting for staff to accept your submission." }; + + case "PRICE_CHANGED_PENDING_CONFIRM": + return { activeIdx: at("submitted"), terminal: null, next: "Price changed since preview — confirm to resubmit." }; + + case "CHANGES_REQUESTED": + return { activeIdx: at("submitted"), terminal: null, next: "Staff requested changes — update and resubmit." }; + + case "AMENDMENTS_PROPOSED": + return { activeIdx: at("submitted"), terminal: null, next: "Amendments proposed — review the proposed changes." }; + + case "PENDING_APPROVAL": + case "RENEWAL_PENDING_APPROVAL": + return { activeIdx: at("approval"), terminal: null, next: "Under internal approval (staff → director → CEO)." }; + + case "APPROVED": + return { activeIdx: at("approval"), terminal: null, next: "Approved — the contract document is being prepared." }; + + case "APPROVED_PENDING_SIGNATURE": + case "CONTRACT_READY": + return { activeIdx: at("sign"), terminal: null, next: "Contract is ready — review and sign it." }; + + case "SIGNED_CUSTOMER": + return { activeIdx: at("sign"), terminal: null, next: "You've signed — waiting for staff to counter-sign." }; + + case "CONTRACT_ACTIVE": + case "FULLY_EXECUTED": + case "CLEARANCE_READY_FOR_BOOKING": + return { activeIdx: at("active"), terminal: null, next: "Active — submit a shipment request to start shipping." }; + + case "AWAITING_CLEARANCE_DOCUMENTS": + return { activeIdx: at("active"), terminal: null, next: "Upload the pre-booking clearance documents." }; + + case "CLEARANCE_UNDER_REVIEW": + return { activeIdx: at("active"), terminal: null, next: "Global Logistics is reviewing your clearance documents." }; + + case "ACTIVE_SHIPMENT_IN_PROGRESS": + return { activeIdx: at("shipping"), terminal: null, next: "A shipment is in progress under this contract." }; + + // ── Terminal ── + case "REJECTED": + return { activeIdx: at("approval"), terminal: "REJECTED", next: "This contract was rejected." }; + case "CANCELLED": + return { activeIdx: at("draft"), terminal: "CANCELLED", next: "This contract was cancelled." }; + case "EXPIRED": + return { activeIdx: at("active"), terminal: "EXPIRED", next: "This contract's validity has expired." }; + case "CONTRACT_CLOSED": + case "ARCHIVED": + return { activeIdx: STAGES.length - 1, terminal: "CLOSED", next: "This contract is closed." }; + + default: + return { activeIdx: at("draft"), terminal: null, next: "" }; + } +} + +/** Days until the contract validity lapses, if any (negative = already lapsed). */ +function daysUntil(dateIso?: string | null): number | null { + if (!dateIso) return null; + const end = new Date(dateIso).getTime(); + if (Number.isNaN(end)) return null; + const ms = end - Date.now(); + return Math.ceil(ms / 86_400_000); +} + +export interface ContractStepBannerProps { + contract: Freight.IContract; +} + +/** + * A polished, branded step banner that shows where a contract sits in its + * lifecycle. Rendered inside the expanded region of a contract row. + */ +export function ContractStepBanner({ contract }: ContractStepBannerProps) { + const { activeIdx, terminal, next } = resolveStep(contract.status); + const isTerminalBad = terminal === "REJECTED" || terminal === "CANCELLED" || terminal === "EXPIRED"; + + const expiryDays = daysUntil(contract.contractValidUntil); + const expirySoon = + !terminal && expiryDays !== null && expiryDays >= 0 && expiryDays <= 14; + + return ( + + {/* Stepper row */} + + {STAGES.map((stage, index) => { + const isComplete = !isTerminalBad && index < activeIdx; + const isActive = !isTerminalBad && index === activeIdx; + const isFailedHere = isTerminalBad && index === activeIdx; + const isLast = index === STAGES.length - 1; + const Icon = isFailedHere ? XCircle : stage.icon; + + return ( + + + + + {isComplete ? ( + + ) : ( + + )} + + + {stage.label} + + + {!isLast && ( + + )} + + + ); + })} + + + {/* Helper line + expiry hint */} + {(next || expirySoon) && ( + + {next && ( + + {isTerminalBad ? ( + + ) : ( + + )} + + {next} + + + )} + {expirySoon && ( + + + + {expiryDays === 0 + ? "Validity ends today" + : `Validity ends in ${expiryDays} day${expiryDays === 1 ? "" : "s"}`} + + + )} + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx index fbb8ac0ce..41e77625b 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { Fragment, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { @@ -17,6 +17,7 @@ import { } from "@mantine/core"; import { CheckCircle2, + ChevronDown, ChevronLeft, ChevronRight, FileStack, @@ -43,6 +44,7 @@ import { MUTED, StatCard, } from "./contract-ui"; +import { ContractStepBanner } from "./ContractStepBanner"; function primaryRoute(contract: Freight.IContract) { const route = contract.routes?.[0]; @@ -61,6 +63,15 @@ export default function ContractsList() { const [kindFilter, setKindFilter] = useState(null); const [createdFrom, setCreatedFrom] = useState(""); const [createdTo, setCreatedTo] = useState(""); + const [expanded, setExpanded] = useState>(new Set()); + + const toggleExpanded = (id: string) => + setExpanded((prev) => { + const nextSet = new Set(prev); + if (nextSet.has(id)) nextSet.delete(id); + else nextSet.add(id); + return nextSet; + }); const resetPage = () => setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); @@ -326,6 +337,7 @@ export default function ContractsList() { > + Contract Cargo Route @@ -340,7 +352,7 @@ export default function ContractsList() { {isLoading && ( - +
@@ -350,7 +362,7 @@ export default function ContractsList() { {!isLoading && isError && ( - +
Failed to load contracts. Please try again. @@ -362,7 +374,7 @@ export default function ContractsList() { {!isLoading && !isError && rows.length === 0 && ( - + @@ -383,12 +395,48 @@ export default function ContractsList() { const tradeLabel = dir ? dir.charAt(0) + dir.slice(1).toLowerCase() : "—"; + const isOpen = expanded.has(c.id); return ( + navigate(`/contracts/${c.id}`)} > + + { + e.stopPropagation(); + toggleExpanded(c.id); + }} + style={{ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: 28, + height: 28, + borderRadius: 8, + border: `1px solid ${BORDER}`, + background: isOpen ? GREEN : "#FFFFFF", + color: isOpen ? "#FFFFFF" : MUTED, + cursor: "pointer", + transition: "all 140ms ease", + }} + > + + + {c.reference} @@ -483,6 +531,14 @@ export default function ContractsList() { + {isOpen && ( + + + + + + )} + ); })} From f2f6c89c0eacb7e1010d9a8a1de1bb73e0f3cd17 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 15:37:24 +0000 Subject: [PATCH 021/122] fix --- .../modules/last-mile/last-mile.service.ts | 26 ++++++++++++++++--- .../components/fleet/FleetHistoryModal.tsx | 13 +++++++--- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index f0d39d51c..f5c9696e6 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -58,6 +58,23 @@ export class LastMileService { } } + /** Human booking reference for a last-mile record, for the history timeline. + * Uses the already-loaded relation when present, else looks it up. */ + private async resolveBookingRef( + record: LastMile, + ): Promise { + const loaded = (record as LastMile & { booking?: { reference?: string } }) + .booking?.reference; + if (loaded) return loaded; + if (!record.bookingId) return null; + try { + const booking = await this.bookingsRepository.findById(record.bookingId); + return (booking as { reference?: string } | null)?.reference ?? null; + } catch { + return null; + } + } + async acceptBooking(bookingReference: string): Promise { const booking = await this.bookingsRepository.findByReference(bookingReference); @@ -165,7 +182,7 @@ export class LastMileService { lastMileId: record.id, driverId: await this.resolveDriverId(dto.vehicleId), label: record.status, - metadata: { mile: 'LAST' }, + metadata: { mile: 'LAST', bookingRef: await this.resolveBookingRef(record) }, }); } @@ -209,6 +226,7 @@ export class LastMileService { } // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. + const bookingRef = await this.resolveBookingRef(existing); if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { if (existing.vehicleId) { await this.history.record({ @@ -216,7 +234,7 @@ export class LastMileService { vehicleId: existing.vehicleId, lastMileId: id, driverId: await this.resolveDriverId(existing.vehicleId), - metadata: { mile: 'LAST' }, + metadata: { mile: 'LAST', bookingRef }, }); } if (dto.vehicleId) { @@ -226,7 +244,7 @@ export class LastMileService { lastMileId: id, driverId: await this.resolveDriverId(dto.vehicleId), label: updated.status, - metadata: { mile: 'LAST' }, + metadata: { mile: 'LAST', bookingRef }, }); } } @@ -240,7 +258,7 @@ export class LastMileService { driverId: await this.resolveDriverId(vehicleId), fromValue: existing.status, toValue: dto.status, - metadata: { mile: 'LAST' }, + metadata: { mile: 'LAST', bookingRef }, }); } diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx index 868ed347d..0e7a0e65b 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx @@ -42,6 +42,13 @@ const arrow = (from?: string | null, to?: string | null) => `${from ?? "—"} → ${to ?? "—"}`; function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") { + const bookingRef = + typeof e.metadata?.bookingRef === "string" ? e.metadata.bookingRef : null; + const withBooking = (rest?: string) => + [bookingRef ? `Booking ${bookingRef}` : "", rest ?? ""] + .filter(Boolean) + .join(" · "); + switch (e.eventType) { case "DRIVER_REGISTERED": return { @@ -89,19 +96,19 @@ function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") { return { icon: , title: `${mileLabel(e)}: vehicle assigned`, - text: e.label ? `Status: ${e.label}` : "", + text: withBooking(e.label ? `Status: ${e.label}` : ""), }; case "MILE_VEHICLE_RELEASED": return { icon: , title: `${mileLabel(e)}: vehicle released`, - text: "", + text: withBooking(), }; case "MILE_STATUS_CHANGED": return { icon: , title: `${mileLabel(e)} status`, - text: arrow(e.fromValue, e.toValue), + text: withBooking(arrow(e.fromValue, e.toValue)), }; default: return { icon: , title: e.eventType, text: "" }; From 077667610aba6c56e064048666abfdb12a13eb80 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Fri, 3 Jul 2026 18:44:03 +0300 Subject: [PATCH 022/122] Add alternative schedule options if schedule not found --- .../portal/src/app/booking/results/page.tsx | 443 ++++++++++-------- 1 file changed, 260 insertions(+), 183 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 810e0689a..d1952da23 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -1,4 +1,4 @@ -'use client'; +'use client'; import { useSearchParams, useRouter } from 'next/navigation'; import { useQuery } from '@tanstack/react-query'; @@ -147,10 +147,16 @@ export default function ResultsPage() { // For one-way, check if outbound has results // For round-trip, check if BOTH outbound and inbound have results - const hasResults = isRoundTrip + const hasResults = isRoundTrip ? (outboundSchedules.length > 0 && inboundSchedules.length > 0) : outboundSchedules.length > 0; + // One-way searches that come back with an empty outbound list may still include + // date-shifted alternatives from the API — surface those instead of a dead end. + const isOneWayNoOutbound = !isRoundTrip && !!results && outboundSchedules.length === 0; + const alternativeOutbound: Schedule[] = isOneWayNoOutbound ? (results.alternativeOutbound || []) : []; + const requestedDate: string = (results && results.requestedDate) || searchData.date; + const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => { setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } })); }; @@ -217,7 +223,190 @@ export default function ResultsPage() { router.push('/booking/auth-check'); }; - const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false) => { + // Shared "Choose Your Coach" drawer — used by both the normal results view and the + // Alternative Travel Options fallback, so selecting an alternative opens the exact + // same coach-type picker as a normal schedule. + const renderClassModal = () => { + if (!classModal) return null; + + const scheduleId = classModal.scheduleId || classModal.id || ''; + const selectedCoachType = selectedCoachTypes[scheduleId]; + const isOutbound = (classModal as any).isOutbound; + const coachTypes = classModal.coachTypes || []; + + const getCoachIcon = (typeName: string) => { + const lower = typeName.toLowerCase(); + if (lower.includes('soft') || lower.includes('vip')) return Star; + if (lower.includes('bed')) return Bed; + return Armchair; + }; + + return ( + <> +
setClassModal(null)} /> +
+
+
+

Choose Your Coach

+

+ + {classModal.trainNumber} + · + {classModal.origin?.name} → {classModal.destination?.name} +

+
+ +
+ +
+ {coachTypes.length > 0 ? ( +
+ {coachTypes.map((coachType: any, index: number) => { + const isSelected = selectedCoachType?.id === coachType.coachTypeId; + const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0; + const coachCurrency = 'ETB'; + const CoachIcon = getCoachIcon(coachType.coachTypeName); + + return ( + + ); + })} +
+ ) : ( +
+
+ +
+

No coach types available for this journey

+
+ )} +
+ +
+
+ + {!selectedCoachType && ( +

+ + Select a coach type to continue +

+ )} +
+
+
+ + + ); + }; + + const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false, isAlternative: boolean = false) => { const scheduleId = schedule.scheduleId || schedule.id || ''; const selectedCoachType = selectedCoachTypes[scheduleId]; @@ -242,7 +431,22 @@ export default function ResultsPage() { const isNextDay = departureDate && arrivalDate && departureDate.toDateString() !== arrivalDate.toDateString(); return ( -
+
+ {isAlternative && ( +
+ + + Different date — {schedule.departureAt ? format(new Date(schedule.departureAt), 'EEEE, MMMM d, yyyy') : 'N/A'} + + + {schedule.hasAvailability ? 'Seats available' : 'Fully booked'} + +
+ )}
@@ -310,9 +514,10 @@ export default function ResultsPage() { )}
@@ -478,6 +683,54 @@ export default function ResultsPage() { } if (!hasResults) { + // ONE_WAY search with an explicit empty outbound list — surface any date-shifted + // alternatives the API suggests instead of a dead-end "no trains found" screen. + if (isOneWayNoOutbound) { + const requestedDateLabel = requestedDate + ? format(new Date(`${requestedDate}T00:00:00`), 'EEEE, MMMM d, yyyy') + : 'your selected date'; + const hasAlternatives = alternativeOutbound.length > 0; + + return ( +
+ {renderClassModal()} +
+
+
+
+ +
+

No trains available

+

+ No trains are available on {requestedDateLabel}. This + may be due to no scheduled service or full capacity. {hasAlternatives + ? 'Please check the alternative options below or try a different date.' + : 'Please try a different date.'} +

+ +
+ + {hasAlternatives && ( +
+
+

Alternative Travel Options

+

+ These trains run on different dates than requested — adjust your travel date to book one of them. +

+
+
+ {alternativeOutbound.map((schedule) => renderScheduleCard(schedule, true, true))} +
+
+ )} +
+
+
+ ); + } + return (
@@ -505,183 +758,7 @@ export default function ResultsPage() {
- {classModal && (() => { - const scheduleId = classModal.scheduleId || classModal.id || ''; - const selectedCoachType = selectedCoachTypes[scheduleId]; - const isOutbound = (classModal as any).isOutbound; - const coachTypes = classModal.coachTypes || []; - - const getCoachIcon = (typeName: string) => { - const lower = typeName.toLowerCase(); - if (lower.includes('soft') || lower.includes('vip')) return Star; - if (lower.includes('bed')) return Bed; - return Armchair; - }; - - return ( - <> -
setClassModal(null)} /> -
-
-
-

Choose Your Coach

-

- - {classModal.trainNumber} - · - {classModal.origin?.name} → {classModal.destination?.name} -

-
- -
- -
- {coachTypes.length > 0 ? ( -
- {coachTypes.map((coachType: any, index: number) => { - const isSelected = selectedCoachType?.id === coachType.coachTypeId; - const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0; - const coachCurrency = 'ETB'; - const CoachIcon = getCoachIcon(coachType.coachTypeName); - - return ( - - ); - })} -
- ) : ( -
-
- -
-

No coach types available for this journey

-
- )} -
- -
-
- - {!selectedCoachType && ( -

- - Select a coach type to continue -

- )} -
-
-
- - - ); - })()} + {renderClassModal()} {promoData && (
From c643d30624825009a96d2f537f0c9fcfa39fc330 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 15:47:12 +0000 Subject: [PATCH 023/122] fix --- .../modules/first-mile/first-mile.service.ts | 83 +++++++++++++++---- .../modules/last-mile/last-mile.service.ts | 60 ++++++++++---- .../src/modules/vehicles/vehicles.service.ts | 7 ++ .../components/fleet/FleetHistoryModal.tsx | 52 ++++++++---- 4 files changed, 157 insertions(+), 45 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 419b69969..43f02442a 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -48,13 +48,33 @@ export class FirstMileService { private readonly history: FleetHistoryService, ) { } - /** Resolve the driver currently assigned to a vehicle, for stamping mile - * events onto that driver's timeline. Best-effort — never throws. */ - private async resolveDriverId(vehicleId?: string | null): Promise { - if (!vehicleId) return null; + /** Resolve a vehicle's driver + human labels, for stamping mile events onto + * the driver's timeline and naming the vehicle. Best-effort — never throws. */ + private async vehicleInfo( + vehicleId?: string | null, + ): Promise<{ driverId: string | null; plate: string | null; driverName: string | null }> { + if (!vehicleId) return { driverId: null, plate: null, driverName: null }; try { - const vehicle = await this.vehiclesService.findById(vehicleId); - return vehicle.assignedDriverId ?? null; + const v = await this.vehiclesService.findById(vehicleId); + return { + driverId: v.assignedDriverId ?? null, + plate: v.plateNumber ?? v.code ?? null, + driverName: v.assignedDriverName ?? null, + }; + } catch { + return { driverId: null, plate: null, driverName: null }; + } + } + + /** Human booking reference for a first-mile record, for the history timeline. */ + private async resolveBookingRef(record: FirstMile): Promise { + const loaded = (record as FirstMile & { booking?: { reference?: string } }) + .booking?.reference; + if (loaded) return loaded; + if (!record.bookingId) return null; + try { + const b = await this.bookingsRepository.findById(record.bookingId); + return (b as { reference?: string } | null)?.reference ?? null; } catch { return null; } @@ -225,13 +245,19 @@ export class FirstMileService { if (dto.vehicleId) { await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + const info = await this.vehicleInfo(dto.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, vehicleId: dto.vehicleId, firstMileId: record.id, - driverId: await this.resolveDriverId(dto.vehicleId), + driverId: info.driverId, label: record.status, - metadata: { mile: 'FIRST' }, + metadata: { + mile: 'FIRST', + bookingRef: await this.resolveBookingRef(record), + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } @@ -302,23 +328,36 @@ export class FirstMileService { await this.vehiclesService.releaseIfUnused([existing.vehicleId]); } // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. + const bookingRef = await this.resolveBookingRef(existing); if (existing.vehicleId) { + const info = await this.vehicleInfo(existing.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_RELEASED, vehicleId: existing.vehicleId, firstMileId: id, - driverId: await this.resolveDriverId(existing.vehicleId), - metadata: { mile: 'FIRST' }, + driverId: info.driverId, + metadata: { + mile: 'FIRST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } if (dto.vehicleId) { + const info = await this.vehicleInfo(dto.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, vehicleId: dto.vehicleId, firstMileId: id, - driverId: await this.resolveDriverId(dto.vehicleId), + driverId: info.driverId, label: updated.status, - metadata: { mile: 'FIRST' }, + metadata: { + mile: 'FIRST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } } @@ -330,14 +369,20 @@ export class FirstMileService { if (dto.status !== undefined && dto.status !== existing.status) { const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + const info = await this.vehicleInfo(vehicleId); await this.history.record({ eventType: FleetEventType.MILE_STATUS_CHANGED, firstMileId: id, vehicleId, - driverId: await this.resolveDriverId(vehicleId), + driverId: info.driverId, fromValue: existing.status, toValue: dto.status, - metadata: { mile: 'FIRST' }, + metadata: { + mile: 'FIRST', + bookingRef: await this.resolveBookingRef(existing), + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } @@ -359,14 +404,20 @@ export class FirstMileService { if (status !== existing.status) { const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + const info = await this.vehicleInfo(vehicleId); await this.history.record({ eventType: FleetEventType.MILE_STATUS_CHANGED, firstMileId: id, vehicleId, - driverId: await this.resolveDriverId(vehicleId), + driverId: info.driverId, fromValue: existing.status, toValue: status, - metadata: { mile: 'FIRST' }, + metadata: { + mile: 'FIRST', + bookingRef: await this.resolveBookingRef(existing), + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index f5c9696e6..1e952b1ce 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -46,15 +46,21 @@ export class LastMileService { private readonly history: FleetHistoryService, ) {} - /** Resolve the driver currently assigned to a vehicle, for stamping mile - * events onto that driver's timeline. Best-effort — never throws. */ - private async resolveDriverId(vehicleId?: string | null): Promise { - if (!vehicleId) return null; + /** Resolve a vehicle's driver + human labels, for stamping mile events onto + * the driver's timeline and naming the vehicle. Best-effort — never throws. */ + private async vehicleInfo( + vehicleId?: string | null, + ): Promise<{ driverId: string | null; plate: string | null; driverName: string | null }> { + if (!vehicleId) return { driverId: null, plate: null, driverName: null }; try { - const vehicle = await this.vehiclesService.findById(vehicleId); - return vehicle.assignedDriverId ?? null; + const v = await this.vehiclesService.findById(vehicleId); + return { + driverId: v.assignedDriverId ?? null, + plate: v.plateNumber ?? v.code ?? null, + driverName: v.assignedDriverName ?? null, + }; } catch { - return null; + return { driverId: null, plate: null, driverName: null }; } } @@ -176,13 +182,19 @@ export class LastMileService { }); if (dto.vehicleId) { + const info = await this.vehicleInfo(dto.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, vehicleId: dto.vehicleId, lastMileId: record.id, - driverId: await this.resolveDriverId(dto.vehicleId), + driverId: info.driverId, label: record.status, - metadata: { mile: 'LAST', bookingRef: await this.resolveBookingRef(record) }, + metadata: { + mile: 'LAST', + bookingRef: await this.resolveBookingRef(record), + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } @@ -229,36 +241,54 @@ export class LastMileService { const bookingRef = await this.resolveBookingRef(existing); if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { if (existing.vehicleId) { + const info = await this.vehicleInfo(existing.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_RELEASED, vehicleId: existing.vehicleId, lastMileId: id, - driverId: await this.resolveDriverId(existing.vehicleId), - metadata: { mile: 'LAST', bookingRef }, + driverId: info.driverId, + metadata: { + mile: 'LAST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } if (dto.vehicleId) { + const info = await this.vehicleInfo(dto.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, vehicleId: dto.vehicleId, lastMileId: id, - driverId: await this.resolveDriverId(dto.vehicleId), + driverId: info.driverId, label: updated.status, - metadata: { mile: 'LAST', bookingRef }, + metadata: { + mile: 'LAST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } } if (dto.status !== undefined && dto.status !== existing.status) { const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + const info = await this.vehicleInfo(vehicleId); await this.history.record({ eventType: FleetEventType.MILE_STATUS_CHANGED, lastMileId: id, vehicleId, - driverId: await this.resolveDriverId(vehicleId), + driverId: info.driverId, fromValue: existing.status, toValue: dto.status, - metadata: { mile: 'LAST', bookingRef }, + metadata: { + mile: 'LAST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts index 345d4f896..25260e86f 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -51,6 +51,10 @@ export class VehiclesService { vehicleId: saved.id, driverId: saved.assignedDriverId, label: saved.assignedDriverName ?? null, + metadata: { + vehiclePlate: saved.plateNumber ?? saved.code ?? null, + driverName: saved.assignedDriverName ?? null, + }, }); } @@ -133,12 +137,14 @@ export class VehiclesService { dto.assignedDriverId !== undefined && dto.assignedDriverId !== prev.assignedDriverId ) { + const vehiclePlate = saved.plateNumber ?? saved.code ?? null; if (prev.assignedDriverId) { await this.history.record({ eventType: FleetEventType.DRIVER_UNASSIGNED, vehicleId: id, driverId: prev.assignedDriverId, label: prev.assignedDriverName ?? null, + metadata: { vehiclePlate, driverName: prev.assignedDriverName ?? null }, }); } if (saved.assignedDriverId) { @@ -147,6 +153,7 @@ export class VehiclesService { vehicleId: id, driverId: saved.assignedDriverId, label: saved.assignedDriverName ?? null, + metadata: { vehiclePlate, driverName: saved.assignedDriverName ?? null }, }); } } diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx index 0e7a0e65b..43f2bce4f 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx @@ -41,11 +41,24 @@ const mileLabel = (e: FleetHistoryEvent) => const arrow = (from?: string | null, to?: string | null) => `${from ?? "—"} → ${to ?? "—"}`; +const metaStr = (e: FleetHistoryEvent, key: string) => { + const v = e.metadata?.[key]; + return typeof v === "string" && v ? v : null; +}; + function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") { - const bookingRef = - typeof e.metadata?.bookingRef === "string" ? e.metadata.bookingRef : null; - const withBooking = (rest?: string) => - [bookingRef ? `Booking ${bookingRef}` : "", rest ?? ""] + const vehiclePlate = metaStr(e, "vehiclePlate"); + const driverName = metaStr(e, "driverName") ?? (e.label || null); + const bookingRef = metaStr(e, "bookingRef"); + + // Compose the detail line with whatever the current view doesn't already + // know: on a driver's timeline show which vehicle; always show the booking. + const detail = (extra?: string) => + [ + entity === "driver" && vehiclePlate ? `Vehicle ${vehiclePlate}` : "", + bookingRef ? `Booking ${bookingRef}` : "", + extra ?? "", + ] .filter(Boolean) .join(" · "); @@ -65,20 +78,31 @@ function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") { case "DRIVER_ASSIGNED": return { icon: , - title: + title: entity === "vehicle" ? "Driver assigned" : "Assigned to vehicle", + text: entity === "vehicle" - ? `Driver assigned${e.label ? `: ${e.label}` : ""}` - : "Assigned to a vehicle", - text: "", + ? driverName + ? `Driver ${driverName}` + : "" + : vehiclePlate + ? `Vehicle ${vehiclePlate}` + : "", }; case "DRIVER_UNASSIGNED": return { icon: , title: entity === "vehicle" - ? `Driver unassigned${e.label ? `: ${e.label}` : ""}` - : "Unassigned from a vehicle", - text: "", + ? "Driver unassigned" + : "Unassigned from vehicle", + text: + entity === "vehicle" + ? driverName + ? `Driver ${driverName}` + : "" + : vehiclePlate + ? `Vehicle ${vehiclePlate}` + : "", }; case "VEHICLE_STATUS_CHANGED": return { @@ -96,19 +120,19 @@ function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") { return { icon: , title: `${mileLabel(e)}: vehicle assigned`, - text: withBooking(e.label ? `Status: ${e.label}` : ""), + text: detail(e.label ? `Status: ${e.label}` : ""), }; case "MILE_VEHICLE_RELEASED": return { icon: , title: `${mileLabel(e)}: vehicle released`, - text: withBooking(), + text: detail(), }; case "MILE_STATUS_CHANGED": return { icon: , title: `${mileLabel(e)} status`, - text: withBooking(arrow(e.fromValue, e.toValue)), + text: detail(arrow(e.fromValue, e.toValue)), }; default: return { icon: , title: e.eventType, text: "" }; From 907ff2138bfeca9742a6e62cf3fe22de1bed1040 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 15:56:26 +0000 Subject: [PATCH 024/122] Add ScheduleWorkspacePanel and integrate it into TrainScheduleV2DetailPage with tabs --- .../ScheduleWorkspacePanel.tsx | 546 ++++++++++++++++++ .../TrainScheduleV2DetailPage.tsx | 31 +- .../TrainSchedulingGlobalRulesPage.tsx | 12 - 3 files changed, 576 insertions(+), 13 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx new file mode 100644 index 000000000..07222baa1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -0,0 +1,546 @@ +import { useMemo, useState } from "react"; +import { + Badge, + Box, + Button, + Group, + Modal, + Paper, + Progress, + ScrollArea, + Select, + Stack, + Text, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + AlertTriangle, + ArrowLeftRight, + ArrowRight, + CheckCircle2, + Inbox, + PackageCheck, + Repeat, + Train, + Weight, + X, +} from "lucide-react"; + +import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; +import { api } from "@/services/api"; +import { useToast } from "@/hooks/use-toast"; +import type { + EligibleContainerBooking, + FreightType, + TrainScheduleDetail, +} from "@/types/trainScheduling"; + +interface ScheduleWorkspacePanelProps { + schedule: TrainScheduleDetail; + /** Refetch the schedule detail after a mutation so both panels refresh. */ + onChanged: () => void; +} + +const GREEN = "var(--mantine-color-edr-green-6)"; + +/** Cargo weight already allocated to this train (sum of on-train bookings). */ +function usedWeight(schedule: TrainScheduleDetail): number { + return (schedule.bookings ?? []).reduce( + (sum, b) => sum + (Number(b.weightTons) || 0), + 0, + ); +} + +/** Max pull weight across all locomotives on the set (0 when unknown). */ +function pullCapacity(schedule: TrainScheduleDetail): number { + const set = schedule.trainSet; + if (!set) return 0; + const locos = + set.locomotives && set.locomotives.length > 0 + ? set.locomotives + : set.locomotive + ? [set.locomotive] + : []; + return locos.reduce((sum, l) => sum + (Number(l.maxPullWeightTons) || 0), 0); +} + +export function ScheduleWorkspacePanel({ + schedule, + onChanged, +}: ScheduleWorkspacePanelProps) { + const { toast } = useToast(); + + const freightType: FreightType | undefined = + schedule.freightType === "CONTAINER" || schedule.freightType === "BULK" + ? schedule.freightType + : undefined; + + const locked = ["DISPATCHED", "ARRIVED"].includes(schedule.status); + const canManage = ["DRAFT", "SCHEDULED"].includes(schedule.status); + + // Pool = accepted, ready-to-pay bookings on THIS train's route+day that are not + // yet linked to any schedule (same filter the auto-batch uses). + const poolQuery = useQuery( + api.trainScheduling.eligibleBookings.queryOptions({ + input: { + filters: { + originStationId: schedule.originStation?.id, + destinationStationId: schedule.destinationStation?.id, + trainScheduleId: schedule.id, + }, + freightType, + }, + enabled: Boolean(schedule.originStation?.id && schedule.destinationStation?.id), + }), + ); + + const onTrainIds = useMemo( + () => new Set((schedule.bookings ?? []).map((b) => b.id)), + [schedule.bookings], + ); + + const pool: EligibleContainerBooking[] = useMemo( + () => (poolQuery.data?.items ?? []).filter((b) => !onTrainIds.has(b.id)), + [poolQuery.data, onTrainIds], + ); + + const onTrain = schedule.bookings ?? []; + + // ── Mutations (reuse the existing endpoints) ─────────────────────────────── + const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions()); + const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions()); + const moveSchedule = useMutation( + api.trainScheduling.moveBookingSchedule.mutationOptions(), + ); + + const [moveBookingId, setMoveBookingId] = useState(null); + const [moveTarget, setMoveTarget] = useState(null); + + const { data: targets } = useQuery( + api.trainScheduling.bookableSchedules.queryOptions({ + input: { + originYardId: schedule.originStation?.id, + destinationYardId: schedule.destinationStation?.id, + }, + enabled: Boolean( + schedule.originStation?.id && schedule.destinationStation?.id, + ), + }), + ); + const moveOptions = useMemo( + () => + (targets ?? []) + .filter((s) => s.id !== schedule.id) + .map((s) => ({ + value: s.id, + label: `${s.routeName ?? `${s.origin} → ${s.destination}`} · ${new Date( + s.scheduleDate, + ).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} free`, + })), + [targets, schedule.id], + ); + + // ── Capacity meter (by cargo weight vs locomotive pull) ──────────────────── + const used = usedWeight(schedule); + const capacity = pullCapacity(schedule); + const pct = capacity > 0 ? Math.min(100, Math.round((used / capacity) * 100)) : 0; + const over = capacity > 0 && used > capacity; + + const forceAdd = (bookingId: string, ref: string, weightTons: number) => { + const wouldOverfill = capacity > 0 && used + (weightTons || 0) > capacity; + assign + .mutateAsync({ + id: schedule.id, + freightType, + payload: { + bookingIds: [...onTrainIds, bookingId], + forceAssign: true, + }, + }) + .then(() => { + toast({ + title: `${ref} added to train`, + description: wouldOverfill + ? "Force-added past the pull-weight limit — review capacity." + : "Wagons auto-pinned.", + variant: wouldOverfill ? "destructive" : undefined, + }); + onChanged(); + void poolQuery.refetch(); + }) + .catch(() => + toast({ title: "Could not add booking", variant: "destructive" }), + ); + }; + + const removeFromTrain = (bookingId: string, ref: string) => { + unassign + .mutateAsync({ id: schedule.id, bookingId }) + .then(() => { + toast({ title: `${ref} removed from train` }); + onChanged(); + void poolQuery.refetch(); + }) + .catch(() => + toast({ title: "Could not remove booking", variant: "destructive" }), + ); + }; + + const doMove = () => { + if (!moveBookingId || !moveTarget) return; + moveSchedule + .mutateAsync({ bookingId: moveBookingId, trainScheduleId: moveTarget }) + .then(() => { + toast({ title: "Booking reassigned to another train" }); + setMoveBookingId(null); + onChanged(); + void poolQuery.refetch(); + }) + .catch(() => + toast({ title: "Could not reassign booking", variant: "destructive" }), + ); + }; + + return ( + + + {/* Header + capacity meter */} + + + + + +
+ Allocation workspace + + Manually add ready-to-pay bookings, remove, or reassign them + +
+
+ + + + + + + Load {used.toFixed(1)}T + {capacity > 0 ? ` / ${capacity.toFixed(0)}T pull` : ""} + + + {over ? ( + + Over capacity + + ) : ( + + {capacity > 0 ? `${pct}%` : "—"} + + )} + + 0 ? pct : 0} + color={over ? "red" : pct > 85 ? "orange" : "edr-green"} + radius="xl" + size="md" + /> + +
+ + {over ? ( + + + + This train is loaded beyond its locomotive pull weight. Force-adds are + allowed, but review before dispatch. + + + ) : null} + + {locked ? ( + + This train is {schedule.status.toLowerCase()} — bookings can no longer be + changed. + + ) : null} + + {/* Two-panel board */} + + {/* Pool */} + + {pool.map((b) => ( + + + + ) : null + } + /> + ))} + + + {/* On train */} + + {onTrain.map((b) => ( + + + + + + + + + ) : null + } + /> + ))} + + +
+ + {/* Reassign modal */} + setMoveBookingId(null)} + title={ + + + Reassign booking to another train + + } + centered + radius="lg" + > + + { + if (!next) return; + // Only the display unit changes; the stored native value stays put. + // displayValue re-derives from it on the next render. + setUnit(next as DurationUnit); + }} + allowDeselect={false} + disabled={disabled} + w={90} + /> + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/hooks/use-toast.ts b/apps/edr-freight-web/backoffice/src/hooks/use-toast.ts index 7b15a028d..48acaa38d 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/use-toast.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/use-toast.ts @@ -1,3 +1,4 @@ +import { useCallback } from 'react'; import toast from 'react-hot-toast'; interface ToastOptions { @@ -8,7 +9,9 @@ interface ToastOptions { } export function useToast() { - const showToast = (options: ToastOptions) => { + // Stable identity so callers can safely list `toast` in effect/callback deps + // without re-firing on every render. + const showToast = useCallback((options: ToastOptions) => { const { title, description, variant = 'default', duration = 3000 } = options; const message = title ? `${title}${description ? ': ' + description : ''}` : description || ''; @@ -18,7 +21,7 @@ export function useToast() { } else { toast.success(message, { duration }); } - }; + }, []); return { toast: showToast }; } diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx index 8502a996c..6a451ba56 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx @@ -61,9 +61,11 @@ import { useContractMutations, } from "@/hooks/contracts/useContracts"; import { contractsService } from "@/services/contracts.service"; +import { api } from "@/services/api"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { fileViewUrl } from "@/constants/apiConfig"; import { downloadBookingFile } from "@/services/files.service"; +import type { CustomerDocument } from "@/types/customer"; import type { Freight } from "@edr/types"; // Clearance phase — staff can still ACT (approve / query / finalize). @@ -152,6 +154,34 @@ export default function ContractRequestDetailPage() { enabled: Boolean(id) && showClearanceTabQuery, }); + // Customer profile documents (national ID, TIN, import/business license) for + // the company this contract belongs to. Shown as a separate section in the + // Documents tab, alongside the contract's own attached files. + const companyId = contract?.companyId ?? ""; + const profileDocumentsQuery = useQuery( + api.customers.documents.queryOptions({ + input: { id: companyId }, + enabled: Boolean(companyId), + }), + ); + const profileDocumentsRaw = Array.isArray(profileDocumentsQuery.data) + ? profileDocumentsQuery.data + : []; + // Reshape to the contract-file shape so we can reuse ContractDocumentsCard. + const profileDocuments = profileDocumentsRaw.map( + (doc: CustomerDocument) => + ({ + id: doc.id, + code: doc.code, + name: doc.name, + url: doc.url ?? "", + mimeType: doc.mimeType, + size: doc.size, + resourceId: companyId, + resource: "company", + }) satisfies NonNullable[number], + ); + const downloadContractPdf = async () => { if (!contract?.id) return; try { @@ -247,6 +277,11 @@ export default function ContractRequestDetailPage() { const selfClear = !contract.customsClearingEnabled; const files = contract.files ?? []; const contractPdf = files.find((f) => f.code === "contract"); + // Signature files (code `signature_`) are baked into the contract PDF — + // don't list them as standalone documents in the Documents tab. + const contractDocuments = files.filter( + (f) => !f.code.startsWith("signature_"), + ); const hasContractDocument = Boolean( contractPdf || contract.contractGeneratedAt, ); @@ -406,9 +441,9 @@ export default function ContractRequestDetailPage() { value="documents" leftSection={} rightSection={ - files.length > 0 ? ( + contractDocuments.length + profileDocuments.length > 0 ? ( - {files.length} + {contractDocuments.length + profileDocuments.length} ) : null } @@ -453,7 +488,18 @@ export default function ContractRequestDetailPage() { ) : currentTab === "documents" ? ( + diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx index fb778c4c1..4cccd35b4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { Button, Card, Group, NumberInput, Stack } from "@mantine/core"; import { PageContainer, PageHeader } from "@/components/page"; +import DurationField from "@/components/trainScheduling/DurationField"; import { trainSchedulingService } from "@/services/trainScheduling.service"; import { useToast } from "@/hooks/use-toast"; import type { TrainSchedulingGlobalRules } from "@/types/trainScheduling"; @@ -19,14 +20,30 @@ export default function TrainSchedulingGlobalRulesPage() { void (async () => { try { const rules = await trainSchedulingService.getGlobalRules(); - setForm(rules); + // `numeric` columns come back from the API as strings (e.g. "250.00"). + // Coerce every field to a real number so Mantine's controlled + // NumberInput edits cleanly (a string value fights the caret) and the + // default can be cleared and replaced. + const numeric: Partial> = {}; + for (const [key, value] of Object.entries(rules)) { + if (key === "id") continue; + const num = value === "" || value == null ? "" : Number(value); + numeric[key as keyof TrainSchedulingGlobalRules] = + typeof num === "number" && Number.isNaN(num) ? "" : num; + } + setForm(numeric); } catch { toast({ title: "Failed to load train scheduling rules", variant: "destructive" }); } finally { setLoading(false); } })(); - }, [toast]); + // Run once on mount only. `toast` from useToast is a fresh function every + // render — listing it here re-fired the effect on every render, refetching + // the rules and overwriting whatever the user was typing (values snapped + // back to the saved defaults). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const handleSave = async () => { // Every field must hold a real number — an empty box (cleared but not @@ -88,6 +105,8 @@ export default function TrainSchedulingGlobalRulesPage() { onChange={(value) => setForm((current) => ({ ...current, maxTrainLengthMeters: value })) } + clampBehavior="none" + allowDecimal min={1} disabled={loading} /> @@ -98,6 +117,8 @@ export default function TrainSchedulingGlobalRulesPage() { onChange={(value) => setForm((current) => ({ ...current, maxTrainWeightTons: value })) } + clampBehavior="none" + allowDecimal min={1} disabled={loading} /> @@ -107,6 +128,8 @@ export default function TrainSchedulingGlobalRulesPage() { onChange={(value) => setForm((current) => ({ ...current, maxWagonsPerTrain: value })) } + clampBehavior="none" + allowDecimal min={1} disabled={loading} /> @@ -120,6 +143,8 @@ export default function TrainSchedulingGlobalRulesPage() { max20ftContainerWeightTons: value, })) } + clampBehavior="none" + allowDecimal min={0.001} disabled={loading} /> @@ -133,6 +158,8 @@ export default function TrainSchedulingGlobalRulesPage() { max20ftPairWeightDiffTons: value, })) } + clampBehavior="none" + allowDecimal min={0} disabled={loading} /> @@ -145,20 +172,22 @@ export default function TrainSchedulingGlobalRulesPage() { title="Booking windows" subtitle="Import booking-day cycle and export lead time. All times in Addis Ababa (EAT)." /> - setForm((current) => ({ ...current, importWindowLeadDays: value })) } min={0} disabled={loading} /> - setForm((current) => ({ ...current, exportBookingLeadHours: value })) } @@ -172,45 +201,50 @@ export default function TrainSchedulingGlobalRulesPage() { onChange={(value) => setForm((current) => ({ ...current, windowOpenHour: value })) } + clampBehavior="none" + allowDecimal min={0} max={23} disabled={loading} /> - setForm((current) => ({ ...current, windowDurationHours: value })) } - min={0.25} - max={12} - step={0.25} + min={1} disabled={loading} /> - setForm((current) => ({ ...current, docReviewMinutes: value })) } min={0} disabled={loading} /> - setForm((current) => ({ ...current, paymentWindowMinutes: value })) } min={1} disabled={loading} /> - setForm((current) => ({ ...current, reopenDelayMinutes: value })) } From aeb06e8a552f7acb6c009e2e9c24615ce87b072c Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:39:41 +0000 Subject: [PATCH 031/122] fix --- .../src/pages/operations/LastMilePage.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 56fbcc3fc..b55320e7a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -49,7 +49,7 @@ import { driversService, type Driver } from "@/services/drivers.service"; import { ratesService } from "@/services/rates.service"; import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable"; import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal"; -import { LastMileStepBar, LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; +import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; import { api } from "@/auth/http"; const formatPrice = (amount: number) => @@ -988,20 +988,6 @@ const LastMilePage = () => { ), }, - { - id: "progress", - header: "Progress", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => ( - - ), - }, { id: "actions", header: "Actions", From 53229f3da9818e6403111865429f37f841e549ff Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:42:38 +0000 Subject: [PATCH 032/122] fix --- .../backoffice/src/pages/operations/LastMilePage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index b55320e7a..780c6f881 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -111,6 +111,7 @@ const computeLastMileSteps = ( ): LastMileStepState[] => { const exactKm = (record as { exactKm?: number | null }).exactKm; const flags = [ + record.status !== "PAYMENT_PENDING", Boolean(record.vehicleId), Boolean(releaseRow?.releaseOrderReference), Boolean(releaseRow?.releaseDate), @@ -120,8 +121,9 @@ const computeLastMileSteps = ( ]; // Current step = earliest incomplete one. const activeIdx = flags.findIndex((f) => !f); - const labels = ["Assign vehicle", "Truck arrived", "Truck leave", "In transit", "Add distance", "Delivered"]; + const labels = ["Ready to Transit", "Assign vehicle", "Truck arrived", "Truck leave", "In transit", "Add distance", "Delivered"]; const details: (string | null)[] = [ + null, record.vehicle?.plateNumber ?? null, releaseRow?.releaseOrderReference ?? null, fmtStamp(releaseRow?.releaseDate), From 482c569216270e4b37ace9d4480c6d89bdbdbc2c Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:43:58 +0000 Subject: [PATCH 033/122] fix --- .../backoffice/src/pages/operations/FirstMilePage.tsx | 2 +- .../backoffice/src/pages/operations/LastMilePage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index bbd5acc0c..68e9745f1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -958,7 +958,7 @@ const FirstMilePage = () => { }, [vehicleOptions]); return ( - + diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 780c6f881..4854ee7a7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -1095,7 +1095,7 @@ const LastMilePage = () => { }, [vehicleOptions, pickupReadyByBooking]); return ( - + From b7f6c3150e78dedcd2a2ac56883459063e39eb91 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:46:31 +0000 Subject: [PATCH 034/122] fix --- .../src/pages/operations/LastMilePage.tsx | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 4854ee7a7..71964c038 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -1003,6 +1003,13 @@ const LastMilePage = () => { const releaseRow = pickupReadyByBooking.get(row.original.bookingId) ?? pickupReadyByBooking.get(bookingRef(row.original)); const truckArrivalLabel = releaseRow?.releaseOrderReference ? "Truck Leaving" : "Truck Arrival"; + // Only the current step's action is active; the rest stay disabled. + // Step order: 0 Ready·1 Assign·2 Arrived·3 Leave·4 In-transit·5 Distance·6 Delivered. + const activeStep = computeLastMileSteps(row.original, releaseRow).findIndex((s) => s.active); + const canAdvance = activeStep === 0 || activeStep === 4 || activeStep === 6; + const canAssignStep = activeStep === 1; + const canTruck = activeStep === 2 || activeStep === 3; + const canDistance = activeStep === 5; return ( @@ -1014,19 +1021,17 @@ const LastMilePage = () => { } - disabled={!nextStatus || (nextStatus === "IN_TRANSIT" && !assigned)} + disabled={!nextStatus || !canAdvance} onClick={() => handleAdvanceStatus(row.original)} > - {nextStatus === "IN_TRANSIT" && !assigned - ? "Assign a vehicle first" - : nextStatus - ? `Mark ${STATUS_META[nextStatus].label}` - : STATUS_META[row.original.status].label} + {nextStatus + ? `Mark ${STATUS_META[nextStatus].label}` + : STATUS_META[row.original.status].label} } - disabled={assigned || delivered} + disabled={!canAssignStep} onClick={() => openAssign(row.original.id)} > Assign @@ -1040,7 +1045,7 @@ const LastMilePage = () => { } - disabled={!assigned} + disabled={!canTruck} onClick={() => openTruckArrival(row.original)} > {truckArrivalLabel} @@ -1054,7 +1059,7 @@ const LastMilePage = () => { } - disabled={delivered} + disabled={!canDistance} onClick={() => openDistance(row.original.id)} > Add distance From f0035a3695475fba45c44596a4c5f09bf40e6ec3 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:52:31 +0000 Subject: [PATCH 035/122] fix --- .../backoffice/src/pages/operations/FirstMilePage.tsx | 8 ++++++++ .../backoffice/src/pages/operations/LastMilePage.tsx | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 68e9745f1..2ec99f496 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -6,6 +6,7 @@ import { MoreHorizontal, PackageCheck, Printer, + Receipt, RefreshCw, Ruler, Trash, @@ -923,6 +924,13 @@ const FirstMilePage = () => { > Add distance + } + disabled={!(row.original.exactKm != null && row.original.exactKm > 0)} + onClick={() => openInvoice(row.original)} + > + Generate Invoice + {canPrint && ( } diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 71964c038..664a23069 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -4,6 +4,7 @@ import { Eye, MoreHorizontal, Printer, + Receipt, RefreshCw, Ruler, Trash, @@ -1064,6 +1065,13 @@ const LastMilePage = () => { > Add distance + } + disabled={!(row.original.exactKm != null && row.original.exactKm > 0)} + onClick={() => openInvoice(row.original)} + > + Generate Invoice + {canPrint && ( } From ac55a867c0314e68f0474903c9831edc0d45223c Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:56:23 +0000 Subject: [PATCH 036/122] fix --- .../src/pages/operations/LastMilePage.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 664a23069..dd173ba3e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -1003,14 +1003,17 @@ const LastMilePage = () => { const delivered = row.original.status === "DELIVERED"; const releaseRow = pickupReadyByBooking.get(row.original.bookingId) ?? pickupReadyByBooking.get(bookingRef(row.original)); - const truckArrivalLabel = releaseRow?.releaseOrderReference ? "Truck Leaving" : "Truck Arrival"; // Only the current step's action is active; the rest stay disabled. // Step order: 0 Ready·1 Assign·2 Arrived·3 Leave·4 In-transit·5 Distance·6 Delivered. const activeStep = computeLastMileSteps(row.original, releaseRow).findIndex((s) => s.active); const canAdvance = activeStep === 0 || activeStep === 4 || activeStep === 6; const canAssignStep = activeStep === 1; - const canTruck = activeStep === 2 || activeStep === 3; const canDistance = activeStep === 5; + // Truck arrival/leaving are independent of the step sequence — each + // driven only by its own state: arrive once assigned & not arrived, + // leave once arrived & not departed. + const canArrive = assigned && !releaseRow?.releaseOrderReference; + const canLeave = Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate; return ( @@ -1046,10 +1049,17 @@ const LastMilePage = () => { } - disabled={!canTruck} + disabled={!canArrive} onClick={() => openTruckArrival(row.original)} > - {truckArrivalLabel} + Truck Arrival + + } + disabled={!canLeave} + onClick={() => openTruckArrival(row.original)} + > + Truck Leaving Date: Fri, 3 Jul 2026 16:58:42 +0000 Subject: [PATCH 037/122] fix --- .../backoffice/src/pages/operations/LastMilePage.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index dd173ba3e..565edcc79 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -118,11 +118,12 @@ const computeLastMileSteps = ( Boolean(releaseRow?.releaseDate), record.status === "IN_TRANSIT" || record.status === "DELIVERED", exactKm != null, + exactKm != null, // Generate Invoice — auto-generated when distance is saved record.status === "DELIVERED", ]; // Current step = earliest incomplete one. const activeIdx = flags.findIndex((f) => !f); - const labels = ["Ready to Transit", "Assign vehicle", "Truck arrived", "Truck leave", "In transit", "Add distance", "Delivered"]; + const labels = ["Ready to Transit", "Assign vehicle", "Truck arrived", "Truck leave", "In transit", "Add distance", "Generate Invoice", "Delivered"]; const details: (string | null)[] = [ null, record.vehicle?.plateNumber ?? null, @@ -130,6 +131,7 @@ const computeLastMileSteps = ( fmtStamp(releaseRow?.releaseDate), null, exactKm != null ? `${exactKm} KM` : null, + exactKm != null ? "Invoice ready" : null, fmtStamp(releaseRow?.deliveredAt), ]; return labels.map((label, i) => ({ @@ -1004,9 +1006,9 @@ const LastMilePage = () => { const releaseRow = pickupReadyByBooking.get(row.original.bookingId) ?? pickupReadyByBooking.get(bookingRef(row.original)); // Only the current step's action is active; the rest stay disabled. - // Step order: 0 Ready·1 Assign·2 Arrived·3 Leave·4 In-transit·5 Distance·6 Delivered. + // Steps: 0 Ready·1 Assign·2 Arrived·3 Leave·4 In-transit·5 Distance·6 Invoice·7 Delivered. const activeStep = computeLastMileSteps(row.original, releaseRow).findIndex((s) => s.active); - const canAdvance = activeStep === 0 || activeStep === 4 || activeStep === 6; + const canAdvance = activeStep === 0 || activeStep === 4 || activeStep === 7; const canAssignStep = activeStep === 1; const canDistance = activeStep === 5; // Truck arrival/leaving are independent of the step sequence — each From aaaa38f2344f295ff6a64243ed00f4396b9b0b74 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 17:04:41 +0000 Subject: [PATCH 038/122] fix --- .../src/pages/operations/LastMilePage.tsx | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 565edcc79..2fb05b0de 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -111,12 +111,16 @@ const computeLastMileSteps = ( releaseRow?: ImportUnloadedItem, ): LastMileStepState[] => { const exactKm = (record as { exactKm?: number | null }).exactKm; + // Truck arrival/leave live in the transient warehouse pickup-ready queue and + // vanish once the item is released. So once the leg is IN_TRANSIT/DELIVERED, + // treat both as done (the truck must have arrived + left to get there). + const past = record.status === "IN_TRANSIT" || record.status === "DELIVERED"; const flags = [ record.status !== "PAYMENT_PENDING", Boolean(record.vehicleId), - Boolean(releaseRow?.releaseOrderReference), - Boolean(releaseRow?.releaseDate), - record.status === "IN_TRANSIT" || record.status === "DELIVERED", + past || Boolean(releaseRow?.releaseOrderReference), + past || Boolean(releaseRow?.releaseDate), + past, exactKm != null, exactKm != null, // Generate Invoice — auto-generated when distance is saved record.status === "DELIVERED", @@ -1005,15 +1009,22 @@ const LastMilePage = () => { const delivered = row.original.status === "DELIVERED"; const releaseRow = pickupReadyByBooking.get(row.original.bookingId) ?? pickupReadyByBooking.get(bookingRef(row.original)); - // Only the current step's action is active; the rest stay disabled. - // Steps: 0 Ready·1 Assign·2 Arrived·3 Leave·4 In-transit·5 Distance·6 Invoice·7 Delivered. - const activeStep = computeLastMileSteps(row.original, releaseRow).findIndex((s) => s.active); - const canAdvance = activeStep === 0 || activeStep === 4 || activeStep === 7; - const canAssignStep = activeStep === 1; - const canDistance = activeStep === 5; - // Truck arrival/leaving are independent of the step sequence — each - // driven only by its own state: arrive once assigned & not arrived, - // leave once arrived & not departed. + // Gate on PERSISTENT state (status/vehicle/distance), not the truck + // arrival/leave signals — those live in the warehouse queue and vanish + // once the item is released, so they can't gate the status advance. + const status = row.original.status; + const hasDistance = row.original.exactKm != null; + // Advance: PAYMENT_PENDING→Ready, READY_TO_TRANSIT→In-transit (needs a + // vehicle), IN_TRANSIT→Delivered (needs distance/invoice). + const canAdvance = + status === "PAYMENT_PENDING" || + (status === "READY_TO_TRANSIT" && assigned) || + (status === "IN_TRANSIT" && hasDistance); + const canAssignStep = !assigned && status !== "DELIVERED"; + const canDistance = status === "IN_TRANSIT"; + // Truck arrival/leaving are independent — each driven only by its own + // warehouse state: arrive once assigned & not arrived, leave once + // arrived & not departed. const canArrive = assigned && !releaseRow?.releaseOrderReference; const canLeave = Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate; return ( From 0ac5adcee9a2e537763b43b966571982809bb2fe Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 17:11:35 +0000 Subject: [PATCH 039/122] Add countdown timer component and integrate phase deadlines in booking windows --- .../train-scheduling.service.ts | 28 ++++++ .../ScheduleWorkspacePanel.tsx | 47 ++++++++++ .../backoffice/src/types/trainScheduling.ts | 5 ++ .../components/UpcomingWindowsSection.tsx | 36 ++++++++ .../BookingDetailPage/ReadonlyBookingView.tsx | 60 ++----------- .../components/ContractCard.tsx | 26 +----- .../portal/src/services/bookings.service.ts | 2 + .../CountdownTimer/CountdownTimer.tsx | 87 +++++++++++++++++++ .../src/components/CountdownTimer/index.ts | 2 + packages/ui-common/src/index.ts | 3 + 10 files changed, 217 insertions(+), 79 deletions(-) create mode 100644 packages/ui-common/src/components/CountdownTimer/CountdownTimer.tsx create mode 100644 packages/ui-common/src/components/CountdownTimer/index.ts diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 0aadc62ab..53ebc445b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -178,6 +178,8 @@ interface BookingWindowRow { window_phase: string | null; window_opens_at: Date | null; window_closes_at: Date | null; + doc_review_ends_at: Date | null; + payment_phase_ends_at: Date | null; booking_window_status: string; booking_cycle_no: number; scheduled_departure_date: Date; @@ -3027,6 +3029,8 @@ export class TrainSchedulingService { ts.window_phase, ts.window_opens_at, ts.window_closes_at, + ts.doc_review_ends_at, + ts.payment_phase_ends_at, ts.booking_window_status, ts.booking_cycle_no, ts.scheduled_departure_date, @@ -3041,6 +3045,7 @@ export class TrainSchedulingService { ON c.id = cr.contract_id AND c.company_id = $1 AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') + AND c.contract_kind = 'GENERAL' AND c.deleted_at IS NULL LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id @@ -3068,6 +3073,8 @@ export class TrainSchedulingService { ts.window_phase, ts.window_opens_at, ts.window_closes_at, + ts.doc_review_ends_at, + ts.payment_phase_ends_at, ts.booking_window_status, ts.booking_cycle_no, ts.scheduled_departure_date, @@ -3079,6 +3086,10 @@ export class TrainSchedulingService { AND cr.destination_yard_id = ts.destination_station_id AND cr.contract_id = $1 AND cr.deleted_at IS NULL + JOIN freight.contracts c + ON c.id = cr.contract_id + AND c.contract_kind = 'GENERAL' + AND c.deleted_at IS NULL LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id WHERE ts.deleted_at IS NULL @@ -3101,6 +3112,8 @@ export class TrainSchedulingService { isOpenNow: r.window_phase === 'OPEN' && r.booking_window_status === 'OPEN', windowOpensAt: r.window_opens_at, windowClosesAt: r.window_closes_at, + docReviewEndsAt: r.doc_review_ends_at, + paymentPhaseEndsAt: r.payment_phase_ends_at, bookingWindowStatus: r.booking_window_status, bookingCycleNo: r.booking_cycle_no, departureDate: r.scheduled_departure_date, @@ -3388,6 +3401,21 @@ export class TrainSchedulingService { freightType: this.resolveScheduleFreightType(schedule), trainNumber: schedule.trainNumber ?? null, direction: schedule.direction ?? null, + // Booking-window phase + phase deadlines drive the countdown timers in the + // operations workspace (display only — the window engine enforces them). + windowPhase: schedule.windowPhase ?? null, + windowOpensAt: schedule.windowOpensAt + ? schedule.windowOpensAt.toISOString() + : null, + windowClosesAt: schedule.windowClosesAt + ? schedule.windowClosesAt.toISOString() + : null, + docReviewEndsAt: schedule.docReviewEndsAt + ? schedule.docReviewEndsAt.toISOString() + : null, + paymentPhaseEndsAt: schedule.paymentPhaseEndsAt + ? schedule.paymentPhaseEndsAt.toISOString() + : null, route: schedule.route ? { id: schedule.route.id, name: formatRouteLabel(schedule.route) } : null, diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index 07222baa1..4c2ccb57e 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -28,6 +28,8 @@ import { X, } from "lucide-react"; +import { CountdownTimer } from "@edr/ui-common"; + import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; @@ -45,6 +47,32 @@ interface ScheduleWorkspacePanelProps { const GREEN = "var(--mantine-color-edr-green-6)"; +/** + * Deadline + label for the window phase this schedule is currently in. + * Phases run: window open (windowClosesAt) → document review (docReviewEndsAt) + * → payment (paymentPhaseEndsAt). Display only. Returns null off-phase. + */ +function phaseCountdown( + schedule: TrainScheduleDetail, +): { label: string; deadline: string } | null { + switch (schedule.windowPhase) { + case "OPEN": + return schedule.windowClosesAt + ? { label: "Booking window closes in", deadline: schedule.windowClosesAt } + : null; + case "DOC_REVIEW": + return schedule.docReviewEndsAt + ? { label: "Document review ends in", deadline: schedule.docReviewEndsAt } + : null; + case "PAYMENT": + return schedule.paymentPhaseEndsAt + ? { label: "Payment window ends in", deadline: schedule.paymentPhaseEndsAt } + : null; + default: + return null; + } +} + /** Cargo weight already allocated to this train (sum of on-train bookings). */ function usedWeight(schedule: TrainScheduleDetail): number { return (schedule.bookings ?? []).reduce( @@ -248,6 +276,25 @@ export function ScheduleWorkspacePanel({ + {(() => { + const cd = phaseCountdown(schedule); + return cd ? ( + + + + ) : null; + })()} + {over ? ( + {(() => { + const cd = phaseCountdown(w); + return cd ? ( + + + + ) : null; + })()} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index f0f75f537..6d274f22f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -1,12 +1,10 @@ -import { Box, Group, Text } from "@mantine/core"; +import { Group } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { CreditCard, Download, Eye } from "lucide-react"; +import { CreditCard } from "lucide-react"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { isViewable } from "@edr/ui-common"; import { api } from "@/services/api"; -import { fileViewUrl } from "@/constants/apiConfig"; import { useFileViewer } from "@/hooks/useFileViewer"; import { invoicesService } from "@/services/invoices.service"; import { paymentsService, type PaymentMethod } from "@/services/payments.service"; @@ -19,9 +17,8 @@ import { ClearanceCard } from "./components/ClearanceCard"; import { ContainersCard } from "./components/ContainersCard"; import { ContractCard } from "./components/ContractCard"; import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard"; -import { DocRow, IconSquare } from "./components/Documents"; import { KeyFactsStrip } from "./components/KeyFactsStrip"; -import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout"; +import { BodyGrid, PageShell } from "./components/layout"; import { CancelledBanner, ConsolidationPairedNotice, @@ -51,7 +48,7 @@ export function ReadonlyBookingView({ useScrollToHash(); const status = booking.status as string; const [payModalOpen, setPayModalOpen] = useState(false); - const { view, viewer } = useFileViewer(); + const { viewer } = useFileViewer(); // Re-book opens the New Shipment Booking form for the same contract, not the // New Contract page. Fall back to /contracts/new only if the link is missing. @@ -160,7 +157,6 @@ export function ReadonlyBookingView({ ) } menuActions={{ - onViewContract: booking.signedByCeoAt ? () => {} : undefined, onRebook, onSupport: () => navigate("/support"), }} @@ -199,7 +195,7 @@ export function ReadonlyBookingView({ - + {isClearance && } @@ -220,52 +216,6 @@ export function ReadonlyBookingView({ )} - {booking.files && booking.files.length > 0 && ( - - - Documents - - {booking.files.length} files - - - - {booking.files.map((file, i) => ( - - {isViewable({ - name: file.name, - url: fileViewUrl(file.id), - mimeType: file.mimeType, - }) && ( - } - onClick={() => - view({ - name: file.name, - url: fileViewUrl(file.id), - mimeType: file.mimeType, - }) - } - /> - )} - } - /> - - } - /> - ))} - - - )} - } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractCard.tsx index f56e07349..083e4de26 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractCard.tsx @@ -1,6 +1,4 @@ -import { Box, Button, Group, Paper, Text } from "@mantine/core"; -import { FileSignature } from "lucide-react"; -import type { useNavigate } from "react-router-dom"; +import { Box, Group, Paper, Text } from "@mantine/core"; import type { Freight } from "@edr/types"; @@ -37,13 +35,7 @@ const CONTRACT_CONFIG: Record< }, }; -export function ContractCard({ - booking, - navigate, -}: { - booking: Freight.IBooking; - navigate: ReturnType; -}) { +export function ContractCard({ booking }: { booking: Freight.IBooking }) { const c = CONTRACT_CONFIG[booking.status as string]; if (!c) return null; @@ -76,20 +68,6 @@ export function ContractCard({ {c.description} - {c.buttonLabel && ( - - )} ); diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index f92e20cf4..c6481bb27 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -60,6 +60,8 @@ export interface MyBookingWindow { isOpenNow: boolean; windowOpensAt: string | null; windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; bookingWindowStatus: string; bookingCycleNo: number; departureDate: string; diff --git a/packages/ui-common/src/components/CountdownTimer/CountdownTimer.tsx b/packages/ui-common/src/components/CountdownTimer/CountdownTimer.tsx new file mode 100644 index 000000000..faadb0adf --- /dev/null +++ b/packages/ui-common/src/components/CountdownTimer/CountdownTimer.tsx @@ -0,0 +1,87 @@ +import { Group, Text } from "@mantine/core"; +import { Clock } from "lucide-react"; +import { useEffect, useState } from "react"; + +export interface CountdownTimerProps { + /** ISO timestamp the countdown targets. */ + deadline: string | null | undefined; + /** Optional label shown before the time (e.g. "Window closes in"). */ + label?: string; + /** Text shown once the deadline has passed. */ + expiredText?: string; + /** Visual size of the time text. */ + size?: "xs" | "sm" | "md" | "lg"; + /** Colour once under this many seconds remain (urgency). Default 300 (5 min). */ + urgentUnderSeconds?: number; +} + +function pad(n: number): string { + return String(n).padStart(2, "0"); +} + +/** Break a remaining-milliseconds figure into a human string. */ +function formatRemaining(ms: number): string { + const total = Math.floor(ms / 1000); + const days = Math.floor(total / 86400); + const hours = Math.floor((total % 86400) / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + + if (days > 0) return `${days}d ${pad(hours)}h ${pad(minutes)}m`; + if (hours > 0) return `${hours}h ${pad(minutes)}m ${pad(seconds)}s`; + return `${pad(minutes)}m ${pad(seconds)}s`; +} + +/** + * Live countdown to an ISO deadline. Ticks once a second, shows the remaining + * time (d/h/m/s), turns red when under `urgentUnderSeconds`, and shows + * `expiredText` once the deadline is in the past. Display only — enforcement + * lives server-side. + */ +export function CountdownTimer({ + deadline, + label, + expiredText = "Expired", + size = "sm", + urgentUnderSeconds = 300, +}: CountdownTimerProps) { + const [remaining, setRemaining] = useState(() => + deadline ? new Date(deadline).getTime() - Date.now() : null, + ); + + useEffect(() => { + if (!deadline) { + setRemaining(null); + return; + } + const target = new Date(deadline).getTime(); + const tick = () => setRemaining(target - Date.now()); + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [deadline]); + + if (!deadline || remaining == null || Number.isNaN(remaining)) { + return null; + } + + const expired = remaining <= 0; + const urgent = !expired && remaining <= urgentUnderSeconds * 1000; + const color = expired ? "red.7" : urgent ? "orange.7" : "dimmed"; + + return ( + + + {label && ( + + {label} + + )} + + {expired ? expiredText : formatRemaining(remaining)} + + + ); +} + +export default CountdownTimer; diff --git a/packages/ui-common/src/components/CountdownTimer/index.ts b/packages/ui-common/src/components/CountdownTimer/index.ts new file mode 100644 index 000000000..d1f61e322 --- /dev/null +++ b/packages/ui-common/src/components/CountdownTimer/index.ts @@ -0,0 +1,2 @@ +export { CountdownTimer, default } from "./CountdownTimer"; +export type { CountdownTimerProps } from "./CountdownTimer"; diff --git a/packages/ui-common/src/index.ts b/packages/ui-common/src/index.ts index 43c39d962..136e9e93b 100644 --- a/packages/ui-common/src/index.ts +++ b/packages/ui-common/src/index.ts @@ -25,6 +25,9 @@ export { useFileViewer } from "./hooks/useFileViewer"; export { OperationDatePicker } from "./components/OperationDatePicker"; export type { OperationDatePickerProps } from "./components/OperationDatePicker"; +export { CountdownTimer } from "./components/CountdownTimer"; +export type { CountdownTimerProps } from "./components/CountdownTimer"; + export { Badge } from "./components/badge"; // export type { BadgeProps } from "./components/badge"; From 8db19dfacff9dc4a62ccf25926578095ed6f6c45 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 18:45:45 +0000 Subject: [PATCH 040/122] Add snapshot of booking-window rules to train schedules and update related logic --- ...000000000-AddScheduleWindowRuleSnapshot.ts | 58 +++++++++++++++++++ .../entities/train-schedule.entity.ts | 21 +++++++ .../train-scheduling/batch-window.util.ts | 15 ++++- .../train-scheduling/booking-batch.service.ts | 35 +++++++++-- .../train-scheduling.service.ts | 21 +++++++ 5 files changed, 142 insertions(+), 8 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1920000000000-AddScheduleWindowRuleSnapshot.ts diff --git a/apps/edr-freight-api/src/migrations/1920000000000-AddScheduleWindowRuleSnapshot.ts b/apps/edr-freight-api/src/migrations/1920000000000-AddScheduleWindowRuleSnapshot.ts new file mode 100644 index 000000000..d48e127c3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1920000000000-AddScheduleWindowRuleSnapshot.ts @@ -0,0 +1,58 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Snapshot the booking-window rule onto each train schedule. + * + * A schedule's window (open time + reopen cycles) must be frozen to the rule it + * was created with: a later global-rules edit applies only to FUTURE schedules, + * while an already-open schedule keeps its base rule. Previously the batch board + * recomputed windows from the LIVE global config, so editing the rule redrew the + * board for open schedules (a synthetic grid that no longer matched the window + * the customer was shown). These columns give the board a per-schedule rule to + * derive its display windows from. + * + * Existing rows are backfilled from the current global-rules singleton — the best + * available base, since they never stored one. Their stamped windowOpensAt/ + * windowClosesAt are still real, so only projected reopen cycles rely on the + * backfill. + */ +export class AddScheduleWindowRuleSnapshot1920000000000 + implements MigrationInterface +{ + name = "AddScheduleWindowRuleSnapshot1920000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS rule_window_open_hour integer, + ADD COLUMN IF NOT EXISTS rule_window_duration_hours numeric(6, 4), + ADD COLUMN IF NOT EXISTS rule_reopen_delay_minutes integer, + ADD COLUMN IF NOT EXISTS rule_import_window_lead_days integer, + ADD COLUMN IF NOT EXISTS rule_export_booking_lead_hours integer; + `); + + // Backfill from the global-rules singleton so pre-existing schedules render. + await queryRunner.query(` + UPDATE freight.train_schedules ts + SET + rule_window_open_hour = COALESCE(ts.rule_window_open_hour, r.window_open_hour), + rule_window_duration_hours = COALESCE(ts.rule_window_duration_hours, r.window_duration_hours), + rule_reopen_delay_minutes = COALESCE(ts.rule_reopen_delay_minutes, r.reopen_delay_minutes), + rule_import_window_lead_days = COALESCE(ts.rule_import_window_lead_days, r.import_window_lead_days), + rule_export_booking_lead_hours = COALESCE(ts.rule_export_booking_lead_hours, r.export_booking_lead_hours) + FROM freight.train_scheduling_global_rules r + WHERE ts.rule_window_open_hour IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS rule_window_open_hour, + DROP COLUMN IF EXISTS rule_window_duration_hours, + DROP COLUMN IF EXISTS rule_reopen_delay_minutes, + DROP COLUMN IF EXISTS rule_import_window_lead_days, + DROP COLUMN IF EXISTS rule_export_booking_lead_hours; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index d67d58811..0cc07dacc 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -111,6 +111,27 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'booking_cycle_no', type: 'int', default: 0 }) bookingCycleNo!: number; + // ── Booking-window rule snapshot ────────────────────────────────────────── + // The scheduling rule this train was created with, frozen at creation. A later + // global-rules edit applies only to FUTURE schedules — an already-open schedule + // keeps its base rule. The batch board derives its display windows (open time + + // reopen cycles) from THIS snapshot, never from the live global config. NULL on + // legacy rows created before the snapshot existed (board falls back to live cfg). + @Column({ name: 'rule_window_open_hour', type: 'int', nullable: true }) + ruleWindowOpenHour?: number | null; + + @Column({ name: 'rule_window_duration_hours', type: 'numeric', precision: 6, scale: 4, nullable: true }) + ruleWindowDurationHours?: number | null; + + @Column({ name: 'rule_reopen_delay_minutes', type: 'int', nullable: true }) + ruleReopenDelayMinutes?: number | null; + + @Column({ name: 'rule_import_window_lead_days', type: 'int', nullable: true }) + ruleImportWindowLeadDays?: number | null; + + @Column({ name: 'rule_export_booking_lead_hours', type: 'int', nullable: true }) + ruleExportBookingLeadHours?: number | null; + @OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule) scheduleBookings?: TrainScheduleBooking[]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index c60c316ac..b30b3f454 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -307,14 +307,22 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow { * `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the * exact windows the engine runs. * EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure. + * + * `anchorOpensAt` pins the FIRST window's open time to the schedule's stored + * `windowOpensAt` instead of recomputing it from config. Pass it so the board + * shows the real frozen window (and reopen cycles projected from it) even after + * the global rule changed — the recomputed open time would otherwise drift. */ export function listConfigBookingWindows( direction: string | null | undefined, departure: Date, cfg: BoardWindowConfig, + anchorOpensAt?: Date | null, ): BoardWindow[] { if (direction === 'EXPORT') { - const start = new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000); + const start = + anchorOpensAt ?? + new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000); return [boardWindowFromInterval(start, departure)]; } @@ -323,7 +331,7 @@ export function listConfigBookingWindows( const reopenMs = cfg.reopenDelayMinutes * 60_000; const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays); - let opensAt = eatDayToUtc(windowDay, cfg.windowOpenHour); + let opensAt = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour); // Reopen stays on the same EAT booking day and before departure; cap at 12 cycles. for (let cycle = 0; cycle < 12; cycle += 1) { if (opensAt.getTime() >= departure.getTime()) break; @@ -375,8 +383,9 @@ export function groupBookingsIntoBoardWindows( departure: Date, cfg: BoardWindowConfig, pendingKey = 'pending-contract', + anchorOpensAt?: Date | null, ): Map { - const windows = listConfigBookingWindows(direction, departure, cfg); + const windows = listConfigBookingWindows(direction, departure, cfg, anchorOpensAt); const map = new Map(); for (const w of windows) { map.set(w.key, { window: w, items: [] }); 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 efa6b3259..46175c7ef 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 @@ -719,11 +719,34 @@ export class BookingBatchService implements OnModuleInit { const loco = s.trainSet?.locomotive ?? null; - // Display windows are the REAL booking-window cycles from the global-rules - // config (import: opens at windowOpenHour EAT importWindowLeadDays before - // departure, lasts windowDurationHours, reopens per reopenDelayMinutes; - // export: single FCFS lead window) — not a fixed clock grid. - const windowCfg = await this.trainSchedulingService.getWindowConfig(); + // Display windows are the REAL booking-window cycles this schedule was FROZEN + // with at creation (import: opens at its stored window time, lasts its rule's + // duration, reopens per its rule's delay; export: single FCFS lead window) — + // NOT the live global config. A later global-rules edit only re-derives + // not-yet-open schedules (restampPendingWindows), so an already-open schedule + // must keep drawing from its own snapshot, anchored on its stored open time. + // Legacy rows with no snapshot fall back to the live config. + const liveCfg = await this.trainSchedulingService.getWindowConfig(); + const num = (v: unknown, fallback: number) => { + const n = v == null ? NaN : Number(v); + return Number.isFinite(n) ? n : fallback; + }; + const windowCfg = { + windowOpenHour: num(s.ruleWindowOpenHour, liveCfg.windowOpenHour), + windowDurationHours: num( + s.ruleWindowDurationHours, + liveCfg.windowDurationHours, + ), + reopenDelayMinutes: num(s.ruleReopenDelayMinutes, liveCfg.reopenDelayMinutes), + importWindowLeadDays: num( + s.ruleImportWindowLeadDays, + liveCfg.importWindowLeadDays, + ), + exportBookingLeadHours: num( + s.ruleExportBookingLeadHours, + liveCfg.exportBookingLeadHours, + ), + }; const departureDate = s.scheduledDepartureDate ?? new Date(); const windowBuckets = groupBookingsIntoBoardWindows( items, @@ -731,6 +754,8 @@ export class BookingBatchService implements OnModuleInit { s.direction ?? null, departureDate, windowCfg, + undefined, + s.windowOpensAt ?? null, ); const emptyCounts = () => ({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 53ebc445b..fbb2ed4cd 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -321,9 +321,17 @@ export class TrainSchedulingService { s.direction === 'EXPORT' ? computeExportWindowTimes(s.scheduledDepartureDate, cfg) : computeImportWindowTimes(s.scheduledDepartureDate, cfg, now); + // A not-yet-open schedule legitimately adopts the new rule, so refresh its + // snapshot alongside the re-stamped times — the board then draws the new + // window from this same rule. await repo.update(s.id, { windowOpensAt: times.windowOpensAt, windowClosesAt: times.windowClosesAt, + ruleWindowOpenHour: cfg.windowOpenHour, + ruleWindowDurationHours: cfg.windowDurationHours, + ruleReopenDelayMinutes: cfg.reopenDelayMinutes, + ruleImportWindowLeadDays: cfg.importWindowLeadDays, + ruleExportBookingLeadHours: cfg.exportBookingLeadHours, }); restamped += 1; } @@ -489,17 +497,30 @@ export class TrainSchedulingService { `(earliest ${earliest.toISOString()})`, ); } + // Freeze the rule this schedule is born with. A later global-rules edit + // only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an + // already-open schedule keeps this snapshot, and the batch board draws its + // windows from it rather than the live config. + const ruleSnapshot = { + ruleWindowOpenHour: windowCfg.windowOpenHour, + ruleWindowDurationHours: windowCfg.windowDurationHours, + ruleReopenDelayMinutes: windowCfg.reopenDelayMinutes, + ruleImportWindowLeadDays: windowCfg.importWindowLeadDays, + ruleExportBookingLeadHours: windowCfg.exportBookingLeadHours, + }; const windowFields = direction === 'EXPORT' ? { bookingWindowStatus: 'CLOSED', windowPhase: 'PRE_WINDOW', + ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg), } : { // IMPORT and DOMESTIC share the import booking-day window cycle. bookingWindowStatus: 'CLOSED', windowPhase: 'PRE_WINDOW', + ...ruleSnapshot, ...computeImportWindowTimes(departure, windowCfg, new Date()), }; const schedule = manager.getRepository(TrainSchedule).create({ From a1dfb439ff8ed369d2a4dd2714dfc48a5fa1a4a1 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 19:17:45 +0000 Subject: [PATCH 041/122] mile --- .../modules/last-mile/dto/set-vehicles.dto.ts | 8 + .../modules/last-mile/last-mile.controller.ts | 11 + .../src/modules/last-mile/last-mile.module.ts | 3 +- .../modules/last-mile/last-mile.service.ts | 71 ++++- .../notifications/notifications.module.ts | 9 +- .../seed/marshalling-demo-trains.seeder.ts | 10 + .../src/pages/operations/FirstMilePage.tsx | 43 +-- .../src/pages/operations/LastMilePage.tsx | 259 ++++++++++++------ .../src/services/last-mile.service.ts | 6 + 9 files changed, 289 insertions(+), 131 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts new file mode 100644 index 000000000..40426b663 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts @@ -0,0 +1,8 @@ +import { IsArray, IsUUID } from 'class-validator'; + +/** Replace the full set of vehicles assigned to a last-mile delivery. */ +export class SetVehiclesDto { + @IsArray() + @IsUUID('4', { each: true }) + vehicleIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 9ad5a00bf..047d6b3e9 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -18,6 +18,7 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto'; +import { SetVehiclesDto } from './dto/set-vehicles.dto'; import { LastMileStatus } from './entities/last-mile.entity'; import { LastMileService } from './last-mile.service'; import { LastMileInvoiceService } from './last-mile-invoice.service'; @@ -136,4 +137,14 @@ export class LastMileController { ) { return this.lastMileService.allocateContainers(id, dto.allocations); } + + @Post(':id/vehicles') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Set the vehicles assigned to a last-mile delivery (multi-truck)' }) + async setVehicles( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetVehiclesDto, + ) { + return this.lastMileService.setVehicles(id, dto.vehicleIds); + } } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts index 32b688069..e639e4dfd 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts @@ -8,6 +8,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; import { VehiclesModule } from '../vehicles/vehicles.module'; import { LastMile } from './entities/last-mile.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; +import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity'; import { LastMileController } from './last-mile.controller'; import { LastMileInvoiceService } from './last-mile-invoice.service'; import { LastMileRepository } from './last-mile.repository'; @@ -15,7 +16,7 @@ import { LastMileService } from './last-mile.service'; @Module({ imports: [ - TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation]), + TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation, LastMileVehicleAssignment]), BillingModule, forwardRef(() => BookingsModule), VehiclesModule, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 884a02aae..744aa7df1 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -10,6 +10,7 @@ import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; +import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity'; import { LastMileRepository } from './last-mile.repository'; import { InvoiceEventPayload } from '../billing/billing.service'; import { OnEvent } from '@nestjs/event-emitter'; @@ -149,8 +150,9 @@ export class LastMileService { const [data, total] = await this.lastMileRepository.findAndCount({ where, relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: true }, vehicle: true, + vehicleAssignments: { vehicle: true }, }, order: { [sortBy]: sortOrder }, skip: (page - 1) * pageSize, @@ -171,8 +173,9 @@ export class LastMileService { async findById(id: string): Promise { const record = await this.lastMileRepository.findById(id, { relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: true }, vehicle: true, + vehicleAssignments: { vehicle: true }, }, }); @@ -353,6 +356,70 @@ export class LastMileService { await this.vehiclesService.releaseIfUnused(vehicleIds); } + /** + * Replace the full set of vehicles serving a last-mile delivery (multi-truck). + * Diffs against the current junction rows, syncing availability + audit history + * for each added/removed vehicle. The first vehicle is mirrored onto the legacy + * `vehicleId` column for back-compat with single-vehicle readers. + */ + async setVehicles(id: string, vehicleIds: string[]): Promise { + const existing = await this.findById(id); + const desired = [...new Set(vehicleIds.filter(Boolean))]; + + const manager = this.dataSource.manager; + const current = await manager.find(LastMileVehicleAssignment, { + where: { lastMileId: id }, + }); + const currentIds = current.map((a) => a.vehicleId); + const currentSet = new Set(currentIds); + const desiredSet = new Set(desired); + const added = desired.filter((v) => !currentSet.has(v)); + const removed = currentIds.filter((v) => !desiredSet.has(v)); + + await this.dataSource.transaction(async (tx) => { + if (removed.length) { + await tx.delete(LastMileVehicleAssignment, { + lastMileId: id, + vehicleId: In(removed), + }); + } + for (const vehicleId of added) { + await tx.insert(LastMileVehicleAssignment, { lastMileId: id, vehicleId }); + } + }); + + // Legacy primary vehicle = first of the set (null when cleared). + await this.lastMileRepository.update(id, { vehicleId: desired[0] ?? null } as any); + + const bookingRef = await this.resolveBookingRef(existing); + for (const vehicleId of added) { + await this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY); + void this.notifyDriverAssignment(vehicleId, existing); + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId, + lastMileId: id, + driverId: info.driverId, + label: existing.status, + metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + for (const vehicleId of removed) { + await this.vehiclesService.releaseIfUnused([vehicleId]); + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + lastMileId: id, + driverId: info.driverId, + metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + + return this.findById(id); + } + private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); diff --git a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts index 4e56c8b70..16d598eb0 100644 --- a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts @@ -8,6 +8,11 @@ import { EmailClientService } from "./email-client.service"; import { EmailNotificationStrategy } from "./strategies/notification.email.strategy"; import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"; +// Fall back to a sane broker URL so an unset RABBITMQ_URL can't produce +// `urls: [undefined]` (which crashes amqp-connection-manager on 'heartbeat'). +const RABBITMQ_URL = + process.env.RABBITMQ_URL ?? process.env.PAYMENT_RABBITMQ_URL ?? "amqp://localhost:5672"; + @Module({ imports: [ ConfigModule, @@ -16,7 +21,7 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy" name: "SMS_SERVICE", transport: Transport.RMQ, options: { - urls: [process.env.RABBITMQ_URL as string], + urls: [RABBITMQ_URL], queue: process.env.SMS_QUEUE ?? "sms_queue", queueOptions: { durable: true }, }, @@ -25,7 +30,7 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy" name: "EMAIL_SERVICE", transport: Transport.RMQ, options: { - urls: [process.env.RABBITMQ_URL as string], + urls: [RABBITMQ_URL], queue: process.env.EMAIL_QUEUE ?? "email_queue", queueOptions: { durable: true }, }, diff --git a/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts b/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts index 2d3c47c26..53d6c9eec 100644 --- a/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts +++ b/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts @@ -3,6 +3,7 @@ import { WagonStatus } from '@edr/types'; import { DataSource } from 'typeorm'; import { Booking } from '../modules/bookings/entities/booking.entity'; +import { Company } from '../modules/companies/entities/company.entity'; import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; @@ -116,6 +117,11 @@ export class MarshallingDemoTrainsSeeder { ? await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } }) : null; + // bookings.company_id is NOT NULL — reuse any seeded company for the demo. + const company = await this.dataSource + .getRepository(Company) + .findOne({ where: {}, order: { createdAt: 'ASC' } }); + const missing = [ !djiboutiYard ? 'Djibouti yard' : '', !ethiopiaYard ? 'Ethiopia yard' : '', @@ -124,6 +130,7 @@ export class MarshallingDemoTrainsSeeder { !warehouse ? 'INDODE_OPEN warehouse' : '', !warehouseYard ? 'warehouse yard' : '', !warehouseZone ? 'warehouse zone' : '', + !company ? 'company' : '', ].filter(Boolean); if (missing.length) { this.logger.warn(`Cannot seed marshalling demo trains, missing: ${missing.join(', ')}`); @@ -141,6 +148,7 @@ export class MarshallingDemoTrainsSeeder { warehouse: warehouse!, warehouseYard: warehouseYard!, warehouseZone: warehouseZone!, + company: company!, }); if (created) seeded += 1; } @@ -164,6 +172,7 @@ export class MarshallingDemoTrainsSeeder { warehouse: Warehouse; warehouseYard: WarehouseYard; warehouseZone: WarehouseZone; + company: Company; }, ): Promise { const bookingRepo = this.dataSource.getRepository(Booking); @@ -231,6 +240,7 @@ export class MarshallingDemoTrainsSeeder { const booking = await bookingRepo.save( bookingRepo.create({ reference: bookingReference, + companyId: refs.company.id, originYardId: originYard.id, destinationYardId: destinationYard.id, serviceTypeId: refs.serviceType.id, diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 2ec99f496..5f1790f42 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -755,30 +755,6 @@ const FirstMilePage = () => { meta: { headerClassName, cellClassName }, cell: ({ row }) => customerName(row.original), }, - { - id: "pickup", - header: "Pickup", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => pickupLocation(row.original), - }, - { - id: "destination", - header: "Destination", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => destinationYardName(row.original), - }, - { - id: "cargo", - header: "Cargo", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => cargoDesc(row.original), - }, - { - id: "advancedPayment", - header: "Advanced Payment", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => formatPrice(row.original.advancedPayment), - }, { id: "postPayment", header: "Post Payment", @@ -789,13 +765,8 @@ const FirstMilePage = () => { id: "vehicle", header: "Vehicle", meta: { headerClassName, cellClassName }, - cell: ({ row }) => vehicleLabel(row.original) ?? , - }, - { - id: "estimatedKm", - header: "Est. Distance (KM)", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => row.original.estimatedKm != null ? row.original.estimatedKm : , + cell: ({ row }) => + vehicleLabel(row.original) ?? Unassigned, }, { id: "exactKm", @@ -849,16 +820,6 @@ const FirstMilePage = () => { return {meta.label}; }, }, - { - id: "assignment", - header: "Assignment", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => ( - - {isAssigned(row.original) ? "Assigned" : "Unassigned"} - - ), - }, { id: "actions", header: "Actions", diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 2fb05b0de..4fce74364 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -3,18 +3,21 @@ import { ArrowRight, Eye, MoreHorizontal, + Plus, Printer, Receipt, RefreshCw, Ruler, Trash, Truck, + X, } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { ColumnDef } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { ActionIcon, + Alert, Badge, Box, Button, @@ -24,6 +27,7 @@ import { Group, Menu, Modal, + MultiSelect, NumberInput, ScrollArea, Select, @@ -92,6 +96,32 @@ const vehicleLabel = (record: LastMileRecord) => { return parts.join(" · "); }; +/** One vehicle (with trailer) carries two containers. */ +const CONTAINERS_PER_VEHICLE = 2; +const containerCount = (record: LastMileRecord) => + (record.booking?.bookingContainers ?? []).reduce( + (sum, c) => sum + (Number(c.quantity) || 0), + 0, + ); +/** Trucks needed for a booking = ceil(containers / 2). 0 when no container data. */ +const requiredVehicles = (record: LastMileRecord) => { + const n = containerCount(record); + return n > 0 ? Math.ceil(n / CONTAINERS_PER_VEHICLE) : 0; +}; + +/** Column summary: the primary vehicle, plus a "+N more" when multi-truck. */ +const vehiclesSummary = (record: LastMileRecord) => { + const assigns = record.vehicleAssignments ?? []; + if (assigns.length > 1) { + const first = assigns[0]?.vehicle; + const firstLabel = first + ? [first.code, first.plateNumber].filter(Boolean).join(" · ") + : "Vehicle"; + return `${firstLabel} +${assigns.length - 1} more`; + } + return vehicleLabel(record); +}; + const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId); const fmtStamp = (iso?: string | null) => { @@ -422,13 +452,14 @@ const LastMilePage = () => { const [tripSlipOpen, setTripSlipOpen] = useState(false); const [tripSlipRecord, setTripSlipRecord] = useState(null); const [activeId, setActiveId] = useState(null); - const [vehicleValue, setVehicleValue] = useState(null); + // Multi-vehicle assign: one entry per selected vehicle (null = empty picker). + const [vehicleValues, setVehicleValues] = useState<(string | null)[]>([null]); // 2-step "Assign Mile" accept modal (arrival queue → vehicle) const [acceptOpen, setAcceptOpen] = useState(false); const [acceptStep, setAcceptStep] = useState<1 | 2>(1); const [selectedArrivalItems, setSelectedArrivalItems] = useState([]); - const [acceptVehicleValue, setAcceptVehicleValue] = useState(null); + const [acceptVehicleValues, setAcceptVehicleValues] = useState([]); const [arrivalSearch, setArrivalSearch] = useState(""); const [distanceOpen, setDistanceOpen] = useState(false); @@ -516,6 +547,18 @@ const LastMilePage = () => { }, }); + const setVehiclesMutation = useMutation({ + mutationFn: ({ id, vehicleIds }: { id: string; vehicleIds: string[] }) => + lastMileService.setVehicles(id, vehicleIds), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); + void qc.invalidateQueries({ queryKey: ["vehicles"] }); + }, + onError: () => { + toast({ title: "Assign failed", variant: "destructive" }); + }, + }); + const updateDistanceMutation = useMutation({ mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) => lastMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }), @@ -576,12 +619,12 @@ const LastMilePage = () => { }, [arrivalQueue, arrivalSearch, existingLastMileBookingIds]); const acceptMutation = useMutation({ - mutationFn: async ({ items, vehicleId }: { items: ArrivalQueueItem[]; vehicleId: string | null }) => { + mutationFn: async ({ items, vehicleIds }: { items: ArrivalQueueItem[]; vehicleIds: string[] }) => { const created = await Promise.all( items.map((item) => lastMileService.accept(item.bookingReference).then((r) => r.data)), ); - if (vehicleId) { - await Promise.all(created.map((record) => lastMileService.update(record.id, { vehicleId }))); + if (vehicleIds.length) { + await Promise.all(created.map((record) => lastMileService.setVehicles(record.id, vehicleIds))); } return created; }, @@ -603,7 +646,7 @@ const LastMilePage = () => { setAcceptOpen(true); setAcceptStep(1); setSelectedArrivalItems([]); - setAcceptVehicleValue(null); + setAcceptVehicleValues([]); setArrivalSearch(""); }; @@ -611,7 +654,7 @@ const LastMilePage = () => { setAcceptOpen(false); setAcceptStep(1); setSelectedArrivalItems([]); - setAcceptVehicleValue(null); + setAcceptVehicleValues([]); setArrivalSearch(""); }; @@ -625,7 +668,7 @@ const LastMilePage = () => { const handleAcceptConfirm = () => { if (!selectedArrivalItems.length) return; - acceptMutation.mutate({ items: selectedArrivalItems, vehicleId: acceptVehicleValue }); + acceptMutation.mutate({ items: selectedArrivalItems, vehicleIds: acceptVehicleValues }); }; const openDistance = (id: string) => { @@ -688,6 +731,27 @@ const LastMilePage = () => { [records, activeId], ); + // Vehicle picker options for the assign modal = free vehicles PLUS the ones + // already on this record (which are BUSY, so absent from the free list) so a + // reassign shows its current trucks selected instead of blank. + const assignVehicleOptions = useMemo(() => { + const opts = [...vehicleOptions]; + const seen = new Set(opts.map((o) => o.value)); + const current = [ + ...(activeRecord?.vehicleAssignments?.map((a) => a.vehicle) ?? []), + activeRecord?.vehicle, + ]; + for (const v of current) { + if (v && !seen.has(v.id)) { + seen.add(v.id); + const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber]; + if (v.code) parts.unshift(v.code); + opts.push({ value: v.id, label: parts.join(" · ") }); + } + } + return opts; + }, [vehicleOptions, activeRecord]); + const pickupReadyByBooking = useMemo(() => { const map = new Map(); for (const row of pickupReadyRows) { @@ -751,16 +815,22 @@ const LastMilePage = () => { const openAssign = (id: string | null) => { const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null; + const rec = records.find((r) => r.id === resolved); + const existing = rec?.vehicleAssignments?.length + ? rec.vehicleAssignments.map((a) => a.vehicleId) + : rec?.vehicleId + ? [rec.vehicleId] + : []; setBulkMode(false); setActiveId(resolved); - setVehicleValue(null); + setVehicleValues(existing.length ? existing : [null]); setAssignOpen(true); }; const openBulkAssign = () => { setBulkMode(true); setActiveId(null); - setVehicleValue(null); + setVehicleValues([null]); setAssignOpen(true); }; @@ -768,28 +838,26 @@ const LastMilePage = () => { setAssignOpen(false); setBulkMode(false); setActiveId(null); - setVehicleValue(null); + setVehicleValues([null]); }; const handleAssign = () => { - if (!vehicleValue) { - toast({ title: "Select a vehicle", description: "Choose a vehicle to assign.", variant: "destructive" }); - return; - } - + const ids = [...new Set(vehicleValues.filter((v): v is string => Boolean(v)))]; const targetIds = bulkMode ? selectedIds : [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.id].filter((id): id is string => Boolean(id)); if (!targetIds.length) return; + if (!ids.length) { + toast({ title: "Select a vehicle", description: "Choose at least one vehicle to assign.", variant: "destructive" }); + return; + } - const selectedLabel = vehicleOptions.find((o) => o.value === vehicleValue)?.label ?? vehicleValue; - - Promise.all(targetIds.map((id) => updateMutation.mutateAsync({ id, data: { vehicleId: vehicleValue } }))) + Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicleIds: ids }))) .then(() => { toast({ - title: "Vehicle assigned", - description: bulkMode ? `${targetIds.length} deliveries → ${selectedLabel}` : selectedLabel, + title: ids.length > 1 ? "Vehicles assigned" : "Vehicle assigned", + description: `${bulkMode ? `${targetIds.length} deliveries · ` : ""}${ids.length} vehicle${ids.length > 1 ? "s" : ""}`, }); if (bulkMode) setRowSelection({}); closeAssign(); @@ -893,30 +961,6 @@ const LastMilePage = () => { meta: { headerClassName, cellClassName }, cell: ({ row }) => customerName(row.original), }, - { - id: "pickup", - header: "Pickup", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => originYardName(row.original), - }, - { - id: "destination", - header: "Destination", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => deliveryLocation(row.original), - }, - { - id: "cargo", - header: "Cargo", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => cargoDesc(row.original), - }, - { - id: "advancedPayment", - header: "Advanced Payment", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => formatPrice(row.original.advancedPayment), - }, { id: "postPayment", header: "Post Payment", @@ -927,13 +971,8 @@ const LastMilePage = () => { id: "vehicle", header: "Vehicle", meta: { headerClassName, cellClassName }, - cell: ({ row }) => vehicleLabel(row.original) ?? , - }, - { - id: "estimatedKm", - header: "Est. Distance (KM)", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => row.original.estimatedKm != null ? row.original.estimatedKm : , + cell: ({ row }) => + vehiclesSummary(row.original) ?? Unassigned, }, { id: "exactKm", @@ -987,16 +1026,6 @@ const LastMilePage = () => { return {meta.label}; }, }, - { - id: "assignment", - header: "Assignment", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => ( - - {isAssigned(row.original) ? "Assigned" : "Unassigned"} - - ), - }, { id: "actions", header: "Actions", @@ -1315,19 +1344,26 @@ const LastMilePage = () => { - + + {vehicleValues.map((val, i) => ( + + setAllocations((prev) => ({ @@ -148,7 +169,8 @@ export function LastMileContainerAllocationTable({ - {allocatedCount} of {containers.length} containers allocated + {allocatedCount} of {containers.length} containers allocated · max{" "} + {CONTAINERS_PER_VEHICLE} per vehicle - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 19a8fa290..062577977 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -1,7 +1,6 @@ import { type ReactNode, useMemo, useState } from "react"; import { ArrowRight, - Boxes, Eye, MoreHorizontal, Plus, @@ -55,10 +54,8 @@ import { import { vehiclesService } from "@/services/vehicles.service"; import { driversService, type Driver } from "@/services/drivers.service"; import { ratesService } from "@/services/rates.service"; -import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable"; import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal"; import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; -import { api } from "@/auth/http"; const formatPrice = (amount: number) => `ETB ${amount.toLocaleString("en-US", { @@ -118,19 +115,6 @@ const bookingContainerNumbers = (record: LastMileRecord): string[] => .map((c) => c.containerNumber) .filter((n): n is string => Boolean(n)); -/** Container rows for the per-container→vehicle allocation table. */ -const allocationRowsFor = (record: LastMileRecord): LastMileContainerRow[] => - (record.booking?.bookingContainers ?? []).map((c) => ({ - id: c.id, - type: - c.containerNumber ?? - c.containerType?.code ?? - c.containerType?.label ?? - c.containerType?.name ?? - (c.containerSize || "Container"), - qty: c.quantity || 1, - })); - /** Container badges for a booking: the container number when known, else the * type × quantity. */ const containerLabels = (record: LastMileRecord): string[] => { @@ -500,8 +484,6 @@ const LastMilePage = () => { // Record pending invoice-generation confirmation (shows a summary first). const [invoiceConfirm, setInvoiceConfirm] = useState(null); - const [allocationOpen, setAllocationOpen] = useState(false); - const [allocationContainers, setAllocationContainers] = useState([]); const [releaseItem, setReleaseItem] = useState(null); const [releaseTruckPrefill, setReleaseTruckPrefill] = useState(null); @@ -639,19 +621,6 @@ const LastMilePage = () => { }, }); - const allocateMutation = useMutation({ - mutationFn: (data: Array<{ containerId: string; vehicleId: string }>) => - api.post(`/last-mile/${activeId}/allocate-containers`, { allocations: data }), - onSuccess: () => { - toast({ title: "Containers allocated", variant: "default" }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); - void qc.invalidateQueries({ queryKey: ["vehicles"] }); - closeAllocation(); - }, - onError: () => { - toast({ title: "Allocation failed", variant: "destructive" }); - }, - }); const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({ queryKey: ["warehouse-inventory", "arrival-queue"], @@ -744,17 +713,6 @@ const LastMilePage = () => { }; - const openAllocation = (id: string, containers?: LastMileContainerRow[]) => { - setActiveId(id); - setAllocationContainers(containers ?? []); - setAllocationOpen(true); - }; - - const closeAllocation = () => { - setAllocationOpen(false); - setActiveId(null); - setAllocationContainers([]); - }; const handleSaveDistance = () => { const distances = Object.entries(distanceRows) @@ -984,6 +942,15 @@ const LastMilePage = () => { setReleaseItem(toReleaseInventoryItem(row)); }; + // Truck leaving the warehouse = the leg is now in transit. Advance the status + // (same as "Mark In Transit") alongside the warehouse exit-weighing flow. + const handleTruckLeaving = (record: LastMileRecord) => { + openTruckArrival(record); + if (record.status === "READY_TO_TRANSIT") { + updateMutation.mutate({ id: record.id, data: { status: "IN_TRANSIT" } }); + } + }; + const closeTruckArrival = () => { setReleaseItem(null); setReleaseTruckPrefill(null); @@ -1089,7 +1056,11 @@ const LastMilePage = () => { return ( navigate(`/dashboard/invoices/${invoice.id}`)} + onClick={() => + invoice.id + ? navigate(`/dashboard/invoices/${invoice.id}`) + : toast({ title: "Invoice link unavailable", description: "Refresh after the API restart.", variant: "destructive" }) + } c="blue" fw={500} style={{ textDecoration: "underline", cursor: "pointer" }} @@ -1135,11 +1106,12 @@ const LastMilePage = () => { (status === "IN_TRANSIT" && hasDistance); const canAssignStep = !assigned && status !== "DELIVERED"; const canDistance = status === "IN_TRANSIT"; - // Truck arrival/leaving are independent — each driven only by its own - // warehouse state: arrive once assigned & not arrived, leave once - // arrived & not departed. - const canArrive = assigned && !releaseRow?.releaseOrderReference; - const canLeave = Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate; + // Truck arrival/leaving are independent — each driven by its own + // warehouse state — but both are done once the leg is IN_TRANSIT/DELIVERED. + const pastTransit = status === "IN_TRANSIT" || status === "DELIVERED"; + const canArrive = assigned && !releaseRow?.releaseOrderReference && !pastTransit; + const canLeave = + Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate && !pastTransit; return ( @@ -1189,13 +1161,6 @@ const LastMilePage = () => { > Unassign - } - disabled={allocationRowsFor(row.original).length === 0 || delivered} - onClick={() => openAllocation(row.original.id, allocationRowsFor(row.original))} - > - Allocate to trucks - } disabled={!canArrive} @@ -1206,7 +1171,7 @@ const LastMilePage = () => { } disabled={!canLeave} - onClick={() => openTruckArrival(row.original)} + onClick={() => handleTruckLeaving(row.original)} > Truck Leaving @@ -1219,17 +1184,20 @@ const LastMilePage = () => { } - disabled={!canDistance} + disabled={!canDistance || Boolean(row.original.invoice)} onClick={() => openDistance(row.original.id)} > Add distance } - disabled={!(row.original.exactKm != null && row.original.exactKm > 0)} + disabled={ + !(row.original.exactKm != null && row.original.exactKm > 0) || + Boolean(row.original.invoice) + } onClick={() => setInvoiceConfirm(row.original)} > - Generate Invoice + {row.original.invoice ? "Invoice generated" : "Generate Invoice"} {canPrint && ( { } color="red" - disabled={delivered} + disabled={delivered || Boolean(row.original.invoice)} onClick={() => { if (confirm(`Delete last-mile record ${bookingRef(row.original)}?`)) { deleteMutation.mutate(row.original.id); @@ -1829,75 +1797,6 @@ const LastMilePage = () => { )} - {/* Container Allocation modal */} - Allocate Containers to Vehicles} - size="xl" - radius="lg" - centered - > - - {activeRecord && ( - <> - - - - - {bookingRef(activeRecord)} - {customerName(activeRecord)} - - - Cargo Type - {activeRecord.booking?.cargoType?.label ?? activeRecord.booking?.cargoType?.name ?? "—"} - - - - - - {/* Capacity logic based on cargo type */} - {activeRecord.booking?.cargoType?.name === "BULK" ? ( - - - - Smart Capacity Allocation - - - Capacity: TBD - - TODO: add vehicle capacity_tons to vehicle API if missing - - - TODO: add container weight to booking if missing - - - - Select multiple containers per vehicle based on capacity - - - - ) : ( - - Up to 2 containers per vehicle (trailer) - - )} - - )} - - { - await allocateMutation.mutateAsync(mappings); - }} - /> - - - - - - - Date: Fri, 3 Jul 2026 21:25:49 +0000 Subject: [PATCH 056/122] mile --- .../last-mile/entities/last-mile-container-allocation.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts index 8a61c73bf..187d9aea1 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts @@ -24,7 +24,7 @@ export class LastMileContainerAllocation extends BaseEntity { @Column('uuid', { name: 'vehicle_id', nullable: true }) vehicleId?: string | null; - @Column('text') + @Column('text', { name: 'container_type' }) containerType!: string; @Column('integer', { default: 1 }) From 9422e5588e8259023b0809f7bdd27417a2c07954 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 21:45:54 +0000 Subject: [PATCH 057/122] mile --- .../modules/last-mile/last-mile.service.ts | 32 ++-- .../src/pages/operations/LastMilePage.tsx | 175 ++++++++++++++---- 2 files changed, 158 insertions(+), 49 deletions(-) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 0a6aaa56e..a42ce289a 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -365,20 +365,28 @@ export class LastMileService { } /** - * Free every vehicle held by this record (direct assignment + container - * allocations), unless still in use by another active trip. + * Free every vehicle held by this record — junction assignments, the legacy + * direct vehicle, and container allocations — unless still used by another + * active trip. */ private async releaseVehicles(record: LastMile): Promise { - const recordAllocations = await this.dataSource.manager.find( - LastMileContainerAllocation, - { where: { lastMileId: record.id } }, - ); - const vehicleIds = recordAllocations - .map((a) => a.vehicleId) - .filter((id): id is string => Boolean(id)); - if (record.vehicleId) { - vehicleIds.push(record.vehicleId); - } + const [assignments, recordAllocations] = await Promise.all([ + this.dataSource.manager.find(LastMileVehicleAssignment, { + where: { lastMileId: record.id }, + }), + this.dataSource.manager.find(LastMileContainerAllocation, { + where: { lastMileId: record.id }, + }), + ]); + const vehicleIds = [ + ...new Set( + [ + ...assignments.map((a) => a.vehicleId), + ...recordAllocations.map((a) => a.vehicleId), + record.vehicleId ?? null, + ].filter((id): id is string => Boolean(id)), + ), + ]; await this.vehiclesService.releaseIfUnused(vehicleIds); } diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 062577977..c3c59ca58 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -36,6 +36,7 @@ import { Stack, Text, TextInput, + Tooltip, UnstyledButton, } from "@mantine/core"; import type { ArrivalQueueItem, ImportUnloadedItem, WarehouseInventoryItem } from "@/types/warehouse"; @@ -306,21 +307,43 @@ const BookingInfo = ({ record }: { record: LastMileRecord }) => { ); }; -const tripSlipRows = (record: LastMileRecord): [string, string][] => [ - ["Customer", customerName(record)], - ["Service", serviceTypeName(record)], - ["Pickup (origin yard)", originYardName(record)], - ["Destination", deliveryLocation(record)], - ["Cargo", cargoDesc(record)], - ["Advanced Payment", formatPrice(record.advancedPayment)], - ["Post Payment", formatPrice(record.remainingPayment)], - ["Vehicle", vehicleLabel(record) ?? "Unassigned"], - ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], - ["Requested date", requestedDate(record)], - ["Est. Distance (KM)", record.estimatedKm != null ? String(record.estimatedKm) : "—"], - ["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"], - ["Status", STATUS_META[record.status].label], -]; +type TripSlipVehicle = NonNullable[number]; + +const tripSlipRows = ( + record: LastMileRecord, + vehicle?: TripSlipVehicle | null, +): [string, string][] => { + // Per-vehicle block when a specific truck is chosen (its own driver, container(s) + // and distance); else fall back to the record-level vehicle summary. + const vehicleRows: [string, string][] = vehicle + ? [ + [ + "Vehicle", + vehicle.vehicle + ? [vehicle.vehicle.code, vehicle.vehicle.plateNumber].filter(Boolean).join(" · ") + : vehicle.vehicleId, + ], + ["Driver", vehicle.vehicle?.assignedDriverName || "—"], + ["Container(s)", vehicle.containerNumber || "—"], + ["Distance (KM)", vehicle.distanceKm != null ? String(vehicle.distanceKm) : "—"], + ] + : [ + ["Vehicle", vehicleLabel(record) ?? "Unassigned"], + ["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"], + ]; + return [ + ["Customer", customerName(record)], + ["Service", serviceTypeName(record)], + ["Pickup (origin yard)", originYardName(record)], + ["Destination", deliveryLocation(record)], + ["Cargo", cargoDesc(record)], + ["Post Payment", formatPrice(record.remainingPayment)], + ...vehicleRows, + ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], + ["Requested date", requestedDate(record)], + ["Status", STATUS_META[record.status].label], + ]; +}; const SampleStamp = () => ( @@ -378,7 +401,13 @@ const SignatureBlock = ({ title, stamp }: { title: string; stamp?: ReactNode }) ); -const TripSlipDocument = ({ record }: { record: LastMileRecord }) => ( +const TripSlipDocument = ({ + record, + vehicle, +}: { + record: LastMileRecord; + vehicle?: TripSlipVehicle | null; +}) => ( EDR Freight @@ -390,7 +419,7 @@ const TripSlipDocument = ({ record }: { record: LastMileRecord }) => ( - {tripSlipRows(record).map(([label, value]) => ( + {tripSlipRows(record, vehicle).map(([label, value]) => ( ))} @@ -405,8 +434,8 @@ const TripSlipDocument = ({ record }: { record: LastMileRecord }) => ( const escapeHtml = (v: string) => v.replace(/&/g, "&").replace(//g, ">"); -const buildTripSlipHtml = (record: LastMileRecord) => { - const rows = tripSlipRows(record) +const buildTripSlipHtml = (record: LastMileRecord, vehicle?: TripSlipVehicle | null) => { + const rows = tripSlipRows(record, vehicle) .map(([l, v]) => `${escapeHtml(l)}${escapeHtml(v)}`) .join(""); const sig = (title: string, withStamp: boolean) => ` @@ -465,6 +494,9 @@ const LastMilePage = () => { const [detailOpen, setDetailOpen] = useState(false); const [tripSlipOpen, setTripSlipOpen] = useState(false); const [tripSlipRecord, setTripSlipRecord] = useState(null); + // Which vehicle the trip slip is for (per-truck), + the pre-print picker. + const [tripSlipVehicleId, setTripSlipVehicleId] = useState(null); + const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false); const [activeId, setActiveId] = useState(null); // Multi-vehicle assign: one row per truck — vehicle + the container it carries. const [vehicleRows, setVehicleRows] = useState< @@ -915,6 +947,20 @@ const LastMilePage = () => { const handlePrintTripSlip = (record: LastMileRecord) => { setTripSlipRecord(record); + const assigns = record.vehicleAssignments ?? []; + if (assigns.length > 1) { + // Multiple trucks → let the operator pick which one to print. + setTripSlipVehicleId(null); + setTripSlipSelectOpen(true); + } else { + setTripSlipVehicleId(assigns[0]?.vehicleId ?? null); + setTripSlipOpen(true); + } + }; + + const chooseTripSlipVehicle = (vehicleId: string) => { + setTripSlipVehicleId(vehicleId); + setTripSlipSelectOpen(false); setTripSlipOpen(true); }; @@ -958,6 +1004,9 @@ const LastMilePage = () => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); }; + const tripSlipVehicle = + tripSlipRecord?.vehicleAssignments?.find((a) => a.vehicleId === tripSlipVehicleId) ?? null; + const printTripSlip = () => { if (!tripSlipRecord) return; const win = window.open("", "_blank", "width=820,height=920"); @@ -965,7 +1014,7 @@ const LastMilePage = () => { toast({ title: "Pop-up blocked", description: "Allow pop-ups to print the trip slip.", variant: "destructive" }); return; } - win.document.write(buildTripSlipHtml(tripSlipRecord)); + win.document.write(buildTripSlipHtml(tripSlipRecord, tripSlipVehicle)); win.document.close(); }; @@ -1019,17 +1068,32 @@ const LastMilePage = () => { cell: ({ row }) => { const assigns = row.original.vehicleAssignments ?? []; if (assigns.length > 1) { - const first = assigns[0]?.vehicle; - const firstLabel = first - ? [first.code, first.plateNumber].filter(Boolean).join(" · ") - : "Vehicle"; + const labelFor = (a: (typeof assigns)[number]) => { + const v = a.vehicle; + const l = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; + return a.containerNumber ? `${l} · ${a.containerNumber}` : l; + }; return ( - - {firstLabel} - - +{assigns.length - 1} - - + + {assigns.map(labelFor).join("\n")} +
+ } + > + + + {assigns[0].vehicle + ? [assigns[0].vehicle.code, assigns[0].vehicle.plateNumber].filter(Boolean).join(" · ") + : assigns[0].vehicleId} + + + +{assigns.length - 1} + + + ); } return vehicleLabel(row.original) ?? Unassigned; @@ -1114,7 +1178,12 @@ const LastMilePage = () => { Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate && !pastTransit; return ( - + @@ -1661,7 +1730,7 @@ const LastMilePage = () => { centered > - {tripSlipRecord && } + {tripSlipRecord && } @@ -1670,6 +1739,42 @@ const LastMilePage = () => { + {/* Trip slip — pick a vehicle (multi-truck) */} + setTripSlipSelectOpen(false)} + title={Print trip slip — select vehicle} + radius="lg" + centered + > + + + {tripSlipRecord ? bookingRef(tripSlipRecord) : ""} has multiple trucks — choose one. + + {(tripSlipRecord?.vehicleAssignments ?? []).map((a) => { + const v = a.vehicle; + const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; + return ( + + ); + })} + + + {/* Add Actual Distance modal */} { loading={generateInvoiceMutation.isPending} onClick={() => generateInvoiceMutation.mutate(invoiceConfirm.id, { - onSuccess: (res) => { - setInvoiceConfirm(null); - const invoiceId = res?.data?.id; - if (invoiceId) navigate(`/dashboard/invoices/${invoiceId}`); - }, + onSuccess: () => setInvoiceConfirm(null), }) } > From cad4b84b8c8c3f4905103c0e16ff26031756899f Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 21:57:53 +0000 Subject: [PATCH 058/122] mile --- .../src/pages/operations/LastMilePage.tsx | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index c3c59ca58..f3f290304 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -324,7 +324,10 @@ const tripSlipRows = ( : vehicle.vehicleId, ], ["Driver", vehicle.vehicle?.assignedDriverName || "—"], - ["Container(s)", vehicle.containerNumber || "—"], + [ + "Container(s)", + vehicle.containerNumber || bookingContainerNumbers(record).join(", ") || "—", + ], ["Distance (KM)", vehicle.distanceKm != null ? String(vehicle.distanceKm) : "—"], ] : [ @@ -469,7 +472,7 @@ const buildTripSlipHtml = (record: LastMileRecord, vehicle?: TripSlipVehicle | n .ring-inner span { font-size: 9px; font-weight: 700; letter-spacing: 1px; } .ring-inner strong { font-size: 13px; font-weight: 800; } - +

EDR Freight

Last Mile Trip Slip

${escapeHtml(bookingRef(record))}${escapeHtml(requestedDate(record))}
${rows}
@@ -946,16 +949,16 @@ const LastMilePage = () => { }; const handlePrintTripSlip = (record: LastMileRecord) => { + // Always open the picker so the operator chooses which truck to print. setTripSlipRecord(record); - const assigns = record.vehicleAssignments ?? []; - if (assigns.length > 1) { - // Multiple trucks → let the operator pick which one to print. - setTripSlipVehicleId(null); - setTripSlipSelectOpen(true); - } else { - setTripSlipVehicleId(assigns[0]?.vehicleId ?? null); - setTripSlipOpen(true); - } + setTripSlipVehicleId(null); + setTripSlipSelectOpen(true); + }; + + const printBookingSlip = () => { + setTripSlipVehicleId(null); + setTripSlipSelectOpen(false); + setTripSlipOpen(true); }; const chooseTripSlipVehicle = (vehicleId: string) => { @@ -1016,6 +1019,15 @@ const LastMilePage = () => { } win.document.write(buildTripSlipHtml(tripSlipRecord, tripSlipVehicle)); win.document.close(); + win.focus(); + // Explicit print after the doc paints (onload can miss with document.write). + setTimeout(() => { + try { + win.print(); + } catch { + /* window may have been closed */ + } + }, 250); }; const columns = useMemo((): ColumnDef[] => { @@ -1749,7 +1761,8 @@ const LastMilePage = () => { > - {tripSlipRecord ? bookingRef(tripSlipRecord) : ""} has multiple trucks — choose one. + {tripSlipRecord ? bookingRef(tripSlipRecord) : ""} — choose a truck to print its slip + (vehicle, driver, container). {(tripSlipRecord?.vehicleAssignments ?? []).map((a) => { const v = a.vehicle; @@ -1772,6 +1785,12 @@ const LastMilePage = () => { ); })} + {(tripSlipRecord?.vehicleAssignments?.length ?? 0) === 0 && ( + No vehicles assigned yet. + )} +
From 1d8f09583167f8c742da585605046e58b8268f35 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 22:43:45 +0000 Subject: [PATCH 059/122] fix --- .../first-mile/dto/allocate-containers.dto.ts | 8 - .../first-mile/first-mile.controller.ts | 59 +---- .../modules/first-mile/first-mile.service.ts | 117 +++------ .../FirstMileContainerAllocationTable.tsx | 164 ------------ .../src/pages/operations/FirstMilePage.tsx | 240 +++--------------- .../src/pages/operations/LastMilePage.tsx | 44 ++-- .../src/services/first-mile.service.ts | 4 + .../backoffice/src/types/booking.ts | 2 +- 8 files changed, 120 insertions(+), 518 deletions(-) delete mode 100644 apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts delete mode 100644 apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts deleted file mode 100644 index b750f1147..000000000 --- a/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -export class FirstMileContainerAllocationDto { - containerId!: string; - vehicleId!: string; -} - -export class AllocateFirstMileContainersDto { - allocations!: FirstMileContainerAllocationDto[]; -} diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 444cbee87..928882a7f 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -17,13 +17,9 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; -import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto'; import { FirstMileStatus } from './entities/first-mile.entity'; import { FirstMileService } from './first-mile.service'; import { FirstMileInvoiceService } from './first-mile-invoice.service'; -import { BillingService } from '../billing/billing.service'; -import { BookingsService } from '../bookings/bookings.service'; -import { Freight } from '@edr/types'; @ApiTags('first-mile') @ApiBearerAuth() @@ -33,8 +29,6 @@ export class FirstMileController { constructor( private readonly firstMileService: FirstMileService, private readonly firstMileInvoiceService: FirstMileInvoiceService, - private readonly billingService: BillingService, - private readonly bookingsService: BookingsService ) { } @Get() @@ -89,40 +83,17 @@ export class FirstMileController { @TrainSchedulingManage() @ApiOperation({ summary: 'Update a first-mile leg' }) async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) { - const record = await this.firstMileService.update(id, dto); - // Auto-generate invoice if distance or payment was updated - const booking = await this.bookingsService.findById(record.bookingId); - const currency = booking.paymentCurrency || "ETB"; - if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) { - await this.billingService.generateInvoice({ - source: Freight.InvoiceSource.FirstMile, - sourceId: record.id, - type: "FIRST_MILE", - companyId: booking.companyId, - companyProfileId: booking.companyProfileId, - currency, + // No invoice side-effects — invoices are generated only via the explicit + // POST :id/invoice endpoint (the "Generate Invoice" action). + return this.firstMileService.update(id, dto); + } - lines: [ - { - chargeType: "FIRST_MILE", - description: "First Mile Transportation Service", - quantity: 1, - unitRate: record.remainingPayment, - amount: record.remainingPayment, - currency, - }, - ], - - subtotalAmount: record.remainingPayment, - taxAmount: 0, // Replace if VAT/tax applies - totalAmount: record.remainingPayment, - - dueInDays: 7, - status: Freight.InvoiceStatus.Pending, - }); - await this.firstMileInvoiceService.ensureInvoiceFor(record); - } - return record; + @Post(':id/invoice') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Generate the first-mile delivery-fee invoice' }) + async generateInvoice(@Param('id', ParseUUIDPipe) id: string) { + const record = await this.firstMileService.findById(id); + return this.firstMileInvoiceService.ensureInvoiceFor(record); } @Delete(':id') @@ -132,14 +103,4 @@ export class FirstMileController { remove(@Param('id', ParseUUIDPipe) id: string) { return this.firstMileService.remove(id); } - - @Post(':firstMileId/allocate-containers') - @TrainSchedulingManage() - @ApiOperation({ summary: 'Allocate containers to vehicles for a first-mile leg' }) - allocateContainers( - @Param('firstMileId', ParseUUIDPipe) firstMileId: string, - @Body() dto: AllocateFirstMileContainersDto, - ) { - return this.firstMileService.allocateContainers(firstMileId, dto.allocations); - } } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 1a7db810d..dba5e48c7 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; +import { FindOptionsWhere, IsNull, Not } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; @@ -13,7 +13,7 @@ import { FirstMile, FirstMileStatus } from "./entities/first-mile.entity"; import { FirstMileContainerAllocation } from "./entities/first-mile-container-allocation.entity"; import { FirstMileRepository } from "./first-mile.repository"; import { OnEvent } from "@nestjs/event-emitter"; -import { InvoiceEventPayload } from "../billing/billing.service"; +import { BillingService, InvoiceEventPayload } from "../billing/billing.service"; import { FleetHistoryService } from "../fleet-history/fleet-history.service"; import { FleetEventType } from "../fleet-history/entities/fleet-event.entity"; @@ -46,8 +46,27 @@ export class FirstMileService { private readonly driversService: DriversService, private readonly smsClient: SmsClientService, private readonly history: FleetHistoryService, + private readonly billing: BillingService, ) { } + /** Attach real invoice info so the UI shows an invoice link only when one + * exists — not merely because distance was entered. Batched (no N+1). */ + private async attachInvoices(records: FirstMile[]): Promise { + const invoices = await this.billing.findBySourceIds( + 'first_mile', + records.map((r) => r.id), + ); + const byId = new Map(); + for (const inv of invoices) { + if (!byId.has(inv.sourceId)) { + byId.set(inv.sourceId, { id: inv.id, number: inv.invoiceNumber, status: String(inv.status) }); + } + } + for (const r of records) { + (r as FirstMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null; + } + } + /** Resolve a vehicle's driver + human labels, for stamping mile events onto * the driver's timeline and naming the vehicle. Best-effort — never throws. */ private async vehicleInfo( @@ -191,6 +210,8 @@ export class FirstMileService { take: pageSize, }); + await this.attachInvoices(data); + return { data, meta: { @@ -236,6 +257,8 @@ export class FirstMileService { throw new NotFoundException(`First-mile record ${id} not found`); } + await this.attachInvoices([record]); + return record; } @@ -537,84 +560,18 @@ export class FirstMileService { } async remove(id: string): Promise { - await this.findById(id); + const existing = await this.findById(id); + + // Can't delete once billed. + const invoices = await this.billing.findBySourceIds('first_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Cannot delete a first-mile leg after its invoice is generated', + ); + } + await this.firstMileRepository.softDelete(id); - } - - async allocateContainers( - firstMileId: string, - allocations: Array<{ containerId: string; vehicleId: string }>, - ) { - const firstMile = await this.findById(firstMileId); - if (!firstMile) { - throw new NotFoundException(`First-mile record ${firstMileId} not found`); - } - - const previousAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, { - where: { - firstMileId, - containerId: In(allocations.map((a) => a.containerId)), - }, - }); - const previousVehicleIds = previousAllocations - .map((a) => a.vehicleId) - .filter((id): id is string => Boolean(id)); - - await this.dataSource.transaction(async (manager) => { - for (const allocation of allocations) { - await manager.delete(FirstMileContainerAllocation, { - firstMileId, - containerId: allocation.containerId, - }); - await manager.insert(FirstMileContainerAllocation, { - firstMileId, - containerId: allocation.containerId, - vehicleId: allocation.vehicleId, - containerType: "CONTAINER", - quantity: 1, - }); - } - }); - - const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); - await Promise.all( - [...vehicleIds].map((vehicleId) => this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY)), - ); - await this.vehiclesService.releaseIfUnused( - previousVehicleIds.filter((id) => !vehicleIds.has(id)), - ); - - // History: one event per vehicle actually added or removed by this - // multi-car (re)allocation, so reassignments show on every timeline. - const prevSet = new Set(previousVehicleIds); - const bookingRef = await this.resolveBookingRef(firstMile); - for (const vehicleId of vehicleIds) { - if (prevSet.has(vehicleId)) continue; - const info = await this.vehicleInfo(vehicleId); - await this.history.record({ - eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, - vehicleId, - firstMileId, - driverId: info.driverId, - label: firstMile.status, - metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, - }); - } - for (const vehicleId of previousVehicleIds) { - if (vehicleIds.has(vehicleId)) continue; - const info = await this.vehicleInfo(vehicleId); - await this.history.record({ - eventType: FleetEventType.MILE_VEHICLE_RELEASED, - vehicleId, - firstMileId, - driverId: info.driverId, - metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, - }); - } - - return { - success: true, - allocated: allocations.length, - }; + // Free the trucks it was holding (direct + container), unless still in use. + await this.releaseVehicles(existing); } } diff --git a/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx deleted file mode 100644 index 78c5160ca..000000000 --- a/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { useState, useMemo } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { - Box, - Button, - Group, - Loader, - Select, - Stack, - Table, - Text, - Alert, -} from "@mantine/core"; -import { AlertCircle } from "lucide-react"; -import toast from "react-hot-toast"; - -import { vehiclesService } from "@/services/vehicles.service"; - -export interface ContainerAllocationRow { - id: string; - type: string; - qty: number; -} - -export interface FirstMileContainerAllocationTableProps { - firstMileId: string; - containers: ContainerAllocationRow[]; - onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise; -} - -/** - * Manual container-to-vehicle allocation table for first-mile pickups. - * Displays containers with type/qty, vehicle dropdown per row, and save action. - */ -export function FirstMileContainerAllocationTable({ - firstMileId, - containers, - onSave, -}: FirstMileContainerAllocationTableProps) { - const [allocations, setAllocations] = useState>( - () => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), - ); - - const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ - queryKey: ["vehicles", "free"], - queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }), - }); - - const vehicleOptions = useMemo( - () => - vehicles.map((v) => ({ - value: v.id, - label: `${v.plateNumber} (${v.vehicleType})`, - description: `${v.model} · ${v.manufacturer}`, - })), - [vehicles], - ); - - const saveAllocation = useMutation({ - mutationFn: async () => { - const mappings = containers - .filter((c) => allocations[c.id]) - .map((c) => ({ - containerId: c.id, - vehicleId: allocations[c.id]!, - })); - - if (mappings.length === 0) { - throw new Error("No containers allocated to vehicles"); - } - - await onSave(mappings); - }, - onSuccess: () => { - toast.success("Container allocations saved"); - setAllocations( - containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), - ); - }, - onError: (error) => { - toast.error( - error instanceof Error ? error.message : "Failed to save allocations", - ); - }, - }); - - const allocatedCount = Object.values(allocations).filter(Boolean).length; - const allAllocated = allocatedCount === containers.length; - - if (vehiclesLoading) { - return ( - - - - ); - } - - return ( - - {vehicles.length === 0 && ( - } color="yellow"> - No free vehicles available. Free up or add vehicles before allocating containers. - - )} - - - - - - Container ID - Type - Qty - Assigned Vehicle - - - - {containers.map((container) => ( - - - - {container.id} - - - {container.type} - {container.qty} - -
-
- - - - {allocatedCount} of {containers.length} containers allocated - - - -
- ); -} diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 5f1790f42..182ff7cac 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -13,6 +13,7 @@ import { Truck, } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; import type { ColumnDef } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { @@ -33,11 +34,9 @@ import { Text, TextInput, UnstyledButton, - Alert, } from "@mantine/core"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; -import { FirstMileContainerAllocationTable } from "@/components/FirstMileContainerAllocationTable"; import { ReceiveInventoryModal } from "@/components/warehouses/ReceiveInventoryModal"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { useToast } from "@/hooks/use-toast"; @@ -50,7 +49,6 @@ import { import { bookingsService } from "@/services/bookings.service"; import { vehiclesService } from "@/services/vehicles.service"; import { ratesService } from "@/services/rates.service"; -import { api } from "@/auth/http"; import type { BookingDetail } from "@/types/booking"; const formatPrice = (amount: number) => @@ -320,6 +318,7 @@ const buildTripSlipHtml = (record: FirstMileRecord) => { const FirstMilePage = () => { const { toast } = useToast(); const qc = useQueryClient(); + const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [search, setSearch] = useState(""); @@ -343,13 +342,9 @@ const FirstMilePage = () => { const [distanceOpen, setDistanceOpen] = useState(false); const [distanceValue, setDistanceValue] = useState(""); - const [invoiceOpen, setInvoiceOpen] = useState(false); - const [invoiceRecord, setInvoiceRecord] = useState(null); const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false); const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState(null); - const [containerAllocationOpen, setContainerAllocationOpen] = useState(false); - const [containerAllocationFirstMileId, setContainerAllocationFirstMileId] = useState(null); const { data: listData, isLoading } = useQuery({ queryKey: QUERY_KEYS.FIRST_MILE.list(), @@ -438,6 +433,17 @@ const FirstMilePage = () => { }, }); + const generateInvoiceMutation = useMutation({ + mutationFn: (id: string) => firstMileService.generateInvoice(id), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() }); + toast({ title: "Invoice generated" }); + }, + onError: () => { + toast({ title: "Invoice generation failed", variant: "destructive" }); + }, + }); + const acceptMutation = useMutation({ mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => { const res = await firstMileService.accept(reference); @@ -462,20 +468,6 @@ const FirstMilePage = () => { }, }); - const allocateMutation = useMutation({ - mutationFn: (data) => api.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data), - onSuccess: () => { - toast({ title: "Containers allocated" }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.byId(containerAllocationFirstMileId ?? "") }); - void qc.invalidateQueries({ queryKey: ["vehicles"] }); - setContainerAllocationOpen(false); - setContainerAllocationFirstMileId(null); - }, - onError: () => { - toast({ title: "Allocation failed", variant: "destructive" }); - }, - }); - const activeRecord = useMemo( () => records.find((r) => r.id === activeId) ?? null, [records, activeId], @@ -545,15 +537,6 @@ const FirstMilePage = () => { setDistanceValue(""); }; - const openInvoice = (record: FirstMileRecord) => { - setInvoiceRecord(record); - setInvoiceOpen(true); - }; - - const closeInvoice = () => { - setInvoiceOpen(false); - setInvoiceRecord(null); - }; const openWarehouseReceive = (record: FirstMileRecord) => { setWarehouseReceiveRecord(record); @@ -565,16 +548,6 @@ const FirstMilePage = () => { setWarehouseReceiveRecord(null); }; - const openContainerAllocation = (firstMileId: string) => { - setContainerAllocationFirstMileId(firstMileId); - setContainerAllocationOpen(true); - }; - - const closeContainerAllocation = () => { - setContainerAllocationOpen(false); - setContainerAllocationFirstMileId(null); - }; - const handleSaveDistance = () => { const distance = parseFloat(distanceValue); if (!activeId || isNaN(distance) || distance < 0) { @@ -779,35 +752,28 @@ const FirstMilePage = () => { header: "Invoice", meta: { headerClassName, cellClassName }, cell: ({ row }) => { - const hasDistance = row.original.exactKm != null && row.original.exactKm > 0; - const isPaid = (row.original as any).paid; - if (!hasDistance) { + // Only show once actually generated — not merely on distance. + const invoice = row.original.invoice; + if (!invoice) { return ; } - if (isPaid) { - return ( - - openInvoice(row.original)} - c="blue" - fw={500} - style={{ textDecoration: "underline", cursor: "pointer" }} - > - #345 - - Paid - - ); - } + const isPaid = (row.original as any).paid || invoice.status === "Paid"; return ( - openInvoice(row.original)} - c="blue" - fw={500} - style={{ textDecoration: "underline", cursor: "pointer" }} - > - #345 - + + + invoice.id + ? navigate(`/dashboard/invoices/${invoice.id}`) + : toast({ title: "Invoice link unavailable", description: "Refresh after the API restart.", variant: "destructive" }) + } + c="blue" + fw={500} + style={{ textDecoration: "underline", cursor: "pointer" }} + > + {invoice.number} + + {isPaid && Paid} + ); }, }, @@ -881,16 +847,20 @@ const FirstMilePage = () => { } + disabled={Boolean(row.original.invoice)} onClick={() => openDistance(row.original.id)} > Add distance } - disabled={!(row.original.exactKm != null && row.original.exactKm > 0)} - onClick={() => openInvoice(row.original)} + disabled={ + !(row.original.exactKm != null && row.original.exactKm > 0) || + Boolean(row.original.invoice) + } + onClick={() => generateInvoiceMutation.mutate(row.original.id)} > - Generate Invoice + {row.original.invoice ? "Invoice generated" : "Generate Invoice"} {canPrint && ( { } color="red" + disabled={Boolean(row.original.invoice)} onClick={() => { if (confirm(`Delete first-mile record ${bookingRef(row.original)}?`)) { deleteMutation.mutate(row.original.id); @@ -1295,137 +1266,6 @@ const FirstMilePage = () => { - - {/* Invoice modal */} - Invoice #345} - size="lg" - radius="lg" - centered - > - - {invoiceRecord && ( - <> - - - - EDR Freight - Invoice #345 - - - - - - - - - - - - - - - - - Post Payment - {formatPrice(invoiceRecord.remainingPayment)} - - - Advanced Payment - {formatPrice(invoiceRecord.advancedPayment)} - - - {(() => { - const postPayment = parseFloat(String(invoiceRecord.remainingPayment)); - const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment)); - const difference = postPayment - advancedPayment; - - if (difference > 0) { - return ( - - Remaining to Pay - {formatPrice(difference)} - - ); - } else if (difference < 0) { - return ( - - Refund - {formatPrice(Math.abs(difference))} - - ); - } else { - return ( - - Status - Settled - - ); - } - })()} - - - - - - )} - - - - - - - {/* Container Allocation modal */} - Allocate Containers to Vehicles} - size="xl" - radius="lg" - centered - > - - {activeRecord && ( - <> - {/* Capacity guidance */} - {activeRecord.booking?.cargoType?.label === "BULK" ? ( - - - Select multiple containers per vehicle based on capacity. Each vehicle can carry multiple containers if capacity allows. - - - Capacity: TBD — TODO: add vehicle capacity_tons to vehicle API if missing - - - ) : ( - - - One vehicle per container. Each container will be assigned to a single vehicle. - - - )} - - - {/* Container table */} - { - await allocateMutation.mutateAsync(allocations); - }} - /> - - )} - - - - - ); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index f3f290304..0267bd1ce 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -1761,34 +1761,46 @@ const LastMilePage = () => { > - {tripSlipRecord ? bookingRef(tripSlipRecord) : ""} — choose a truck to print its slip - (vehicle, driver, container). + {tripSlipRecord ? bookingRef(tripSlipRecord) : ""} — pick a truck to print its slip. {(tripSlipRecord?.vehicleAssignments ?? []).map((a) => { const v = a.vehicle; const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; return ( - + + + + + {label} + + + Driver: {v?.assignedDriverName || "—"} + + + Container: {a.containerNumber || "—"} + + + + + + + ); })} {(tripSlipRecord?.vehicleAssignments?.length ?? 0) === 0 && ( - No vehicles assigned yet. + No vehicles assigned yet. )} - diff --git a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts index 1c81f5dc5..1418cb636 100644 --- a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts @@ -45,6 +45,8 @@ export interface FirstMileRecord { vehicleId?: string | null; booking?: FirstMileBooking | null; vehicle?: FirstMileVehicle | null; + /** Present only when an invoice has actually been generated (not on distance). */ + invoice?: { id: string; number: string; status: string } | null; createdAt: string; updatedAt: string; } @@ -66,4 +68,6 @@ export const firstMileService = { api.post(FM.ACCEPT(bookingReference)), remove: (id: string) => api.delete(FM.BY_ID(id)), + generateInvoice: (id: string) => + api.post<{ id: string; invoiceNumber?: string } | null>(`${FM.BASE}/${id}/invoice`), }; diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index 298e749c7..c1be65fa6 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -188,7 +188,7 @@ export interface BookingDetail { company?: BookingNamedRef & Partial; originYard?: BookingNamedRef; destinationYard?: BookingNamedRef; - serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean }; + serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean }; cargoType?: BookingNamedRef; shippingLine?: BookingNamedRef; bookingContainers?: BookingContainerLine[]; From c2649dae3c131fdd88fd610a49711fd9a2be0072 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 23:11:04 +0000 Subject: [PATCH 060/122] enhance phase countdown and booking window descriptions for clarity --- .../components/UpcomingWindowsSection.tsx | 53 +++++++++++++++---- .../portal/src/services/bookings.service.ts | 9 ++-- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx index bb5b629de..300ff643f 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx @@ -45,22 +45,50 @@ function windowLabel(w: MyBookingWindow): string { } /** - * The deadline + label for whichever phase the window is currently in. Phases - * run: window open (closes at windowClosesAt) → document review (docReviewEndsAt) - * → payment (paymentPhaseEndsAt). Returns null when no phase is timing down. + * The countdown for whichever phase the window is currently in. Phases run: + * pre-window (opens at windowOpensAt) → open (closes at windowClosesAt) → + * document review (docReviewEndsAt) → payment (paymentPhaseEndsAt). + * + * `label` describes the deadline being counted down to; `expiredText` names the + * NEXT step so that when a deadline lapses between the 60s refetches the row + * announces what comes next ("Booking opening now…", "Review starting…") rather + * than the bare word "Expired". Returns null when no phase is timing down. */ function phaseCountdown( w: MyBookingWindow, -): { label: string; deadline: string } | null { +): { label: string; deadline: string; expiredText: string } | null { switch (w.windowPhase) { + case "PRE_WINDOW": + if (w.windowOpensAt) + return { + label: "Booking opens in", + deadline: w.windowOpensAt, + expiredText: "Booking opening now…", + }; + return null; case "OPEN": - if (w.windowClosesAt) return { label: "Window closes in", deadline: w.windowClosesAt }; + if (w.windowClosesAt) + return { + label: "Window closes in", + deadline: w.windowClosesAt, + expiredText: "Document review starting…", + }; return null; case "DOC_REVIEW": - if (w.docReviewEndsAt) return { label: "Document review ends in", deadline: w.docReviewEndsAt }; + if (w.docReviewEndsAt) + return { + label: "Document review ends in", + deadline: w.docReviewEndsAt, + expiredText: "Payment starting…", + }; return null; case "PAYMENT": - if (w.paymentPhaseEndsAt) return { label: "Payment due in", deadline: w.paymentPhaseEndsAt }; + if (w.paymentPhaseEndsAt) + return { + label: "Payment due in", + deadline: w.paymentPhaseEndsAt, + expiredText: "Payment window closing…", + }; return null; default: return null; @@ -141,9 +169,11 @@ interface UpcomingWindowsSectionProps { } /** - * The customer's upcoming/open booking windows on their active-contract - * lanes. Import trains open a window on one booking day; export trains open - * 24h before departure. Hidden entirely when there is nothing to show. + * All announced upcoming/open booking windows, shown to every customer + * regardless of whether they hold a contract on the lane. Import trains open a + * window on one booking day; export trains open 24h before departure. Rows on a + * lane the customer has an active contract for carry a "Book now" action; + * others route to the contract list. Hidden entirely when nothing is announced. */ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ windows, @@ -162,7 +192,7 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ Booking Windows - Upcoming and open booking windows on your contract lanes + Upcoming and open booking windows across all lanes @@ -211,6 +241,7 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 79676cab1..c2729bebb 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -47,13 +47,16 @@ export interface PriceLineItem { } /** - * An upcoming/open booking window on one of the signed-in customer's - * active-contract lanes. Import trains open a window on one booking day; + * An announced upcoming/open booking window, shown to every signed-in customer + * regardless of contract. Import trains open a window on one booking day; * export trains open 24h before departure (first come, first served). */ export interface MyBookingWindow { scheduleId: string; - /** Contract whose route this window belongs to, when the row carries it. */ + /** + * The customer's active contract on this lane, when they hold one — enables + * "Book now" to target it. Null for lanes they have no contract on. + */ contractId: string | null; /** ONE_TIME contracts can't draw down against a window — button is hidden. */ contractKind: "ONE_TIME" | "GENERAL" | null; From 18926c32cba8d2c44974699d00591285c0c2cead Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 23:24:41 +0000 Subject: [PATCH 061/122] enhance phase countdown and booking window descriptions for clarity --- apps/edr-freight-web/backoffice/src/App.tsx | 18 + .../components/fleet/FleetRecordActions.tsx | 19 + .../src/pages/fleet/DriverDetailPage.tsx | 264 ++++++++++ .../src/pages/fleet/VehicleDetailPage.tsx | 453 ++++++++++++++++++ .../src/pages/fleet/config/drivers.ts | 1 + .../src/pages/fleet/config/vehicles.ts | 1 + 6 files changed, 756 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 4c74f6993..70b080d6d 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -83,6 +83,8 @@ import UsersPage from "./pages/dashboard/user-management/UsersPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; +import VehicleDetailPage from "./pages/fleet/VehicleDetailPage"; +import DriverDetailPage from "./pages/fleet/DriverDetailPage"; import RoutesPage from "./pages/fleet/RoutesPage"; import FuelPurchasePage from "./pages/fleet/FuelPurchasePage"; import FuelStatsPage from "./pages/fleet/FuelStatsPage"; @@ -926,6 +928,14 @@ const App = () => { } /> + + + + } + /> { } /> + + + + } + /> void; onAssignDriver?: (record: FleetRecord) => void; onHistory?: (record: FleetRecord) => void; + onViewDetail?: (record: FleetRecord) => void; layout?: "row" | "compact"; } @@ -22,11 +23,13 @@ const FleetRecordActions = ({ onRemove, onAssignDriver, onHistory, + onViewDetail, layout = "row", }: FleetRecordActionsProps) => { const navigate = useNavigate(); const removeLabel = config.removeActionLabel ?? "Delete"; const showDetail = Boolean(config.detailPath && "id" in record); + const showViewDetail = Boolean(onViewDetail); const isVehicle = config.slug === "vehicles"; const showHistory = Boolean(onHistory) && @@ -62,6 +65,14 @@ const FleetRecordActions = ({ > Edit + {showViewDetail ? ( + onViewDetail?.(record)} + leftSection={} + > + View detail + + ) : null} {showHistory ? ( onHistory?.(record)} @@ -114,6 +125,14 @@ const FleetRecordActions = ({ > Edit + {showViewDetail ? ( + onViewDetail?.(record)} + leftSection={} + > + View detail + + ) : null} {showDetail ? ( { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString(); +}; +const fmtDateTime = (iso?: string | null) => { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString(); +}; +const meta = (e: FleetHistoryEvent, k: string) => { + const v = e.metadata?.[k]; + return typeof v === "string" && v ? v : null; +}; + +const InfoRow = ({ label, value }: { label: string; value: React.ReactNode }) => ( + + {label} + {value} + +); +const Loading = () => ( +
+); + +const DriverDetailPage = () => { + const { id = "" } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const { data: driver, isLoading } = useQuery({ + queryKey: ["driver", id], + queryFn: () => driversService.getById(id).then((r) => r.data), + enabled: Boolean(id), + }); + + const name = driver ? `${driver.firstName ?? ""} ${driver.lastName ?? ""}`.trim() : ""; + const licenseExpired = + driver?.licenseExpiryDate && new Date(driver.licenseExpiryDate) < new Date(); + + return ( + + + navigate("/dashboard/drivers")} aria-label="Back"> + + + + + {name || "Driver"} + {driver && ( + + {driver.status} + {driver.faydaVerified && ( + }> + Fayda verified + + )} + {licenseExpired && ( + License expired + )} + {driver.licenseNumber} + + )} + + + + {isLoading ? ( + + ) : !driver ? ( + Driver not found. + ) : ( + + + }>Overview + }>Vehicles + }>History + }>Trips + + + + + + + + + + + {fmtDate(driver.licenseExpiryDate)} + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + )} + + ); +}; + +const useDriverHistory = (driverId: string) => + useQuery({ + queryKey: ["driver-history", driverId], + queryFn: () => fleetHistoryService.driver(driverId), + }); + +const VehiclesTab = ({ driverId }: { driverId: string }) => { + const { data = [], isLoading } = useDriverHistory(driverId); + const rows = data + .filter((e) => e.eventType === "DRIVER_ASSIGNED") + .map((e) => ({ id: e.id, plate: meta(e, "vehiclePlate") || e.vehicleId || "Vehicle", at: e.createdAt })); + if (isLoading) return ; + return ( + + Vehicles driven ({rows.length}) + {rows.length === 0 ? ( + No vehicle assignments recorded. + ) : ( + + + VehicleAssigned + + + {rows.map((r) => ( + + {r.plate} + {fmtDateTime(r.at)} + + ))} + +
+ )} +
+ ); +}; + +const HistoryTab = ({ driverId }: { driverId: string }) => { + const { data = [], isLoading } = useDriverHistory(driverId); + if (isLoading) return ; + if (!data.length) return No activity recorded yet.; + return ( + + {data.map((e) => ( + {e.eventType.replaceAll("_", " ")}}> + {(meta(e, "vehiclePlate") || meta(e, "bookingRef") || e.label) && ( + + {[meta(e, "vehiclePlate"), meta(e, "bookingRef") && `Booking ${meta(e, "bookingRef")}`, e.label] + .filter(Boolean) + .join(" · ")} + + )} + {fmtDateTime(e.createdAt)} + + ))} + + ); +}; + +const TripsTab = ({ driverId }: { driverId: string }) => { + const { data = [], isLoading } = useDriverHistory(driverId); + const trips = useMemo( + () => + data + .filter((e) => e.eventType === "MILE_VEHICLE_ASSIGNED") + .map((e) => ({ + id: e.id, + mile: meta(e, "mile") === "LAST" ? "Last-mile" : "First-mile", + booking: meta(e, "bookingRef") ?? "—", + vehicle: meta(e, "vehiclePlate") ?? "—", + status: e.label ?? "—", + at: e.createdAt, + })), + [data], + ); + if (isLoading) return ; + return ( + + Trips assigned ({trips.length}) + {trips.length === 0 ? ( + No trips recorded. + ) : ( + + + + + MileBooking + VehicleStatusWhen + + + + {trips.map((t) => ( + + {t.mile} + {t.booking} + {t.vehicle} + {t.status} + {fmtDateTime(t.at)} + + ))} + +
+
+ )} +
+ ); +}; + +export default DriverDetailPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx new file mode 100644 index 000000000..76665f385 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx @@ -0,0 +1,453 @@ +import { useMemo } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { + ActionIcon, + Badge, + Card, + Center, + Container, + Group, + Loader, + SimpleGrid, + Stack, + Table, + Tabs, + Text, + Timeline, + Title, +} from "@mantine/core"; +import { + ArrowLeft, + Fuel, + History, + Route, + Truck, + User, + Wrench, +} from "lucide-react"; + +import { api } from "@/auth/http"; +import { vehiclesService } from "@/services/vehicles.service"; +import { driversService } from "@/services/drivers.service"; +import { fleetHistoryService } from "@/services/fleet-history.service"; + +interface MaintenanceCost { + id: string; + incurredDate: string; + costAmount: number; + costType: string; + description?: string | null; + serviceProvider?: string | null; + invoiceNumber?: string | null; +} +interface FuelPurchase { + id: string; + purchaseDate: string; + liters: number; + costPerLiter: number; + totalCost: number; + fuelStation?: string | null; + odometerReading?: number | null; +} +interface MileRecord { + id: string; + status: string; + exactKm?: number | null; + estimatedKm?: number | null; + remainingPayment?: number | null; + advancedPayment?: number | null; + booking?: { reference?: string } | null; + bookingId: string; +} + +const fmtDate = (iso?: string | null) => { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString(); +}; +const fmtDateTime = (iso?: string | null) => { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString(); +}; +const money = (n?: number | null) => + n == null ? "—" : `ETB ${Number(n).toLocaleString(undefined, { maximumFractionDigits: 2 })}`; + +const InfoRow = ({ label, value }: { label: string; value: React.ReactNode }) => ( + + {label} + {value} + +); + +const Loading = () => ( +
+); + +const VehicleDetailPage = () => { + const { id = "" } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const { data: vehicle, isLoading } = useQuery({ + queryKey: ["vehicle", id], + queryFn: () => vehiclesService.getById(id).then((r) => r.data), + enabled: Boolean(id), + }); + + const plate = vehicle + ? [vehicle.code, vehicle.plateNumber].filter(Boolean).join(" · ") + : ""; + + return ( + + + navigate("/dashboard/vehicles")} aria-label="Back"> + + + + + {plate || "Vehicle"} + {vehicle && ( + + {vehicle.status} + + {vehicle.availability} + + {vehicle.vehicleType && {vehicle.vehicleType}} + + )} + + + + {isLoading ? ( + + ) : !vehicle ? ( + Vehicle not found. + ) : ( + + + }>Overview + }>Driver + }>History + }>Maintenance + }>Fuel + }>First/Last mile + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + ); +}; + +const DriverTab = ({ + vehicleId, + driverId, + fallbackName, +}: { + vehicleId: string; + driverId?: string | null; + fallbackName?: string | null; +}) => { + const { data: driver } = useQuery({ + queryKey: ["driver", driverId], + queryFn: () => driversService.getById(driverId!).then((r) => r.data), + enabled: Boolean(driverId), + }); + // All drivers that have driven this vehicle, from the assignment history. + const { data: history = [], isLoading } = useQuery({ + queryKey: ["vehicle-history", vehicleId], + queryFn: () => fleetHistoryService.vehicle(vehicleId), + }); + const drivers = history + .filter((e) => e.eventType === "DRIVER_ASSIGNED") + .map((e) => ({ + id: e.id, + driverId: e.driverId, + name: (typeof e.metadata?.driverName === "string" && e.metadata.driverName) || e.label || "Driver", + at: e.createdAt, + })); + + return ( + + {driverId && driver ? ( + + Current driver + + + + + + + + + + ) : ( + {fallbackName ? `Assigned: ${fallbackName}` : "No driver currently assigned."} + )} + + + Driver history ({drivers.length}) + {isLoading ? ( + + ) : drivers.length === 0 ? ( + No driver assignments recorded. + ) : ( + + + + DriverAssigned + + + + {drivers.map((d) => ( + + {d.name} + {fmtDateTime(d.at)} + + ))} + +
+ )} +
+
+ ); +}; + +const HistoryTab = ({ vehicleId }: { vehicleId: string }) => { + const { data = [], isLoading } = useQuery({ + queryKey: ["vehicle-history", vehicleId], + queryFn: () => fleetHistoryService.vehicle(vehicleId), + }); + if (isLoading) return ; + if (!data.length) return No activity recorded yet.; + return ( + + {data.map((e) => ( + {e.eventType.replaceAll("_", " ")}}> + {(e.label || e.fromValue || e.toValue) && ( + + {[e.label, e.fromValue && e.toValue ? `${e.fromValue} → ${e.toValue}` : e.toValue] + .filter(Boolean) + .join(" · ")} + + )} + {fmtDateTime(e.createdAt)} + + ))} + + ); +}; + +const MaintenanceTab = ({ vehicleId }: { vehicleId: string }) => { + const { data = [], isLoading } = useQuery({ + queryKey: ["vehicle-maintenance", vehicleId], + queryFn: () => api.get(`/maintenance/history/${vehicleId}`).then((r) => r.data), + }); + const total = useMemo(() => data.reduce((s, m) => s + (Number(m.costAmount) || 0), 0), [data]); + if (isLoading) return ; + return ( + + + Last 12 months + Total: {money(total)} + + {data.length === 0 ? ( + No maintenance records. + ) : ( + + + + + DateTypeAmount + DescriptionProvider + + + + {data.map((m) => ( + + {fmtDate(m.incurredDate)} + {m.costType} + {money(m.costAmount)} + {m.description ?? "—"} + {m.serviceProvider ?? "—"} + + ))} + +
+
+ )} +
+ ); +}; + +const FuelTab = ({ vehicleId }: { vehicleId: string }) => { + const { data = [], isLoading } = useQuery({ + queryKey: ["vehicle-fuel", vehicleId], + queryFn: () => { + const end = new Date(); + const start = new Date(); + start.setMonth(start.getMonth() - 12); + const qs = `startDate=${start.toISOString()}&endDate=${end.toISOString()}`; + return api.get(`/fuel/purchases/${vehicleId}?${qs}`).then((r) => r.data); + }, + }); + const totals = useMemo( + () => ({ + liters: data.reduce((s, f) => s + (Number(f.liters) || 0), 0), + cost: data.reduce((s, f) => s + (Number(f.totalCost) || 0), 0), + }), + [data], + ); + if (isLoading) return ; + return ( + + + + Total litres + {totals.liters.toLocaleString(undefined, { maximumFractionDigits: 1 })} L + + + Total fuel cost + {money(totals.cost)} + + + Purchases + {data.length} + + + {data.length === 0 ? ( + No fuel purchases in the last 12 months. + ) : ( + + + + + DateLitresCost/L + TotalOdometerStation + + + + {data.map((f) => ( + + {fmtDate(f.purchaseDate)} + {f.liters} L + {money(f.costPerLiter)} + {money(f.totalCost)} + {f.odometerReading ?? "—"} + {f.fuelStation ?? "—"} + + ))} + +
+
+ )} +
+ ); +}; + +const mileTotal = (rows: MileRecord[]) => + rows.reduce((s, r) => s + (Number(r.remainingPayment) || 0), 0); + +const MileTable = ({ title, rows }: { title: string; rows: MileRecord[] }) => ( + + + {title} ({rows.length}) + Total: {money(mileTotal(rows))} + + {rows.length === 0 ? ( + None. + ) : ( + + + + BookingStatus + Distance (km)Cost + + + + {rows.map((r) => ( + + {r.booking?.reference ?? r.bookingId} + {r.status} + {r.exactKm ?? r.estimatedKm ?? "—"} + {money(r.remainingPayment)} + + ))} + +
+ )} +
+); + +const MileTab = ({ vehicleId }: { vehicleId: string }) => { + const first = useQuery({ + queryKey: ["vehicle-first-mile", vehicleId], + queryFn: () => + api.get<{ data: MileRecord[] }>(`/first-mile?vehicleId=${vehicleId}&pageSize=1000`).then((r) => r.data.data ?? []), + }); + const last = useQuery({ + queryKey: ["vehicle-last-mile", vehicleId], + queryFn: () => + api.get<{ data: MileRecord[] }>(`/last-mile?vehicleId=${vehicleId}&pageSize=1000`).then((r) => r.data.data ?? []), + }); + if (first.isLoading || last.isLoading) return ; + const firstRows = first.data ?? []; + const lastRows = last.data ?? []; + const grandTotal = mileTotal(firstRows) + mileTotal(lastRows); + return ( + + + + Total first + last mile revenue for this vehicle + {money(grandTotal)} + + + + + + ); +}; + +export default VehicleDetailPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts index f240bda09..fdfdd367d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts @@ -19,6 +19,7 @@ export const driversConfig: FleetResourceConfig = { label: "Drivers", subtitle: "Manage driver records and licenses", basePath: "/dashboard/drivers", + detailPath: "/dashboard/drivers/:id", addLabel: "Add Driver", entityLabel: "Driver", searchPlaceholder: "Search drivers…", diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index de6eb09c7..5e76584ed 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -34,6 +34,7 @@ export const vehiclesConfig: FleetResourceConfig = { label: "Vehicles", subtitle: "Manage vehicle master data for fleet operations", basePath: "/dashboard/vehicles", + detailPath: "/dashboard/vehicles/:id", addLabel: "Add Vehicle", entityLabel: "Vehicle", searchPlaceholder: "Search vehicles…", From f153f2936b70eefa508bfb9e121b290dae103308 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 23:30:05 +0000 Subject: [PATCH 062/122] fix --- .../src/pages/fleet/DriverDetailPage.tsx | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx index 190dc8b85..dfdf822e4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx @@ -27,6 +27,7 @@ import { } from "lucide-react"; import { driversService } from "@/services/drivers.service"; +import { vehiclesService } from "@/services/vehicles.service"; import { fleetHistoryService, type FleetHistoryEvent } from "@/services/fleet-history.service"; const fmtDate = (iso?: string | null) => { @@ -160,11 +161,31 @@ const useDriverHistory = (driverId: string) => queryFn: () => fleetHistoryService.driver(driverId), }); +/** id → "code · plate" map so events without a stored plate still show a name. */ +const useVehicleMap = () => { + const { data } = useQuery({ + queryKey: ["vehicles-all"], + queryFn: () => vehiclesService.getAll({}).then((r) => r.data), + }); + return useMemo(() => { + const m = new Map(); + for (const v of data ?? []) { + m.set(v.id, [v.code, v.plateNumber].filter(Boolean).join(" · ") || v.id); + } + return m; + }, [data]); +}; + const VehiclesTab = ({ driverId }: { driverId: string }) => { const { data = [], isLoading } = useDriverHistory(driverId); + const vmap = useVehicleMap(); const rows = data .filter((e) => e.eventType === "DRIVER_ASSIGNED") - .map((e) => ({ id: e.id, plate: meta(e, "vehiclePlate") || e.vehicleId || "Vehicle", at: e.createdAt })); + .map((e) => ({ + id: e.id, + plate: meta(e, "vehiclePlate") || (e.vehicleId ? vmap.get(e.vehicleId) : null) || "Vehicle", + at: e.createdAt, + })); if (isLoading) return ; return ( @@ -214,6 +235,7 @@ const HistoryTab = ({ driverId }: { driverId: string }) => { const TripsTab = ({ driverId }: { driverId: string }) => { const { data = [], isLoading } = useDriverHistory(driverId); + const vmap = useVehicleMap(); const trips = useMemo( () => data @@ -222,11 +244,11 @@ const TripsTab = ({ driverId }: { driverId: string }) => { id: e.id, mile: meta(e, "mile") === "LAST" ? "Last-mile" : "First-mile", booking: meta(e, "bookingRef") ?? "—", - vehicle: meta(e, "vehiclePlate") ?? "—", + vehicle: meta(e, "vehiclePlate") || (e.vehicleId ? vmap.get(e.vehicleId) : null) || "—", status: e.label ?? "—", at: e.createdAt, })), - [data], + [data, vmap], ); if (isLoading) return ; return ( From 69818ab3f996db2a5f87db7de0744eca9758ed42 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 23:30:49 +0000 Subject: [PATCH 063/122] add booking window to gl --- .../train-scheduling.controller.ts | 5 +- .../train-scheduling.service.ts | 32 ++- .../contracts/GlUpcomingWindowsSection.tsx | 250 ++++++++++++++++++ .../contracts/ContractClearanceListPage.tsx | 3 + .../BatchScheduleDetailPage.tsx | 2 +- 5 files changed, 279 insertions(+), 13 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 2d591f2e0..22e322953 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -61,13 +61,14 @@ export class TrainSchedulingController { @Get("my-booking-windows") @ApiOperation({ summary: - "Upcoming/open booking windows on the signed-in customer's active contract lanes", + "Upcoming/open booking windows announced to the signed-in customer (all window-engine schedules; their own contract lanes carry a Book-now target)", }) async getMyBookingWindows(@CurrentUser() user: AuthUserPayload) { + // Every customer sees announced windows; companyId (when resolvable) just + // enriches lanes they hold a contract on so "Book now" can target it. const companyId = await this.billingService.resolveCompanyId( resolveAuthUserId(user), ); - if (!companyId) return []; return this.trainSchedulingService.getBookingWindowsForCompany(companyId); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 199be696a..bac226c0a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -3121,14 +3121,20 @@ export class TrainSchedulingService { } /** - * Upcoming/open booking windows for a customer's active-contract lanes — - * powers the portal home "booking windows" section. Only window-engine - * schedules (IMPORT cycle / EXPORT lead) are listed; DOMESTIC trains are - * always open and need no announcement. + * Upcoming/open booking windows announced on the portal home "booking + * windows" section. ALL window-engine schedules (IMPORT cycle / EXPORT lead) + * are listed so every customer sees what is opening — not just those on their + * contract lanes; DOMESTIC trains are always open and need no announcement. + * + * When `companyId` is given, a matching active contract on the lane is + * LEFT-JOINed in so the row carries `contractId`/`contractKind` (enabling + * "Book now"); customers with no covering contract still see the window with a + * null contract, and the portal routes them to the contract list to get one. */ - async getBookingWindowsForCompany(companyId: string) { + async getBookingWindowsForCompany(companyId: string | null) { const rows: Array = await this.dataSource.query( - `SELECT DISTINCT ts.id AS schedule_id, + `SELECT DISTINCT ON (ts.id) + ts.id AS schedule_id, cr.contract_id AS contract_id, c.contract_kind AS contract_kind, ts.direction, @@ -3143,11 +3149,11 @@ export class TrainSchedulingService { oy.label AS origin_label, oy.code AS origin_code, dy.label AS destination_label, dy.code AS destination_code FROM freight.train_schedules ts - JOIN freight.contract_routes cr + LEFT JOIN freight.contract_routes cr ON cr.origin_yard_id = ts.origin_station_id AND cr.destination_yard_id = ts.destination_station_id AND cr.deleted_at IS NULL - JOIN freight.contracts c + LEFT JOIN freight.contracts c ON c.id = cr.contract_id AND c.company_id = $1 AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') @@ -3159,10 +3165,16 @@ export class TrainSchedulingService { AND ts.window_phase IS NOT NULL AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') AND ts.scheduled_departure_date >= now() - ORDER BY ts.window_opens_at ASC NULLS LAST`, + ORDER BY ts.id, c.id NULLS LAST, ts.window_opens_at ASC NULLS LAST`, [companyId], ); - return rows.map((r) => this.mapBookingWindowRow(r)); + return rows + .map((r) => this.mapBookingWindowRow(r)) + .sort((a, b) => { + const ta = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity; + const tb = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity; + return ta - tb; + }); } /** diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx new file mode 100644 index 000000000..9d9573909 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx @@ -0,0 +1,250 @@ +import { useMemo } from "react"; +import { Badge, Box, Card, Group, ScrollArea, Skeleton, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { ArrowRight, CalendarClock } from "lucide-react"; +import { CountdownTimer } from "@edr/ui-common"; + +import { api } from "@/services/api"; +import type { BatchBoardSchedule } from "@/types/trainScheduling"; + +/** All window times are communicated in East Africa Time. */ +const TZ = "Africa/Addis_Ababa"; + +function fmtDay(iso: string): string { + return new Date(iso).toLocaleDateString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + timeZone: TZ, + }); +} + +function fmtTime(iso: string): string { + return new Date(iso).toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: TZ, + }); +} + +function windowLabel(w: BatchBoardSchedule): string { + if (w.windowOpensAt && w.windowClosesAt) { + return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} – ${fmtTime( + w.windowClosesAt, + )} EAT`; + } + if (w.windowOpensAt) { + return `Opens ${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} EAT`; + } + return (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " "); +} + +/** + * The countdown for whichever phase the window is currently in, mirroring the + * customer portal. Phases run pre-window (opens at windowOpensAt) → open (closes + * at windowClosesAt) → document review (docReviewEndsAt) → payment + * (paymentPhaseEndsAt). `expiredText` names the NEXT step so a deadline that + * lapses between the 60s refetches announces what comes next rather than the + * bare word "Expired". Returns null when no phase is timing down. + */ +function phaseCountdown( + w: BatchBoardSchedule, +): { label: string; deadline: string; expiredText: string } | null { + switch (w.windowPhase) { + case "PRE_WINDOW": + return w.windowOpensAt + ? { + label: "Booking opens in", + deadline: w.windowOpensAt, + expiredText: "Booking opening now…", + } + : null; + case "OPEN": + return w.windowClosesAt + ? { + label: "Window closes in", + deadline: w.windowClosesAt, + expiredText: "Document review starting…", + } + : null; + case "DOC_REVIEW": + return w.docReviewEndsAt + ? { + label: "Document review ends in", + deadline: w.docReviewEndsAt, + expiredText: "Payment starting…", + } + : null; + case "PAYMENT": + return w.paymentPhaseEndsAt + ? { + label: "Payment window ends in", + deadline: w.paymentPhaseEndsAt, + expiredText: "Payment window closing…", + } + : null; + default: + return null; + } +} + +function isOpenNow(w: BatchBoardSchedule): boolean { + return w.windowPhase === "OPEN" && w.bookingWindowStatus === "OPEN"; +} + +/** Drop windows whose booking window (or the train itself) has already passed. */ +function isPast(w: BatchBoardSchedule): boolean { + const now = Date.now(); + const closes = w.windowClosesAt ? new Date(w.windowClosesAt).getTime() : null; + const departs = w.scheduleDate ? new Date(w.scheduleDate).getTime() : null; + // Still live while in a post-close staff phase (doc review / payment). + if (w.windowPhase === "DOC_REVIEW" || w.windowPhase === "PAYMENT") return false; + if (departs != null && departs <= now) return true; + if (closes != null && closes <= now) return true; + return false; +} + +/** + * Upcoming / open import booking windows across all train schedules, shown to GL + * ET on the clearance queue so they can see which lanes are accepting bookings + * (mirrors the customer's portal "Booking Windows" card). Hidden when nothing is + * pending. Windows already past close/departure are dropped. + */ +export function GlUpcomingWindowsSection() { + const { data, isLoading } = useQuery( + api.trainScheduling.batchBoard.queryOptions({ refetchInterval: 60_000 }), + ); + + const windows = useMemo(() => { + const rows = (data ?? []).filter( + (w) => w.windowPhase != null && w.windowPhase !== "DONE" && !isPast(w), + ); + // Open lanes first, then by opening time. + return rows.sort((a, b) => { + const openDiff = Number(isOpenNow(b)) - Number(isOpenNow(a)); + if (openDiff !== 0) return openDiff; + const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity; + const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity; + return at - bt; + }); + }, [data]); + + if (!isLoading && windows.length === 0) return null; + + return ( + + + + + + Booking windows + + + Upcoming and open import booking windows across all lanes (EAT) + + + + + {isLoading ? ( + + {[1, 2].map((i) => ( + + ))} + + ) : ( + + + {windows.map((w) => { + const open = isOpenNow(w); + const cd = phaseCountdown(w); + return ( + + + + + {w.origin ?? "—"} + + + + {w.destination ?? "—"} + + {w.trainNumber ? ( + + · {w.trainNumber} + + ) : null} + + + {windowLabel(w)} + {w.scheduleDate ? ` · Departs ${fmtDay(w.scheduleDate)}` : ""} + + {cd ? ( + + + + ) : null} + + + + {w.direction ? ( + + {w.direction === "IMPORT" ? "Import" : "Export"} + + ) : null} + + {open + ? "Open now" + : w.windowPhase === "PRE_WINDOW" && w.windowOpensAt + ? `Opens ${fmtTime(w.windowOpensAt)} EAT` + : (w.windowPhase ?? w.bookingWindowStatus).replace( + /_/g, + " ", + )} + + + + ); + })} + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx index 034958303..1121329fb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx @@ -50,6 +50,7 @@ import { useEtClearanceQueue, } from "@/hooks/contracts/useContracts"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection"; type ViewMode = "table" | "cards"; type QueueTab = "all" | "et"; @@ -446,6 +447,8 @@ export default function ContractClearanceListPage() { ]} /> + + {queueTabOptions.length > 1 ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index a4ebd531e..7e38ba932 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -40,7 +40,7 @@ import { XCircle, } from "lucide-react"; -import { DataTable, type ColumnDef } from "@edr/ui-common"; +import { CountdownTimer, DataTable, type ColumnDef } from "@edr/ui-common"; import { KpiStrip, PageContainer } from "@/components/page"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; From 61f70d54716a343ac8e444955528e0edf5bda9f8 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 23:37:11 +0000 Subject: [PATCH 064/122] add booking window to gl --- .../BatchScheduleDetailPage.tsx | 459 +++++++----------- 1 file changed, 166 insertions(+), 293 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index 7e38ba932..de6ecfa9b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -1,8 +1,7 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Accordion, - ActionIcon, Alert, Badge, Box, @@ -24,9 +23,7 @@ import { Boxes, CalendarDays, CheckCircle2, - ChevronLeft, ClipboardCheck, - ChevronRight, Clock, FileSignature, Hourglass, @@ -431,101 +428,148 @@ function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) { } /** "05 Jun 2026 · 06:00 – 09:00 EAT" → "06:00 – 09:00 EAT" (date lives in the day header). */ -function timeLabelOf(label: string): string { - const idx = label.indexOf("·"); - return idx >= 0 ? label.slice(idx + 1).trim() : label; -} - const EAT_TZ = "Africa/Addis_Ababa"; -const dateKeyFmt = new Intl.DateTimeFormat("en-CA", { - timeZone: EAT_TZ, - year: "numeric", - month: "2-digit", - day: "2-digit", -}); const dateLabelFmt = new Intl.DateTimeFormat("en-GB", { timeZone: EAT_TZ, weekday: "short", day: "2-digit", month: "short", }); +const timeFmt = new Intl.DateTimeFormat("en-GB", { + timeZone: EAT_TZ, + hour: "2-digit", + minute: "2-digit", + hour12: false, +}); -/** EAT calendar date key for a window — prefers the API field, falls back to `start`. */ -function windowDateKey(w: BatchWindowGroup): string { - if (w.date) return w.date; - if (w.start) return dateKeyFmt.format(new Date(w.start)); - return "undated"; +interface ScheduleWindow { + windowPhase: BatchBoardScheduleDetail["windowPhase"]; + bookingWindowStatus: string; + windowOpensAt: string | null; + windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; + bookingCycleNo?: number; } -/** Human day label for a window — prefers the API field, falls back to `start`. */ -function windowDateLabel(w: BatchWindowGroup): string { - if (w.dateLabel) return w.dateLabel; - if (w.start) return dateLabelFmt.format(new Date(w.start)); - return "Undated"; +/** + * Deadline + label for the phase the schedule's booking window is currently in — + * the SAME phases the customer sees on the portal: pre-window (opens) → open + * (closes) → document review → payment. `expiredText` names the next step so a + * lapsed deadline reads as a handover, not a bare "Expired". + */ +function windowPhaseCountdown( + w: ScheduleWindow, +): { label: string; deadline: string; expiredText: string } | null { + switch (w.windowPhase) { + case "PRE_WINDOW": + return w.windowOpensAt + ? { label: "Booking opens in", deadline: w.windowOpensAt, expiredText: "Booking opening now…" } + : null; + case "OPEN": + return w.windowClosesAt + ? { label: "Window closes in", deadline: w.windowClosesAt, expiredText: "Document review starting…" } + : null; + case "DOC_REVIEW": + return w.docReviewEndsAt + ? { label: "Document review ends in", deadline: w.docReviewEndsAt, expiredText: "Payment starting…" } + : null; + case "PAYMENT": + return w.paymentPhaseEndsAt + ? { label: "Payment window ends in", deadline: w.paymentPhaseEndsAt, expiredText: "Payment window closing…" } + : null; + default: + return null; + } } -function WindowAccordionItem({ window }: { window: BatchWindowGroup }) { - const total = window.bookings.length; - const hasIssues = window.bookings.some( - (b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED", +/** One phase row: label + its clock time (or "—" when unset). */ +function PhaseTimeRow({ + label, + iso, + active, +}: { + label: string; + iso: string | null; + active: boolean; +}) { + return ( + + + {label} + + + {iso ? `${timeFmt.format(new Date(iso))} EAT` : "—"} + + ); +} + +/** + * The schedule's REAL booking window — the exact same window the customer sees on + * the portal (frozen open/close from the schedule's own snapshot + the post-close + * document-review and payment phases), with a live countdown to the current phase. + * Replaces the old theoretical "3-hour windows across every day" projection. + */ +function ScheduleWindowPanel({ window: w }: { window: ScheduleWindow }) { + const phase = w.windowPhase; + const cd = windowPhaseCountdown(w); + const open = phase === "OPEN" && w.bookingWindowStatus === "OPEN"; + + const openDay = w.windowOpensAt + ? dateLabelFmt.format(new Date(w.windowOpensAt)) + : null; return ( - - - - - - - - - - {timeLabelOf(window.label)} - - - {total - ? `${total} booking${total === 1 ? "" : "s"}` - : "Empty window"} - - - - - {hasIssues ? ( - } - > - Issues - - ) : null} - - + + + + {phase ? ( + + ) : null} + - - - - - + {openDay ? ( + + Booking day · {openDay} + + ) : null} + + + {cd ? ( + + + + ) : null} + + + + + + + + ); } +/** EAT calendar date key for a window — prefers the API field, falls back to `start`. */ + export default function BatchScheduleDetailPage() { const { scheduleId } = useParams<{ scheduleId: string }>(); const navigate = useNavigate(); @@ -577,6 +621,34 @@ export default function BatchScheduleDetailPage() { return [...byId.values()]; }, [data]); + // All bookings that fall inside the schedule's booking window (every window + // cycle, flattened) — the window is one booking day, so these belong to the + // single window panel above. + const windowBookings = useMemo( + () => (data?.windows ?? []).flatMap((w) => w.bookings), + [data?.windows], + ); + + const windowCounts = useMemo(() => { + const counts = { + allocated: 0, + selectedForBatch: 0, + ready: 0, + waiting: 0, + expired: 0, + pendingContract: 0, + }; + for (const b of windowBookings) { + if (b.state === "ALLOCATED") counts.allocated += 1; + else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1; + else if (b.state === "READY") counts.ready += 1; + else if (b.state === "WAITING") counts.waiting += 1; + else if (b.state === "EXPIRED") counts.expired += 1; + else counts.pendingContract += 1; + } + return counts; + }, [windowBookings]); + // Batch bookings by state for the composition side panel (payment / expired lists). const batchBookings = useMemo(() => { const all = allBookings; @@ -591,102 +663,10 @@ export default function BatchScheduleDetailPage() { [data?.status], ); - // Group the flat window list into per-day sections (one per EAT calendar date). - const dayGroups = useMemo(() => { - if (!data) return []; - const byDate = new Map< - string, - { - date: string; - dateLabel: string; - windows: BatchWindowGroup[]; - totalBookings: number; - counts: BatchWindowGroup["counts"]; - hasIssues: boolean; - } - >(); - for (const w of data.windows) { - const dateKey = windowDateKey(w); - let group = byDate.get(dateKey); - if (!group) { - group = { - date: dateKey, - dateLabel: windowDateLabel(w), - windows: [], - totalBookings: 0, - counts: { - allocated: 0, - selectedForBatch: 0, - ready: 0, - waiting: 0, - expired: 0, - pendingContract: 0, - }, - hasIssues: false, - }; - byDate.set(dateKey, group); - } - group.windows.push(w); - group.totalBookings += w.bookings.length; - group.counts.allocated += w.counts.allocated; - group.counts.selectedForBatch += w.counts.selectedForBatch; - group.counts.ready += w.counts.ready; - group.counts.waiting += w.counts.waiting; - group.counts.expired += w.counts.expired; - group.counts.pendingContract += w.counts.pendingContract; - group.hasIssues = - group.hasIssues || - w.bookings.some( - (b) => - b.allocationStatus === "FAILED" || - b.allocationStatus === "DEFERRED", - ); - } - return [...byDate.values()]; - }, [data]); - - // Windows with bookings open by default (inside an expanded day). - const openWindowKeys = useMemo( - () => - data - ? data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key) - : [], - [data], - ); - - const todayEat = useMemo( - () => - new Intl.DateTimeFormat("en-CA", { - timeZone: "Africa/Addis_Ababa", - year: "numeric", - month: "2-digit", - day: "2-digit", - }).format(new Date()), - [], - ); - - // Date-stepper: which day is currently shown. Default to today, else the first - // day with bookings, else the first day. Keep the selection if still valid. - const [selectedDate, setSelectedDate] = useState(null); const [activeTab, setActiveTab] = useState("overview"); const [selectedBookingId, setSelectedBookingId] = useState( null, ); - useEffect(() => { - if (!dayGroups.length) return; - if (selectedDate && dayGroups.some((d) => d.date === selectedDate)) return; - const preferred = - dayGroups.find((d) => d.date === todayEat) ?? - dayGroups.find((d) => d.totalBookings > 0) ?? - dayGroups[0]; - setSelectedDate(preferred.date); - }, [dayGroups, selectedDate, todayEat]); - - const selectedIndex = Math.max( - 0, - dayGroups.findIndex((d) => d.date === selectedDate), - ); - const selectedDay = dayGroups[selectedIndex]; const handleCompleteDocReview = () => { completeDocReview @@ -1012,137 +992,30 @@ export default function BatchScheduleDetailPage() { - Batch windows (EAT) + Booking window (EAT) - 3-hour windows for every day from when the booking window - opened through the departure date. Bookings appear under the - date their contract was signed — open a day to see its - windows. + The schedule's real booking window — the same window and + phase timings the customer sees on the portal. Bookings in + the window are listed below. - {dayGroups.length && selectedDay ? ( - <> - {/* Date stepper — page back/forward through each day in the range */} - - - setSelectedDate( - dayGroups[selectedIndex - 1]?.date ?? null, - ) - } - > - - + - - - - - {selectedDay.dateLabel} - - {selectedDay.date === todayEat ? ( - - Today - - ) : null} - - - {selectedDay.totalBookings - ? `${selectedDay.totalBookings} booking${selectedDay.totalBookings === 1 ? "" : "s"} · ${selectedDay.windows.length} windows` - : `${selectedDay.windows.length} windows · no bookings`} - - - - = dayGroups.length - 1} - onClick={() => - setSelectedDate( - dayGroups[selectedIndex + 1]?.date ?? null, - ) - } - > - - - - - - - Day {selectedIndex + 1} of {dayGroups.length} + {windowBookings.length ? ( + + + + Bookings in this window - - {selectedDay.hasIssues ? ( - } - > - Issues - - ) : null} - - + - - - {selectedDay.windows.map((window) => ( - - ))} - - + + ) : ( - No batch windows for this schedule. + No bookings in this window yet. )} From 4994d140029354ec44f39a266353b93d63a6d125 Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 4 Jul 2026 00:23:40 +0000 Subject: [PATCH 065/122] fix --- .../src/modules/first-mile/first-mile.controller.ts | 9 ++++++++- .../src/modules/last-mile/last-mile.controller.ts | 9 ++++++++- apps/edr-freight-api/src/seed/pricing-data.seeder.ts | 3 +++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 928882a7f..49175a6f3 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -93,7 +94,13 @@ export class FirstMileController { @ApiOperation({ summary: 'Generate the first-mile delivery-fee invoice' }) async generateInvoice(@Param('id', ParseUUIDPipe) id: string) { const record = await this.firstMileService.findById(id); - return this.firstMileInvoiceService.ensureInvoiceFor(record); + const invoice = await this.firstMileInvoiceService.ensureInvoiceFor(record); + if (!invoice) { + throw new BadRequestException( + 'Cannot generate invoice: the leg has no billable amount. Add distance and ensure a FIRST_MILE rate is configured, and the booking has a company.', + ); + } + return invoice; } @Delete(':id') diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 0b2ec1dbe..2931a1f85 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -118,6 +119,12 @@ export class LastMileController { @ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' }) async generateInvoice(@Param('id', ParseUUIDPipe) id: string) { const record = await this.lastMileService.findById(id); - return this.lastMileInvoiceService.ensureInvoiceFor(record); + const invoice = await this.lastMileInvoiceService.ensureInvoiceFor(record); + if (!invoice) { + throw new BadRequestException( + 'Cannot generate invoice: the leg has no billable amount. Add distance and ensure a LAST_MILE rate is configured, and the booking has a company.', + ); + } + return invoice; } } diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 14ccb3efb..aa3f2c9bb 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -415,6 +415,9 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { { appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 2, rateUnit: "PER_TON" }, { appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" }, + // ── First/last-mile road haulage (per km) — drives the mile invoices ── + { appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" }, + { appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" }, ]; // Idempotent: insert each canonical rate only if no row with the same From 6bf737171667dafff7b0f94f7018ee6e0f5cd596 Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 4 Jul 2026 00:37:36 +0000 Subject: [PATCH 066/122] fix --- .../first-mile/first-mile-invoice.service.ts | 3 ++- .../last-mile/last-mile-invoice.service.ts | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts index f7d9ee11f..f4935a87b 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts @@ -57,7 +57,8 @@ export class FirstMileInvoiceService { return null; } - const totalAmount = record.remainingPayment || 0; + // numeric columns come back as strings — coerce before the finite/>0 check. + const totalAmount = Number(record.remainingPayment) || 0; if (!Number.isFinite(totalAmount) || totalAmount <= 0) { this.logger.warn( `Skipping invoice for first-mile record ${record.id}: no remaining payment.`, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts index e40e94509..7b14f6887 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts @@ -54,6 +54,15 @@ export class LastMileInvoiceService { return null; } + // numeric columns come back as strings — coerce before billing. + const totalAmount = Number(record.remainingPayment) || 0; + if (!Number.isFinite(totalAmount) || totalAmount <= 0) { + this.logger.warn( + `Skipping invoice for last-mile record ${record.id}: no remaining payment.`, + ); + return null; + } + // Generate invoice with remainingPayment as totalAmount const input: GenerateInvoiceInput = { source: 'last_mile' as Freight.InvoiceSource, @@ -67,11 +76,11 @@ export class LastMileInvoiceService { chargeType: 'DELIVERY', description: 'Last-mile delivery', quantity: 1, - unitRate: record.remainingPayment || 0, - amount: record.remainingPayment || 0, + unitRate: totalAmount, + amount: totalAmount, }, ], - totalAmount: record.remainingPayment || 0, + totalAmount, }; return this.billing.generateInvoice(input); From 97cc9d76b141c58e3bd4cfb5716535a1e05fbb8f Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 4 Jul 2026 00:41:28 +0000 Subject: [PATCH 067/122] enhance booking windows section with pagination and improved UI --- .../bookings/booking-pricing.service.ts | 19 +- .../bookings/booking-transition.service.ts | 87 ++++- .../modules/bookings/bookings.repository.ts | 22 +- .../contracts/contract-booking.service.ts | 120 +++--- .../modules/contracts/contracts.controller.ts | 2 +- .../train-scheduling.controller.ts | 10 + .../train-scheduling.service.ts | 44 +++ .../contracts/GlCreateBookingForm.tsx | 135 ++++++- .../contracts/GlUpcomingWindowsSection.tsx | 352 +++++++++++------- .../backoffice/src/constants/URLS.ts | 2 + .../features/bookings/mapBookingListRow.ts | 1 + .../src/hooks/bookings/useBookings.ts | 8 +- .../pages/bookings/BookingRequestsPage.tsx | 21 +- .../backoffice/src/services/api.ts | 8 + .../src/services/contracts.service.ts | 45 +++ .../src/services/trainScheduling.service.ts | 8 + .../backoffice/src/types/booking.ts | 4 + .../backoffice/src/types/trainScheduling.ts | 21 ++ .../components/UpcomingWindowsSection.tsx | 106 ++++-- .../src/pages/contracts/NewShipmentPage.tsx | 50 ++- .../portal/src/services/contracts.service.ts | 25 +- 21 files changed, 805 insertions(+), 285 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index e8469e627..d63106c2b 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -431,7 +431,7 @@ export class BookingPricingService { const lines: PriceLineItemDto[] = []; const usedRatesMap = new Map(); - const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id); + const wagonCount = await this.resolveWagonCount(booking); for (const container of evalInput.containers) { const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD'); @@ -571,6 +571,23 @@ export class BookingPricingService { return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; } + /** + * Wagon count for PER_WAGON rates. A persisted booking uses the SQL aggregate; + * an unsaved preview booking (no id) sums the wagonsRequired already computed + * on its in-memory container lines — same math, no DB row needed. + */ + private async resolveWagonCount(booking: Booking): Promise { + if (!booking.id) { + return Math.ceil( + (booking.bookingContainers ?? []).reduce( + (sum, bc) => sum + Number(bc.wagonsRequired ?? 0), + 0, + ), + ); + } + return this.bookingsRepository.calculateWagonCount(booking.id); + } + /** Friendly container-type label for the per-unit card; degrades to "Container". */ private async containerTypeLabel(containerTypeId: string): Promise { try { diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 8423e9606..35fc7fd54 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -1051,7 +1051,22 @@ export class BookingTransitionService { // paid → auto-allocated by the settle/paid pipeline. Consolidated bookings // only reserve once both partners are FULLY_EXECUTED (handled inside). const fresh = await this.bookingsService.findById(booking.id); - await this.bookingBatchService.acceptExportBooking(fresh); + try { + await this.bookingBatchService.acceptExportBooking(fresh); + } catch (err) { + // The status update above already committed. Without compensation the + // client gets an error for a booking that reads as accepted after a + // refresh — half-applied state. Put the request back so staff can retry. + await this.bookingsRepository.update(booking.id, { + status: "OPERATION_REQUEST_PENDING", + fullyExecutedAt: null, + lockedAt: booking.lockedAt ?? null, + } as never); + this.logger.warn( + `Export accept failed post-commit for ${booking.reference}:${booking.id}; reverted to OPERATION_REQUEST_PENDING: ${(err as Error).message}`, + ); + throw err; + } } // IMPORT and DOMESTIC bookings wait for their booking-day window cycle — the // batch runs after the window closes + staff document review, never at accept @@ -1073,23 +1088,59 @@ export class BookingTransitionService { } | null; } > { - const note = await this.bookingsRepository.findLatestReviewNote( - booking.id, - "CHANGES_REQUESTED", - ); - const summary = - booking.contractSummary ?? - this.contractService.buildContractSummary(booking); - const nextPending = - booking.status === "PENDING_APPROVAL" || - booking.status === "APPROVED_PENDING_SIGNATURE" - ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) - : null; - const nextStep = computeNextStep(booking, nextPending); - const activeBatchOffer = - booking.status === "SELECTED_FOR_BATCH" - ? await this.bookingBatchService.getOpenOfferSummary(booking.id) - : null; + // This enrichment runs AFTER the transition has committed. A failure here + // must never 500 the response — the client would report "failed" for a + // transition that actually succeeded (visible only after a refresh). + // Degrade each fragile field to null instead. + let note: Awaited< + ReturnType + > = null; + try { + note = await this.bookingsRepository.findLatestReviewNote( + booking.id, + "CHANGES_REQUESTED", + ); + } catch (err) { + this.logger.warn( + `enrichBookingResponse: review-note lookup failed for ${booking.id}: ${(err as Error).message}`, + ); + } + let summary: string | null = booking.contractSummary ?? null; + try { + summary = + booking.contractSummary ?? + this.contractService.buildContractSummary(booking); + } catch (err) { + this.logger.warn( + `enrichBookingResponse: contract summary failed for ${booking.id}: ${(err as Error).message}`, + ); + } + let nextStep: BookingNextStep | null = null; + try { + const nextPending = + booking.status === "PENDING_APPROVAL" || + booking.status === "APPROVED_PENDING_SIGNATURE" + ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) + : null; + nextStep = computeNextStep(booking, nextPending); + } catch (err) { + this.logger.warn( + `enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`, + ); + } + let activeBatchOffer: Awaited< + ReturnType + > = null; + try { + activeBatchOffer = + booking.status === "SELECTED_FOR_BATCH" + ? await this.bookingBatchService.getOpenOfferSummary(booking.id) + : null; + } catch (err) { + this.logger.warn( + `enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`, + ); + } return { ...booking, latestChangeRequestNote: note?.note ?? null, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 913565f70..25cf4f875 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -587,6 +587,10 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.serviceType', 'serviceType') .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner') + // Contract reference for the list column + search (no entity relation on + // Booking → contract, so join by id and select just the reference). + .leftJoin('freight.contracts', 'contract', 'contract.id = booking.contract_id') + .addSelect('contract.reference', 'contract_reference') .where('booking.deleted_at IS NULL'); this.applyListFilters(qb, options); @@ -605,10 +609,24 @@ export class BookingsRepository extends BaseRepository { qb.orderBy(sortField, options.sortOrder ?? 'DESC'); } - const [items, total] = await qb + const total = await qb.getCount(); + const { entities: items, raw } = await qb .skip((page - 1) * pageSize) .take(pageSize) - .getManyAndCount(); + .getRawAndEntities(); + + // The joined contract.reference comes back on the raw rows only (entity has no + // contract relation) — map it onto each booking by position. + const contractRefByBooking = new Map(); + for (const row of raw as Array<{ booking_id: string; contract_reference: string | null }>) { + if (row.booking_id && !contractRefByBooking.has(row.booking_id)) { + contractRefByBooking.set(row.booking_id, row.contract_reference ?? null); + } + } + for (const item of items) { + (item as Booking & { contractReference?: string | null }).contractReference = + contractRefByBooking.get(item.id) ?? null; + } if (items.length) { const links = await this.dataSource.getRepository(TrainScheduleBooking).find({ diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 01b35fc71..3ea19f778 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -8,13 +8,13 @@ import { forwardRef, } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { ExchangeService } from '@edr/api-common'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; +import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto'; import { BookingInvoiceService } from '../bookings/booking-invoice.service'; import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; @@ -66,7 +66,6 @@ export class ContractBookingService { private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, private readonly dataSource: DataSource, - private readonly exchangeService: ExchangeService, @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, ) {} @@ -587,11 +586,14 @@ export class ContractBookingService { } /** - * Pre-create validation for the shipment form: run the overweight rule + the - * 20ft weight-pairing rule against the entered containers WITHOUT persisting a - * booking. The portal calls this from the price-confirm modal so the customer - * sees the overweight warning (+ surcharge basis) and is blocked on an - * un-pairable 20ft set before the booking is created. + * Pre-create validation + authoritative price preview for the shipment form: + * build an UNSAVED booking shaped exactly like {@link createUnderContract} + * would persist it and run the same BookingPricingService compute over it — + * base rail freight, first/last-mile trucking, and every rule-engine surcharge + * (overweight, hazard, reefer, consolidation, …). The portal and the GL + * backoffice form call this from the price-confirm modal, so the breakdown the + * user confirms is line-for-line what the booking will be charged. Also runs + * the 20ft weight-pairing rule, which hard-blocks creation. */ async validateShipment( contractId: string, @@ -606,22 +608,26 @@ export class ContractBookingService { overweightSurchargeAmount: number; currency: string | null; pairingErrors: string[]; + lineItems: PriceLineItemDto[]; + totalAmount: number; }> { const contract = await this.contractsRepository.findByIdWithRelations(contractId); if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); const lines = dto.containers ?? []; - if (!lines.length) { + if (contract.freightType === 'CONTAINER' && !lines.length) { return { overweightLines: [], overweightSurchargeAmount: 0, currency: null, pairingErrors: [], + lineItems: [], + totalAmount: 0, }; } - // Resolve each line's container type + total VGM (sum of unit weights) so the - // rule engine can flag overweight per line (maxVgmTons × quantity vs total). + // Resolve each container line's type + total VGM (sum of unit weights) — + // mirrors persistContainers so the preview lines match the persisted ones. const resolved = await Promise.all( lines.map(async (line) => { const ct = await this.resolveContainerTypeForSize( @@ -636,46 +642,44 @@ export class ContractBookingService { }), ); - const ruleResult = await this.ruleEngineService.evaluate({ - freightType: 'CONTAINER', - cargoTypeId: null, - serviceTypeId: contract.serviceTypeId, - paymentCurrency: contract.paymentCurrency, + // The unsaved twin of the booking createUnderContract would write: same + // denormalized contract fields, same container-line math. No id → the + // pricing service derives wagon counts from the in-memory lines. + const route = await this.resolveRoute(contract, dto.contractRouteId); + const previewBooking = Object.assign(new Booking(), { + freightType: contract.freightType, tradeDirection: contract.tradeDirection, - isHazardous: false, - isReefer: contract.isReefer ?? false, - isGovernment: false, - allowConsolidation: false, + paymentCurrency: contract.paymentCurrency, + serviceTypeId: contract.serviceTypeId, + cargoTypeId: this.resolveCargoTypeId(contract, dto), + isHazardous: contract.isHazardous, + isReefer: contract.isReefer, + isGovernment: contract.isGovernment, shippingLineId: null, - totalWagons: 0, - bulkTons: 0, - containers: resolved.map((r) => ({ - containerTypeId: r.ct.id, - quantity: r.line.quantity, - vgmPerUnitTons: r.line.quantity ? r.totalVgmTons / r.line.quantity : 0, - totalVgmTons: r.totalVgmTons, - isReefer: r.ct.isReefer, - })), - } as never); + contractRouteId: route?.id ?? null, + cargoTotalWeightVgm: this.resolveBulkTons(dto), + firstMilePickupAddress: contract.firstMilePickupAddress ?? null, + lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, + bookingContainers: resolved.map(({ line, ct, totalVgmTons }) => + Object.assign(new BookingContainer(), { + containerTypeId: ct.id, + containerSize: line.containerSize, + quantity: line.quantity, + hazardousQuantity: line.hazardousQuantity ?? 0, + reeferQuantity: line.reeferQuantity ?? 0, + vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0, + totalVgmTons, + wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)), + }), + ), + }) as Booking; - const overweightLines: Array<{ - containerTypeCode: string; - totalVgmTons: number; - maxAllowedTons: number; - excessTons: number; - }> = []; - for (let i = 0; i < ruleResult.containerWeightResults.length; i++) { - const wr = ruleResult.containerWeightResults[i]; - if (!wr?.isOverweight) continue; - const r = resolved[i]; - const excessTons = Number(wr.overweightExcessTons ?? 0); - overweightLines.push({ - containerTypeCode: r?.ct.code ?? r?.line.containerSize ?? '', - totalVgmTons: r?.totalVgmTons ?? 0, - maxAllowedTons: Math.max(0, (r?.totalVgmTons ?? 0) - excessTons), - excessTons, - }); - } + const computed = await this.bookingPricingService.computePriceForBooking(previewBooking); + + // The overweight surcharge line is already currency-converted; surface its + // amount separately so the warning alert can reference the exact charge. + const overweightSurchargeAmount = + computed.lineItems.find((li) => li.code === 'OVERWEIGHT_PER_TON')?.amount ?? 0; // 20ft weight-pairing: gather every 20ft unit weight and check the pair rule. const twentyFtUnits = resolved @@ -691,27 +695,13 @@ export class ContractBookingService { (v) => v.message, ); - // Real overweight surcharge (same rate the rule engine bills at booking-create - // time) so the confirm-modal total isn't missing the charge the warning refers to. - // Rates are stored in USD; convert to the contract's payment currency the same - // way BookingPricingService does so this preview matches the eventual booking total. - const overweightModifier = ruleResult.appliedModifiers.find( - (m) => m.surchargeCode === 'OVERWEIGHT_PER_TON', - ); - let overweightSurchargeAmount = 0; - if (overweightModifier) { - const isEtb = contract.paymentCurrency === 'ETB'; - const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; - overweightSurchargeAmount = isEtb - ? Math.round(overweightModifier.calculatedAmount * usdToEtb) - : overweightModifier.calculatedAmount; - } - return { - overweightLines, + overweightLines: computed.overweightLines, overweightSurchargeAmount, - currency: overweightLines.length ? contract.paymentCurrency : null, + currency: computed.currency, pairingErrors, + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, }; } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 09e4a7ffd..06ac31d68 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -791,7 +791,7 @@ export class ContractsController { @Post(':id/validate-shipment') @ApiOperation({ summary: - 'Pre-create validation: overweight lines + 20ft weight-pairing errors for a shipment payload (no booking created).', + 'Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created).', }) validateShipment( @Param('id', ParseUUIDPipe) id: string, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 22e322953..773e4738a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -83,6 +83,16 @@ export class TrainSchedulingController { return this.trainSchedulingService.getBookingWindowsForContract(contractId); } + @Get("booking-windows") + @TrainSchedulingView() + @ApiOperation({ + summary: + "All announced booking windows across lanes (import cycle + export FCFS), for staff dashboards", + }) + listBookingWindows() { + return this.trainSchedulingService.listAllBookingWindows(); + } + @Get("global-rules") @TrainSchedulingView() @ApiOperation({ summary: "Get global train scheduling rules (singleton)" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index bac226c0a..93846d31d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -3220,6 +3220,50 @@ export class TrainSchedulingService { return rows.map((r) => this.mapBookingWindowRow(r)); } + /** + * All announced booking windows across every lane — import window cycles AND + * export FCFS lead windows — for staff dashboards (GL clearance queue). Same + * phase filter as the customer-facing lists, no contract scoping. + */ + async listAllBookingWindows() { + const rows: Array< + Omit & { + train_number: string | null; + } + > = await this.dataSource.query( + `SELECT ts.id AS schedule_id, + ts.train_number, + ts.direction, + ts.window_phase, + ts.window_opens_at, + ts.window_closes_at, + ts.doc_review_ends_at, + ts.payment_phase_ends_at, + ts.booking_window_status, + ts.booking_cycle_no, + ts.scheduled_departure_date, + oy.label AS origin_label, oy.code AS origin_code, + dy.label AS destination_label, dy.code AS destination_code + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED') + AND ts.window_phase IS NOT NULL + AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') + AND ts.scheduled_departure_date >= now() + ORDER BY ts.window_opens_at ASC NULLS LAST`, + ); + return rows.map((r) => ({ + ...this.mapBookingWindowRow({ + ...r, + contract_id: null, + contract_kind: null, + }), + trainNumber: r.train_number, + })); + } + private mapBookingWindowRow(r: BookingWindowRow) { return { scheduleId: r.schedule_id, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index ef9b2453f..d437d2904 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -4,7 +4,7 @@ import { useParams, useSearchParams, } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { Alert, Box, @@ -25,6 +25,7 @@ import { } from "@mantine/core"; import { AlertCircle, + AlertTriangle, CalendarDays, CheckCircle2, ChevronLeft, @@ -365,8 +366,11 @@ export default function GlCreateBookingForm() { (!needsRouteSelect || Boolean(contractRouteId)) && (isContainer ? containerLines.some((l) => l.units.length > 0) : bulkLines.length > 0); - const handleSubmit = () => { - if (!scheduledDate || !contract || !windowOpen) return; + /** The create-booking DTO from the current form state — shared by the + * authoritative price preview and the actual submit so what GL confirms is + * exactly what gets booked. */ + const buildPayload = (): Freight.CreateBookingUnderContractDto | null => { + if (!scheduledDate || !contract) return null; const payload: Freight.CreateBookingUnderContractDto = { scheduledDate, @@ -408,6 +412,56 @@ export default function GlCreateBookingForm() { })); } + return payload; + }; + + // Authoritative price preview (same pricing pass the booking persists at + // create): rail freight + first/last mile + overweight + every surcharge. + // Fired when the price modal opens; the modal falls back to the contract + // unit-rate estimate while it loads. + const validateShipmentMutation = useMutation({ + mutationFn: (dto: Freight.CreateBookingUnderContractDto) => + contractsService.validateShipment(id ?? "", dto), + }); + const validation = validateShipmentMutation.data ?? null; + + const serverTotal = useMemo(() => { + const items = validation?.lineItems; + if (!items?.length) return null; + return { + currency: validation?.currency ?? priceTotal?.currency ?? "ETB", + lines: items.map((li) => ({ + label: li.description, + unitPrice: li.unitAmount, + unit: li.unit.toLowerCase(), + quantity: li.quantity, + amount: li.amount, + })), + total: + validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), + }; + }, [validation, priceTotal]); + + const displayTotal = serverTotal ?? priceTotal; + const pairingErrors = validation?.pairingErrors ?? []; + const overweightLines = validation?.overweightLines ?? []; + + const openPriceModal = () => { + setPriceOpen(true); + const payload = buildPayload(); + if (payload) { + validateShipmentMutation.reset(); + validateShipmentMutation.mutate(payload); + } + }; + + const handleSubmit = () => { + if (!contract || !windowOpen) return; + // Never book past unresolved 20ft pairing hard-blocks. + if (pairingErrors.length > 0) return; + const payload = buildPayload(); + if (!payload) return; + mutations.createBooking.mutate(payload, { onSuccess: async (booking) => { if (requestId) { @@ -819,7 +873,7 @@ export default function GlCreateBookingForm() { radius="md" leftSection={} disabled={!canSubmit} - onClick={() => setPriceOpen(true)} + onClick={openPriceModal} > Review price & book @@ -850,11 +904,67 @@ export default function GlCreateBookingForm() { } > - {priceTotal ? ( + {displayTotal ? ( + {validateShipmentMutation.isPending && ( + + + + Computing the final price breakdown and checking container + weights… + + + )} + + {pairingErrors.length > 0 && ( + } + title="Cannot create booking — 20ft wagon pairing" + > + + {pairingErrors.map((msg, i) => ( + + {msg} + + ))} + + Adjust the 20ft container weights or quantities so pairs + differ by no more than 10 tons. + + + + )} + + {overweightLines.length > 0 && ( + } + title="Overweight containers" + > + + {overweightLines.map((line, i) => ( + + {line.containerTypeCode}: {line.totalVgmTons}t exceeds + limit {line.maxAllowedTons}t (+{line.excessTons}t + overweight) + + ))} + + An overweight surcharge applies (included in the total + below). + + + + )} + - {priceTotal.lines.map((line, i) => ( + {displayTotal.lines.map((line, i) => ( @@ -862,16 +972,16 @@ export default function GlCreateBookingForm() { {line.quantity.toLocaleString()} ×{" "} - {line.unitPrice.toLocaleString()} {priceTotal.currency} ·{" "} + {line.unitPrice.toLocaleString()} {displayTotal.currency} ·{" "} {formatRateUnit(line.unit)} - {line.amount.toLocaleString()} {priceTotal.currency} + {line.amount.toLocaleString()} {displayTotal.currency} ))} - {priceTotal.lines.length === 0 && ( + {displayTotal.lines.length === 0 && ( No priced lines — check the cargo details. @@ -889,9 +999,9 @@ export default function GlCreateBookingForm() { Total - {priceTotal.total.toLocaleString()}{" "} + {displayTotal.total.toLocaleString()}{" "} - {priceTotal.currency} + {displayTotal.currency} @@ -912,6 +1022,9 @@ export default function GlCreateBookingForm() { radius="md" leftSection={} loading={mutations.createBooking.isPending} + disabled={ + validateShipmentMutation.isPending || pairingErrors.length > 0 + } onClick={handleSubmit} > Confirm & book diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx index 9d9573909..e5e998721 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx @@ -1,14 +1,31 @@ -import { useMemo } from "react"; -import { Badge, Box, Card, Group, ScrollArea, Skeleton, Stack, Text } from "@mantine/core"; +import { useMemo, useState } from "react"; +import { + ActionIcon, + Badge, + Box, + Card, + Group, + SimpleGrid, + Skeleton, + Stack, + Text, +} from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; -import { ArrowRight, CalendarClock } from "lucide-react"; +import { + ArrowRight, + CalendarClock, + ChevronLeft, + ChevronRight, +} from "lucide-react"; import { CountdownTimer } from "@edr/ui-common"; import { api } from "@/services/api"; -import type { BatchBoardSchedule } from "@/types/trainScheduling"; +import type { StaffBookingWindow } from "@/types/trainScheduling"; /** All window times are communicated in East Africa Time. */ const TZ = "Africa/Addis_Ababa"; +/** Cards visible per carousel page. */ +const PER_PAGE = 3; function fmtDay(iso: string): string { return new Date(iso).toLocaleDateString("en-GB", { @@ -28,7 +45,7 @@ function fmtTime(iso: string): string { }); } -function windowLabel(w: BatchBoardSchedule): string { +function windowLabel(w: StaffBookingWindow): string { if (w.windowOpensAt && w.windowClosesAt) { return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} – ${fmtTime( w.windowClosesAt, @@ -42,36 +59,33 @@ function windowLabel(w: BatchBoardSchedule): string { /** * The countdown for whichever phase the window is currently in, mirroring the - * customer portal. Phases run pre-window (opens at windowOpensAt) → open (closes - * at windowClosesAt) → document review (docReviewEndsAt) → payment - * (paymentPhaseEndsAt). `expiredText` names the NEXT step so a deadline that - * lapses between the 60s refetches announces what comes next rather than the - * bare word "Expired". Returns null when no phase is timing down. + * customer portal. `expiredText` names the NEXT step so a deadline that lapses + * between refetches announces what comes next rather than the bare "Expired". */ function phaseCountdown( - w: BatchBoardSchedule, + w: StaffBookingWindow, ): { label: string; deadline: string; expiredText: string } | null { switch (w.windowPhase) { case "PRE_WINDOW": return w.windowOpensAt ? { - label: "Booking opens in", + label: "Opens in", deadline: w.windowOpensAt, - expiredText: "Booking opening now…", + expiredText: "Opening now…", } : null; case "OPEN": return w.windowClosesAt ? { - label: "Window closes in", + label: "Closes in", deadline: w.windowClosesAt, - expiredText: "Document review starting…", + expiredText: "Review starting…", } : null; case "DOC_REVIEW": return w.docReviewEndsAt ? { - label: "Document review ends in", + label: "Doc review ends in", deadline: w.docReviewEndsAt, expiredText: "Payment starting…", } @@ -79,9 +93,9 @@ function phaseCountdown( case "PAYMENT": return w.paymentPhaseEndsAt ? { - label: "Payment window ends in", + label: "Payment ends in", deadline: w.paymentPhaseEndsAt, - expiredText: "Payment window closing…", + expiredText: "Closing…", } : null; default: @@ -89,15 +103,11 @@ function phaseCountdown( } } -function isOpenNow(w: BatchBoardSchedule): boolean { - return w.windowPhase === "OPEN" && w.bookingWindowStatus === "OPEN"; -} - /** Drop windows whose booking window (or the train itself) has already passed. */ -function isPast(w: BatchBoardSchedule): boolean { +function isPast(w: StaffBookingWindow): boolean { const now = Date.now(); const closes = w.windowClosesAt ? new Date(w.windowClosesAt).getTime() : null; - const departs = w.scheduleDate ? new Date(w.scheduleDate).getTime() : null; + const departs = w.departureDate ? new Date(w.departureDate).getTime() : null; // Still live while in a post-close staff phase (doc review / payment). if (w.windowPhase === "DOC_REVIEW" || w.windowPhase === "PAYMENT") return false; if (departs != null && departs <= now) return true; @@ -105,16 +115,121 @@ function isPast(w: BatchBoardSchedule): boolean { return false; } +function WindowCard({ w }: { w: StaffBookingWindow }) { + const cd = phaseCountdown(w); + const open = w.isOpenNow; + const isImport = w.direction === "IMPORT"; + + return ( + + + + + {w.direction ? ( + + {isImport ? "Import" : "Export"} + + ) : ( + + )} + + {open + ? "Open now" + : (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")} + + + + + + {w.origin ?? "—"} + + + + {w.destination ?? "—"} + + + {w.trainNumber ? ( + + Train {w.trainNumber} + + ) : null} + + + + + {windowLabel(w)} + + + {w.departureDate ? ( + + Departs {fmtDay(w.departureDate)} + + ) : null} + + + {cd ? ( + + + + ) : null} + + + ); +} + /** - * Upcoming / open import booking windows across all train schedules, shown to GL - * ET on the clearance queue so they can see which lanes are accepting bookings - * (mirrors the customer's portal "Booking Windows" card). Hidden when nothing is - * pending. Windows already past close/departure are dropped. + * All announced booking windows (import cycles + export FCFS) across every lane, + * shown to GL ET on the clearance queue as a paged carousel — three lanes per + * page, arrows to flip. Mirrors the customer's portal "Booking Windows" card. + * Hidden when nothing is pending. */ export function GlUpcomingWindowsSection() { const { data, isLoading } = useQuery( - api.trainScheduling.batchBoard.queryOptions({ refetchInterval: 60_000 }), + api.trainScheduling.allBookingWindows.queryOptions({ + refetchInterval: 60_000, + }), ); + const [page, setPage] = useState(0); const windows = useMemo(() => { const rows = (data ?? []).filter( @@ -122,7 +237,7 @@ export function GlUpcomingWindowsSection() { ); // Open lanes first, then by opening time. return rows.sort((a, b) => { - const openDiff = Number(isOpenNow(b)) - Number(isOpenNow(a)); + const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow); if (openDiff !== 0) return openDiff; const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity; const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity; @@ -130,120 +245,87 @@ export function GlUpcomingWindowsSection() { }); }, [data]); + const pageCount = Math.max(1, Math.ceil(windows.length / PER_PAGE)); + const safePage = Math.min(page, pageCount - 1); + const visible = windows.slice( + safePage * PER_PAGE, + safePage * PER_PAGE + PER_PAGE, + ); + if (!isLoading && windows.length === 0) return null; return ( - - - - - Booking windows - - - Upcoming and open import booking windows across all lanes (EAT) - - + + + + + + Booking windows + + + Import and export booking windows across all lanes (EAT) + + + + + {pageCount > 1 ? ( + + setPage((p) => Math.max(0, p - 1))} + > + + + + {Array.from({ length: pageCount }, (_, i) => ( + setPage(i)} + style={{ + width: i === safePage ? 18 : 7, + height: 7, + borderRadius: 999, + cursor: "pointer", + background: + i === safePage + ? "var(--mantine-color-edr-green-6)" + : "var(--mantine-color-gray-3)", + transition: "width 200ms ease, background 200ms ease", + }} + /> + ))} + + = pageCount - 1} + onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))} + > + + + + ) : null} {isLoading ? ( - - {[1, 2].map((i) => ( - + + {[1, 2, 3].map((i) => ( + ))} - + ) : ( - - - {windows.map((w) => { - const open = isOpenNow(w); - const cd = phaseCountdown(w); - return ( - - - - - {w.origin ?? "—"} - - - - {w.destination ?? "—"} - - {w.trainNumber ? ( - - · {w.trainNumber} - - ) : null} - - - {windowLabel(w)} - {w.scheduleDate ? ` · Departs ${fmtDay(w.scheduleDate)}` : ""} - - {cd ? ( - - - - ) : null} - - - - {w.direction ? ( - - {w.direction === "IMPORT" ? "Import" : "Export"} - - ) : null} - - {open - ? "Open now" - : w.windowPhase === "PRE_WINDOW" && w.windowOpensAt - ? `Opens ${fmtTime(w.windowOpensAt)} EAT` - : (w.windowPhase ?? w.bookingWindowStatus).replace( - /_/g, - " ", - )} - - - - ); - })} - - + + {visible.map((w) => ( + + ))} + )} ); diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 351c95e7e..d0f09f508 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -201,6 +201,7 @@ export const URL_CONSTANTS = { CLEARANCE_HISTORY: "/contracts/clearance/history", OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history", BOOKINGS: (id: string) => `/contracts/${id}/bookings`, + VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`, CAPACITY: (id: string) => `/contracts/${id}/capacity`, // Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking. BOOKING_REQUEST_QUEUE: "/contracts/booking-requests/queue", @@ -295,6 +296,7 @@ export const URL_CONSTANTS = { MOVE_BOOKING_SCHEDULE: (bookingId: string) => `/train-scheduling/bookings/${bookingId}/move-schedule`, GLOBAL_RULES: "/train-scheduling/global-rules", + BOOKING_WINDOWS: "/train-scheduling/booking-windows", PREVIEW: "/train-scheduling/preview", ASSIGN_BOOKINGS: (id: string) => `/train-scheduling/schedules/${id}/assign-bookings`, diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts index ec4708868..a5bc34170 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts @@ -18,6 +18,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow { return { id: booking.id, reference: booking.reference, + contractReference: booking.contractReference ?? null, approvalSteps: booking.approvalSteps, customerLabel: booking.isGovernment ? (booking.governmentInstitution ?? "Government") diff --git a/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts index 8ea6f6f27..1dc2b13ef 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts @@ -78,7 +78,13 @@ export function useBookingMutations(bookingId: string) { note?: string; }) => api.bookings.reviewOperation.call({ id: bookingId, ...payload }), onSuccess: (data) => onSuccess(data, "Operation request reviewed"), - onError: () => toast.error("Failed to review operation request"), + onError: (error) => { + toast.error(parseApiError(error, "Failed to review operation request")); + // The transition may have committed even when the response errored (e.g. + // a post-accept step failed). Refetch so the UI shows the true state + // instead of requiring a manual refresh. + void invalidateBookingDetail(qc, bookingId); + }, }); const approveStep = useMutation({ diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index e45d64c50..477900ee1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -149,7 +149,8 @@ export default function BookingRequestsPage() { return items.filter( (b) => b.reference.toLowerCase().includes(q) || - b.customerLabel.toLowerCase().includes(q), + b.customerLabel.toLowerCase().includes(q) || + (b.contractReference?.toLowerCase().includes(q) ?? false), ); }, [data?.items, query]); @@ -196,6 +197,22 @@ export default function BookingRequestsPage() { ); }, }, + { + id: "contract", + header: () => Contract, + cell: ({ row }) => { + const ref = row.original.contractReference; + return ( +
+ {ref ? ( + {ref} + ) : ( + + )} +
+ ); + }, + }, { id: "route", header: () => Route, @@ -373,7 +390,7 @@ export default function BookingRequestsPage() { } value={query} onChange={(e) => setQuery(e.target.value)} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 91685ef8a..7c6746ec7 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -54,6 +54,7 @@ import type { LocomotiveRecord, PinWagonsPayload, RecordCheckpointPayload, + StaffBookingWindow, TrainScheduleDetail, TrainScheduleFilters, TrainScheduleListItem, @@ -223,6 +224,13 @@ export const api = { () => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(), ), + allBookingWindows: endpoint( + "train-scheduling", + "all-booking-windows", + () => trainSchedulingService.getAllBookingWindows(), + () => ["train-scheduling", "all-booking-windows"], + ), + batchBoardDetail: endpoint< { scheduleId: string }, BatchBoardScheduleDetail diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index 369e53ea8..0c28a8ad7 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -27,6 +27,39 @@ export interface PaginatedContracts { total: number; } +/** One line of the server-priced booking breakdown (mirrors PriceLineItemDto). */ +export interface ShipmentPriceLine { + code: string; + description: string; + amount: number; + unitAmount: number; + /** Rate unit as stored: PER_CONTAINER | PER_WAGON | PER_TON | PER_KM | FLAT | … */ + unit: string; + quantity: number; + currency: string; +} + +/** + * Pre-create validation + authoritative price preview for a booking under a + * contract. `lineItems`/`totalAmount` are the full server-computed breakdown — + * the same pricing pass the booking persists at create (rail freight, + * first/last mile, overweight and every other surcharge). `pairingErrors` are + * HARD BLOCKS; `overweightLines` are warnings. + */ +export interface ShipmentValidation { + overweightLines: Array<{ + containerTypeCode: string; + totalVgmTons: number; + maxAllowedTons: number; + excessTons: number; + }>; + overweightSurchargeAmount: number; + currency: string | null; + pairingErrors: string[]; + lineItems?: ShipmentPriceLine[]; + totalAmount?: number; +} + export interface ContractListSummaryMetrics { inQueue: number; needsAction: number; @@ -471,6 +504,18 @@ export const contractsService = { payload: Freight.CreateBookingUnderContractDto, ) => postContract<{ id: string; reference: string }>(C.BOOKINGS(id), payload), + /** + * Pre-create validation + authoritative price preview: the same + * BookingPricingService pass that prices the booking on create (rail + + * first/last mile + every surcharge), plus overweight warnings and 20ft + * pairing hard-blocks. Shown in the GL price-confirm modal. + */ + validateShipment: ( + id: string, + payload: Freight.CreateBookingUnderContractDto, + ) => + postContract(C.VALIDATE_SHIPMENT(id), payload), + /** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */ getCapacity: async (id: string): Promise => { const response = await client.get(C.CAPACITY(id)); diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts index 8ec6a5783..aa178d4b7 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -21,6 +21,7 @@ import type { LocomotiveRecord, PinWagonsPayload, RecordCheckpointPayload, + StaffBookingWindow, TrainScheduleDetail, TrainScheduleFilters, TrainScheduleListItem, @@ -543,6 +544,13 @@ export const trainSchedulingService = { return unwrap(response.data); }, + getAllBookingWindows: async (): Promise => { + const response = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_WINDOWS, + ); + return unwrap(response.data); + }, + updateGlobalRules: async ( payload: Partial>, ): Promise => { diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index a89e10378..1caf29726 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -191,6 +191,9 @@ export interface BookingDetail { customsClearingEnabled?: boolean; customsClearingAgent?: string | null; contractKind?: "ONE_TIME" | "GENERAL" | null; + contractId?: string | null; + /** Reference of the contract this booking was created under (list column + search). */ + contractReference?: string | null; contractSummary?: string | null; latestChangeRequestNote?: string | null; nextStep?: BookingNextStep | null; @@ -218,6 +221,7 @@ export interface BookingDetail { export interface BookingListRow { id: string; reference: string; + contractReference?: string | null; customerLabel: string; approvalSteps?: BookingApprovalStep[]; status: BookingStatus; diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 3316117f7..d629311ee 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -226,6 +226,27 @@ export interface BatchBoardBooking { state: BatchBoardBookingState; } +/** + * An announced booking window on any lane (import cycle or export FCFS), for + * staff dashboards. Mirrors the customer portal's MyBookingWindow. + */ +export interface StaffBookingWindow { + scheduleId: string; + trainNumber: string | null; + direction: "IMPORT" | "EXPORT" | null; + windowPhase: BookingWindowPhase | string | null; + isOpenNow: boolean; + windowOpensAt: string | null; + windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; + bookingWindowStatus: string; + bookingCycleNo: number; + departureDate: string; + origin: string | null; + destination: string | null; +} + export interface BatchBoardSchedule { scheduleId: string; trainNumber: string | null; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx index 300ff643f..6dcece7ef 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx @@ -1,7 +1,11 @@ -import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core"; -import { memo } from "react"; -import { useNavigate } from "react-router-dom"; -import { ArrowRight, CalendarClock, PackagePlus } from "lucide-react"; +import { ActionIcon, Box, Group, Skeleton, Stack, Text } from "@mantine/core"; +import { memo, useMemo, useState } from "react"; +import { + ArrowRight, + CalendarClock, + ChevronLeft, + ChevronRight, +} from "lucide-react"; import { CountdownTimer } from "@edr/ui-common"; import type { MyBookingWindow } from "@/services/bookings.service"; import { Card } from "./Card"; @@ -175,11 +179,32 @@ interface UpcomingWindowsSectionProps { * lane the customer has an active contract for carry a "Book now" action; * others route to the contract list. Hidden entirely when nothing is announced. */ +/** Rows shown per carousel page. */ +const PER_PAGE = 3; + export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ windows, isLoading, }: UpcomingWindowsSectionProps) { - const navigate = useNavigate(); + const [page, setPage] = useState(0); + + // Open lanes first, then by opening time — the ones the customer can act on + // lead the carousel. + const sorted = useMemo( + () => + [...windows].sort((a, b) => { + const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow); + if (openDiff !== 0) return openDiff; + const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity; + const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity; + return at - bt; + }), + [windows], + ); + + const pageCount = Math.max(1, Math.ceil(sorted.length / PER_PAGE)); + const safePage = Math.min(page, pageCount - 1); + const visible = sorted.slice(safePage * PER_PAGE, safePage * PER_PAGE + PER_PAGE); // Nothing upcoming — keep the dashboard uncluttered. if (!isLoading && windows.length === 0) return null; @@ -195,17 +220,58 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ Upcoming and open booking windows across all lanes + + {pageCount > 1 ? ( + + setPage((p) => Math.max(0, p - 1))} + > + + + + {Array.from({ length: pageCount }, (_, i) => ( + setPage(i)} + style={{ + width: i === safePage ? 18 : 7, + height: 7, + borderRadius: 999, + cursor: "pointer", + background: i === safePage ? "#0A6F4D" : "#D8E2EB", + transition: "width 200ms ease, background 200ms ease", + }} + /> + ))} + + = pageCount - 1} + onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))} + > + + + + ) : null} {isLoading ? ( - {[1, 2].map((i) => ( + {[1, 2, 3].map((i) => ( ))} ) : ( - - {windows.map((w) => ( + + {visible.map((w) => ( + {/* Windows are informational here — booking is done from the + contract page while a window is open, not via a home CTA. */} - {/* ONE_TIME contracts book via their own single-shipment flow, - not window drawdown — show the window + countdown but no - "Book now" entry. */} - {w.isOpenNow && w.contractKind !== "ONE_TIME" && ( - - )} ))} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index b2542881a..ddcff24b7 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -218,8 +218,6 @@ function NewShipmentBookingForm({ mode: "onChange", }); - const isContainerContract = contract.freightType === "CONTAINER"; - const submitMutation = useMutation({ mutationFn: (dto: Freight.CreateBookingUnderContractDto) => api.contracts.createBookingUnderContract.call({ id: contractId, dto }), @@ -287,15 +285,14 @@ function NewShipmentBookingForm({ } // Submit validates the whole form, then opens the price modal for - // confirmation. For container contracts we also run the server-side shipment - // validation (overweight warnings + 20ft pairing hard-blocks) so the modal - // can surface them before the booking is created. + // confirmation. The server-side shipment validation also returns the + // authoritative price breakdown (rail + first/last mile + every surcharge) — + // run it for every freight type; container contracts additionally get + // overweight warnings + 20ft pairing hard-blocks surfaced in the modal. const handleReview = form.handleSubmit((values) => { setPendingValues(values); - if (isContainerContract) { - validateMutation.reset(); - validateMutation.mutate(buildDto(values)); - } + validateMutation.reset(); + validateMutation.mutate(buildDto(values)); }); const handleConfirm = () => { @@ -449,12 +446,32 @@ function PriceConfirmModal({ const hasPairingBlock = pairingErrors.length > 0; const confirmDisabled = loading || validationLoading || hasPairingBlock; - // The contract's frozen unit rates (computeShipmentTotal) don't carry an - // overweight line — that surcharge only exists in the live rule engine. Fold - // the real amount from validateShipment into the displayed total so the - // customer sees the actual charge the overweight warning refers to, not just - // the warning text. + // Authoritative server breakdown — the SAME BookingPricingService pass that + // prices the booking on create, so it carries every line the booking will be + // charged: rail freight, first/last mile trucking, overweight, hazard/reefer + // and any other rule-engine surcharge. + const serverTotal = useMemo(() => { + const items = validation?.lineItems; + if (!items?.length) return null; + return { + currency: validation?.currency ?? baseTotal?.currency ?? "ETB", + lines: items.map((li) => ({ + label: li.description, + unitPrice: li.unitAmount, + unit: li.unit.toLowerCase(), + quantity: li.quantity, + amount: li.amount, + })), + total: + validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), + }; + }, [validation, baseTotal]); + + // Fallback while the server preview loads: the contract's frozen unit rates + // (container/bulk + hazard/reefer only) with the overweight surcharge folded + // in. Replaced by the full server breakdown the moment it arrives. const total = useMemo(() => { + if (serverTotal) return serverTotal; if (!baseTotal) return null; if (!(overweightSurchargeAmount > 0)) return baseTotal; return { @@ -471,7 +488,7 @@ function PriceConfirmModal({ ], total: baseTotal.total + overweightSurchargeAmount, }; - }, [baseTotal, overweightSurchargeAmount]); + }, [serverTotal, baseTotal, overweightSurchargeAmount]); return ( - Checking container weights and wagon pairing… + Computing the final price breakdown and checking container + weights… )} diff --git a/apps/edr-freight-web/portal/src/services/contracts.service.ts b/apps/edr-freight-web/portal/src/services/contracts.service.ts index 7c981eee2..0642e8061 100644 --- a/apps/edr-freight-web/portal/src/services/contracts.service.ts +++ b/apps/edr-freight-web/portal/src/services/contracts.service.ts @@ -42,18 +42,37 @@ export interface OverweightLine { } /** - * Pre-submit validation for a shipment booking under a CONTAINER contract. + * One line of the server-priced booking breakdown — the exact line the booking + * will persist at create time (rail freight, first/last mile, surcharges…). + */ +export interface ShipmentPriceLine { + code: string; + description: string; + amount: number; + unitAmount: number; + /** Rate unit as stored: PER_CONTAINER | PER_WAGON | PER_TON | PER_KM | FLAT | … */ + unit: string; + quantity: number; + currency: string; +} + +/** + * Pre-submit validation + authoritative price preview for a shipment booking. * `overweightLines` are WARNINGS only (an overweight surcharge applies — the * customer may still submit); `pairingErrors` are HARD BLOCKS (20ft containers * that cannot be balanced onto wagons) and must prevent booking. - * `overweightSurchargeAmount` is the real overweight charge (same rate the - * booking is billed at on submit) so the confirm-modal total can include it. + * `lineItems`/`totalAmount` are the full server-computed breakdown — the same + * BookingPricingService pass that prices the booking on create, so the confirm + * modal shows first/last mile, overweight, and every surcharge, not just the + * container estimate. */ export interface ShipmentValidation { overweightLines: OverweightLine[]; overweightSurchargeAmount: number; currency: string | null; pairingErrors: string[]; + lineItems?: ShipmentPriceLine[]; + totalAmount?: number; } export interface ContractListFilter { From 414ed92590ea9d3ccde235ae748f5d2cdcc34df7 Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 4 Jul 2026 00:58:14 +0000 Subject: [PATCH 068/122] fix --- .../src/pages/operations/FirstMilePage.tsx | 18 ++++++++++++++++-- .../src/pages/operations/LastMilePage.tsx | 18 ++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 182ff7cac..68dcf62f7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -64,6 +64,19 @@ const STATUS_META: Record RECEIVED_TO_PORT: { label: "Received to Port", color: "green" }, }; +// Invoice payment state → badge color, keyed by upper-cased status. +const INVOICE_STATUS_META: Record = { + PAID: { label: "Paid", color: "green" }, + PARTIALLY_PAID: { label: "Partially Paid", color: "teal" }, + PENDING: { label: "Pending", color: "yellow" }, + UNPAID: { label: "Unpaid", color: "yellow" }, + OPEN: { label: "Open", color: "yellow" }, + ISSUED: { label: "Issued", color: "blue" }, + OVERDUE: { label: "Overdue", color: "red" }, + CANCELLED: { label: "Cancelled", color: "gray" }, + VOID: { label: "Void", color: "gray" }, +}; + const NEXT_STATUS: Partial> = { PAYMENT_PENDING: "READY_TO_TRANSIT", READY_TO_TRANSIT: "IN_TRANSIT", @@ -757,7 +770,8 @@ const FirstMilePage = () => { if (!invoice) { return ; } - const isPaid = (row.original as any).paid || invoice.status === "Paid"; + const status = String((row.original as any).paid ? "PAID" : invoice.status || "").toUpperCase(); + const badge = INVOICE_STATUS_META[status] ?? { color: "gray", label: status || "—" }; return ( { > {invoice.number} - {isPaid && Paid} + {status && {badge.label}} ); }, diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 0267bd1ce..ace94f85d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -71,6 +71,19 @@ const STATUS_META: Record = DELIVERED: { label: "Delivered", color: "green" }, }; +// Invoice payment state → badge color, keyed by upper-cased status. +const INVOICE_STATUS_META: Record = { + PAID: { label: "Paid", color: "green" }, + PARTIALLY_PAID: { label: "Partially Paid", color: "teal" }, + PENDING: { label: "Pending", color: "yellow" }, + UNPAID: { label: "Unpaid", color: "yellow" }, + OPEN: { label: "Open", color: "yellow" }, + ISSUED: { label: "Issued", color: "blue" }, + OVERDUE: { label: "Overdue", color: "red" }, + CANCELLED: { label: "Cancelled", color: "gray" }, + VOID: { label: "Void", color: "gray" }, +}; + const NEXT_STATUS: Partial> = { PAYMENT_PENDING: "READY_TO_TRANSIT", READY_TO_TRANSIT: "IN_TRANSIT", @@ -1128,7 +1141,8 @@ const LastMilePage = () => { if (!invoice) { return ; } - const isPaid = (row.original as any).paid || invoice.status === "Paid"; + const status = String((row.original as any).paid ? "PAID" : invoice.status || "").toUpperCase(); + const badge = INVOICE_STATUS_META[status] ?? { color: "gray", label: status || "—" }; return ( { > {invoice.number} - {isPaid && Paid} + {status && {badge.label}} ); }, From 8ea2c8e95aff4e03c97a79626431644d91a2a35c Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 4 Jul 2026 01:11:23 +0000 Subject: [PATCH 069/122] add hard capacity ceiling to weight limit rules --- ...000000-AddMaxCapacityToWeightLimitRules.ts | 28 ++++ .../modules/bookings/bookings.repository.ts | 4 +- .../contracts/contract-booking.service.ts | 56 +++++++ .../dto/create-weight-limit-rule.dto.ts | 15 +- .../entities/weight-limit-rule.entity.ts | 8 + .../rule-engine/rule-engine.service.ts | 38 +++++ .../services/weight-limit-rules.service.ts | 32 +++- .../train-scheduling.service.ts | 41 +++-- .../train-scheduling/wagon-plan.util.ts | 12 +- .../contracts/GlCreateBookingForm.tsx | 29 +++- .../ScheduleWorkspacePanel.tsx | 37 +++- .../ruleEngine/RuleEngineResourcePage.tsx | 4 + .../src/pages/ruleEngine/config/resources.ts | 14 ++ .../src/services/contracts.service.ts | 6 +- .../src/pages/bookings/NewBookingPage.tsx | 15 +- .../pages/bookings/new-booking-form/schema.ts | 8 +- .../bookings/new-booking-form/step4-route.tsx | 158 +----------------- .../src/pages/contracts/NewShipmentPage.tsx | 29 +++- .../portal/src/services/contracts.service.ts | 2 + 19 files changed, 340 insertions(+), 196 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1930000000000-AddMaxCapacityToWeightLimitRules.ts diff --git a/apps/edr-freight-api/src/migrations/1930000000000-AddMaxCapacityToWeightLimitRules.ts b/apps/edr-freight-api/src/migrations/1930000000000-AddMaxCapacityToWeightLimitRules.ts new file mode 100644 index 000000000..b1218a9b3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1930000000000-AddMaxCapacityToWeightLimitRules.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add a hard per-unit weight ceiling to weight limit rules. + * + * maxVgmTons stays the soft "overweight" threshold (surcharge + warning); + * max_capacity_tons is the absolute ceiling above which a booking cannot be + * created at all. Null means no ceiling (existing behavior). + */ +export class AddMaxCapacityToWeightLimitRules1930000000000 + implements MigrationInterface +{ + name = "AddMaxCapacityToWeightLimitRules1930000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.weight_limit_rules + ADD COLUMN IF NOT EXISTS max_capacity_tons numeric(8, 3); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.weight_limit_rules + DROP COLUMN IF EXISTS max_capacity_tons; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 25cf4f875..4803f988c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1077,7 +1077,9 @@ export class BookingsRepository extends BaseRepository { company: true, originYard: true, destinationYard: true, - bookingContainers: { containerType: true }, + // units carry the real per-container numbers entered at booking time — + // the wagon plan shows those instead of generated placeholders. + bookingContainers: { containerType: true, units: true }, cargoType: true, }, order: { priorityScore: 'DESC', createdAt: 'ASC' }, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 3ea19f778..c1eb4b4f8 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -133,6 +133,13 @@ export class ContractBookingService { }); } + // Hard capacity gate: a container line whose total weight exceeds the + // container type's max capacity can never be booked — no surcharge path, + // no override. Checked before any row is written. + if (freightType === 'CONTAINER') { + await this.assertWithinMaxCapacity(contract, dto); + } + // Denormalize route/direction/freight onto the booking for the scheduling engine. const booking = await this.bookingsRepository.create({ reference, @@ -608,6 +615,7 @@ export class ContractBookingService { overweightSurchargeAmount: number; currency: string | null; pairingErrors: string[]; + capacityErrors: string[]; lineItems: PriceLineItemDto[]; totalAmount: number; }> { @@ -621,6 +629,7 @@ export class ContractBookingService { overweightSurchargeAmount: 0, currency: null, pairingErrors: [], + capacityErrors: [], lineItems: [], totalAmount: 0, }; @@ -695,16 +704,63 @@ export class ContractBookingService { (v) => v.message, ); + // Hard capacity ceiling — a non-empty result means the create call will be + // rejected, so the form can block submit up front. + const capacityErrors = await this.ruleEngineService.capacityViolations( + resolved.map(({ line, ct, totalVgmTons }) => ({ + containerTypeId: ct.id, + quantity: line.quantity, + totalVgmTons, + })), + contract.tradeDirection, + ); + return { overweightLines: computed.overweightLines, overweightSurchargeAmount, currency: computed.currency, pairingErrors, + capacityErrors, lineItems: computed.lineItems, totalAmount: computed.totalAmount, }; } + /** + * Throws when any container line's total weight exceeds the hard capacity + * ceiling of its weight limit rule. Mirrors validateShipment's line + * resolution so the gate matches what the form preview reported. + */ + private async assertWithinMaxCapacity( + contract: Contract, + dto: CreateBookingUnderContractDto, + ): Promise { + const lines = dto.containers ?? []; + if (!lines.length) return; + + const containers = await Promise.all( + lines.map(async (line) => { + const ct = await this.resolveContainerTypeForSize( + line.containerSize, + contract.isReefer || (line.reeferQuantity ?? 0) > 0, + ); + const totalVgmTons = (line.units ?? []).reduce( + (s, u) => s + Number(u.vgmTons ?? 0), + 0, + ); + return { containerTypeId: ct.id, quantity: line.quantity, totalVgmTons }; + }), + ); + + const violations = await this.ruleEngineService.capacityViolations( + containers, + contract.tradeDirection, + ); + if (violations.length) { + throw new BadRequestException(violations.join('; ')); + } + } + private async max20ftPairDiffTons(): Promise { const row = await this.dataSource .getRepository(TrainSchedulingGlobalRules) diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts index eea223ae3..d60a56944 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts @@ -1,6 +1,6 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsIn, IsNumber, IsUUID, Min } from 'class-validator'; +import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const; @@ -21,4 +21,15 @@ export class CreateWeightLimitRuleDto { @Min(0) @Transform(({ value }) => Number(value)) maxVgmTons!: number; + + @ApiPropertyOptional({ + description: + 'Hard per-unit weight ceiling in tons — above this the booking cannot be created. Null/omitted = no ceiling.', + minimum: 0, + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => (value === null || value === undefined || value === '' ? null : Number(value))) + maxCapacityTons?: number | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts index b6b87b285..7a30cc20d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts @@ -18,4 +18,12 @@ export class WeightLimitRule extends BaseEntity { @Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true }) maxVgmTons!: number; + + /** + * Absolute per-unit weight ceiling in tons. Weight above maxVgmTons but at or + * below this is "overweight" (surcharge + warning); weight above this hard- + * blocks booking creation entirely. Null = no ceiling (overweight only). + */ + @Column({ name: 'max_capacity_tons', type: 'numeric', precision: 8, scale: 3, nullable: true }) + maxCapacityTons!: number | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 4b082e5fd..0fee8e75a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -136,6 +136,10 @@ export class RuleEngineService { } } + hardBlocked.push( + ...(await this.capacityViolations(input.containers, input.tradeDirection)), + ); + for (const container of input.containers) { const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId( container.containerTypeId, @@ -296,6 +300,40 @@ export class RuleEngineService { }; } + /** + * Messages for container lines whose total weight exceeds the hard capacity + * ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking + * must not be created at all. Overweight (above maxVgmTons but within + * capacity) is NOT reported here — that is a surcharge, not a block. + */ + async capacityViolations( + containers: Array<{ + containerTypeId: string; + quantity: number; + totalVgmTons: number; + }>, + tradeDirection: string, + ): Promise { + const violations: string[] = []; + for (const container of containers) { + const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId( + container.containerTypeId, + tradeDirection, + ); + const rule = rules[0]; + if (!rule || rule.maxCapacityTons == null) continue; + const perUnit = Number(rule.maxCapacityTons); + const maxTotal = perUnit * container.quantity; + if (container.totalVgmTons > maxTotal) { + const label = rule.containerType?.code ?? container.containerTypeId; + violations.push( + `${label} total weight ${container.totalVgmTons}t exceeds the maximum capacity of ${maxTotal}t (${perUnit}t per unit) — the booking cannot be created; reduce the cargo weight`, + ); + } + } + return violations; + } + /** * Ensure ITMLS default approval chains exist (container + bulk). Idempotent. */ diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts index bbd042296..44f5332f2 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts @@ -1,4 +1,10 @@ -import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto'; import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto'; import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; @@ -62,13 +68,31 @@ export class WeightLimitRulesService { } } + /** + * Capacity is the hard ceiling; the VGM limit is the soft overweight + * threshold. A ceiling below the threshold would make every overweight + * booking impossible to create, which is never what the operator means. + */ + private assertCapacityAboveVgmLimit( + maxVgmTons: number, + maxCapacityTons: number | null | undefined, + ): void { + if (maxCapacityTons != null && Number(maxCapacityTons) < Number(maxVgmTons)) { + throw new BadRequestException( + 'Max capacity must be greater than or equal to the max VGM limit.', + ); + } + } + /** Create a new weight limit rule. */ async create(dto: CreateWeightLimitRuleDto): Promise { await this.assertNoDuplicate(dto.containerTypeId, dto.tradeDirection); + this.assertCapacityAboveVgmLimit(dto.maxVgmTons, dto.maxCapacityTons); return this.repository.create({ containerTypeId: dto.containerTypeId, tradeDirection: dto.tradeDirection, maxVgmTons: dto.maxVgmTons, + maxCapacityTons: dto.maxCapacityTons ?? null, }); } @@ -79,6 +103,12 @@ export class WeightLimitRulesService { if (dto.containerTypeId !== undefined) patch.containerTypeId = dto.containerTypeId; if (dto.tradeDirection !== undefined) patch.tradeDirection = dto.tradeDirection; if (dto.maxVgmTons !== undefined) patch.maxVgmTons = dto.maxVgmTons; + if (dto.maxCapacityTons !== undefined) patch.maxCapacityTons = dto.maxCapacityTons; + + this.assertCapacityAboveVgmLimit( + patch.maxVgmTons ?? Number(existing.maxVgmTons), + patch.maxCapacityTons !== undefined ? patch.maxCapacityTons : existing.maxCapacityTons, + ); // Re-check uniqueness when the identity (container/direction) changes. if (dto.containerTypeId !== undefined || dto.tradeDirection !== undefined) { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 93846d31d..a10847c17 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -605,16 +605,22 @@ export class TrainSchedulingService { ); if (!validation.valid) { + // Put the violation detail in the message itself — global exception + // filters flatten the body, and "Booking validation failed" alone tells + // staff nothing (e.g. which wagon type is missing at the yard). throw new BadRequestException({ - message: 'Booking validation failed', + message: `Booking validation failed: ${validation.violations.join('; ')}`, violations: validation.violations, warnings: validation.warnings, }); } if (!validation.bookings.length) { + const shortfall = validation.deferredBookings + .map((d) => `${d.reference}: ${d.reason}`) + .join('; '); throw new BadRequestException({ - message: 'No bookings fit on available fleet wagons', + message: `No wagons available for the selected bookings${shortfall ? ` — ${shortfall}` : ''}`, violations: ['Insufficient fleet wagons for the selected bookings'], warnings: validation.warnings, deferredBookings: validation.deferredBookings, @@ -628,12 +634,14 @@ export class TrainSchedulingService { if (!limitLoco) { throw new BadRequestException('Schedule train set has no locomotives'); } - if (limitLoco.maxPullWeightTons < totalWeightTons) { + // forceAssign lets staff overload the locomotive set knowingly — the + // validator has already surfaced it as a warning in that case. + if (!dto.forceAssign && limitLoco.maxPullWeightTons < totalWeightTons) { throw new BadRequestException( `Train set locomotives cannot pull ${totalWeightTons}T`, ); } - if (limitLoco.maxTrainLengthMeters < totalLengthMeters) { + if (!dto.forceAssign && limitLoco.maxTrainLengthMeters < totalLengthMeters) { throw new BadRequestException( `Train set locomotives cannot support ${totalLengthMeters}m`, ); @@ -2251,9 +2259,16 @@ export class TrainSchedulingService { max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons, }; + // With forceAssign, capacity-shaped rules (train limits, total weight, + // locomotive capability) become warnings — staff owns the override. Physical + // impossibilities (no wagon of the required type at the yard, wrong route, + // wrong status) can never be forced and stay violations. + const pushLimit = (issues: string[]) => + forceAssign ? warnings.push(...issues) : violations.push(...issues); + if (resolvedMode === 'MIXED') { - violations.push( - ...validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits), + pushLimit( + validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits), ); if (requireContainerPlacements) { const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER'); @@ -2270,7 +2285,7 @@ export class TrainSchedulingService { ); } } else { - violations.push(...validateTrainLimits(wagonPlan, wagonType, trainLimits)); + pushLimit(validateTrainLimits(wagonPlan, wagonType, trainLimits)); if (requireContainerPlacements && resolvedMode === 'CONTAINER') { violations.push( @@ -2293,8 +2308,8 @@ export class TrainSchedulingService { ); if (totalWeightTons > trainLimits.maxWeightTons) { const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`; - if (!violations.includes(message)) { - violations.push(message); + if (!violations.includes(message) && !warnings.includes(message)) { + pushLimit([message]); } } @@ -2321,9 +2336,9 @@ export class TrainSchedulingService { (setLimits.maxPullWeightTons < totalWeightTons || setLimits.maxTrainLengthMeters < totalLengthMeters) ) { - violations.push( + pushLimit([ 'Assigned locomotives cannot support the total train weight and length', - ); + ]); } } else { const inServiceLocomotives = await this.locomotivesRepository.findAll({ @@ -2341,7 +2356,7 @@ export class TrainSchedulingService { Number(l.maxTrainLengthMeters) >= totalLengthMeters, ) ) { - violations.push('No locomotive can support the total train weight and length'); + pushLimit(['No locomotive can support the total train weight and length']); } } @@ -3740,7 +3755,7 @@ export class TrainSchedulingService { if (!validation.valid) { throw new BadRequestException({ - message: 'Booking validation failed', + message: `Booking validation failed: ${validation.violations.join('; ')}`, violations: validation.violations, warnings: validation.warnings, }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 8c3199461..35d5ce185 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -210,7 +210,14 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5)); const perWagon = containersPerWagonFromType(wagonsPerUnit); const teuSlots = teuSlotsForSizeFt(sizeFt); + // The REAL per-container numbers/weights entered at booking time. Unit i of + // the line maps to units[i] (sortOrder order); the line-level number is only + // a legacy fallback — never invent numbers here. + const units = [...(line.units ?? [])].sort( + (a, b) => Number(a.sortOrder ?? 0) - Number(b.sortOrder ?? 0), + ); for (let i = 0; i < qty; i += 1) { + const unit = units[i]; rows.push({ bookingId: booking.id, bookingReference: booking.reference, @@ -219,12 +226,13 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR containerTypeId: line.containerTypeId ?? '', containerTypeCode: code, label: `${booking.reference} · ${i + 1}/${qty} · ${code}`, - grossWeightTons: Number(line.vgmPerUnitTons), + grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons), sizeFt, wagonsPerUnit, containersPerWagon: perWagon, teuSlots, - containerNumber: line.containerNumber ?? null, + containerNumber: + unit?.containerNumber?.trim() || line.containerNumber || null, }); } } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index d437d2904..d967ffc3f 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -444,6 +444,7 @@ export default function GlCreateBookingForm() { const displayTotal = serverTotal ?? priceTotal; const pairingErrors = validation?.pairingErrors ?? []; + const capacityErrors = validation?.capacityErrors ?? []; const overweightLines = validation?.overweightLines ?? []; const openPriceModal = () => { @@ -459,6 +460,8 @@ export default function GlCreateBookingForm() { if (!contract || !windowOpen) return; // Never book past unresolved 20ft pairing hard-blocks. if (pairingErrors.length > 0) return; + // A line above the container type's max capacity can never book. + if (capacityErrors.length > 0) return; const payload = buildPayload(); if (!payload) return; @@ -938,6 +941,28 @@ export default function GlCreateBookingForm() { )} + {capacityErrors.length > 0 && ( + } + title="Cannot create booking — over maximum capacity" + > + + {capacityErrors.map((msg, i) => ( + + {msg} + + ))} + + Reduce the cargo weight or split it across more containers + to book this shipment. + + + + )} + {overweightLines.length > 0 && ( } loading={mutations.createBooking.isPending} disabled={ - validateShipmentMutation.isPending || pairingErrors.length > 0 + validateShipmentMutation.isPending || + pairingErrors.length > 0 || + capacityErrors.length > 0 } onClick={handleSubmit} > diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index 4c2ccb57e..2a7689582 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from "react"; +import { isAxiosError } from "axios"; import { Badge, Box, @@ -47,6 +48,18 @@ interface ScheduleWorkspacePanelProps { const GREEN = "var(--mantine-color-edr-green-6)"; +/** Pull the API's violation detail out of an error (e.g. "No CW3 wagon available…"). */ +function apiErrorMessage(error: unknown, fallback: string): string { + if (isAxiosError(error)) { + const data = error.response?.data as Record | undefined; + const violations = data?.violations; + if (Array.isArray(violations) && violations.length) return violations.join(", "); + if (typeof data?.message === "string") return data.message; + if (Array.isArray(data?.message)) return (data.message as string[]).join(", "); + } + return fallback; +} + /** * Deadline + label for the window phase this schedule is currently in. * Phases run: window open (windowClosesAt) → document review (docReviewEndsAt) @@ -198,8 +211,12 @@ export function ScheduleWorkspacePanel({ onChanged(); void poolQuery.refetch(); }) - .catch(() => - toast({ title: "Could not add booking", variant: "destructive" }), + .catch((error) => + toast({ + title: "Could not add booking", + description: apiErrorMessage(error, "Validation failed — check capacity and status."), + variant: "destructive", + }), ); }; @@ -211,8 +228,12 @@ export function ScheduleWorkspacePanel({ onChanged(); void poolQuery.refetch(); }) - .catch(() => - toast({ title: "Could not remove booking", variant: "destructive" }), + .catch((error) => + toast({ + title: "Could not remove booking", + description: apiErrorMessage(error, "Please try again."), + variant: "destructive", + }), ); }; @@ -226,8 +247,12 @@ export function ScheduleWorkspacePanel({ onChanged(); void poolQuery.refetch(); }) - .catch(() => - toast({ title: "Could not reassign booking", variant: "destructive" }), + .catch((error) => + toast({ + title: "Could not reassign booking", + description: apiErrorMessage(error, "Target train may be closed or full."), + variant: "destructive", + }), ); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 99260946f..297d67050 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -341,6 +341,10 @@ const RuleEngineResourcePage = () => { } else if (config.slug === "priority-configs") { // Label is required by the backend but hidden in the UI for now. payload = { ...values, label: String(Date.now()) }; + } else if (config.slug === "weight-limit-rules") { + // Empty max capacity means "no ceiling" — send null explicitly so an + // edit can clear a previously-set ceiling (omitting the key keeps it). + payload = { ...values, maxCapacityTons: values.maxCapacityTons ?? null }; } if (editing?.id) { diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index ff4a15868..faa26036b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -374,6 +374,12 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ }, { id: "tradeDirection", header: "Direction", accessorKey: "tradeDirection" }, { id: "maxVgmTons", header: "Max VGM (t)", accessorKey: "maxVgmTons", format: "number" }, + { + id: "maxCapacityTons", + header: "Max capacity (t)", + accessorKey: "maxCapacityTons", + format: "number", + }, ], formFields: [ { @@ -391,6 +397,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ options: TRADE_DIRECTIONS, }, { name: "maxVgmTons", label: "Max VGM (tons)", type: "number", required: true }, + { + name: "maxCapacityTons", + label: "Max capacity (tons)", + type: "number", + optional: true, + description: + "Hard ceiling — a booking whose line weight exceeds this cannot be created at all. Leave empty for no ceiling (overweight surcharge only).", + }, ], }, { diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index 0c28a8ad7..8860df62a 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -43,8 +43,8 @@ export interface ShipmentPriceLine { * Pre-create validation + authoritative price preview for a booking under a * contract. `lineItems`/`totalAmount` are the full server-computed breakdown — * the same pricing pass the booking persists at create (rail freight, - * first/last mile, overweight and every other surcharge). `pairingErrors` are - * HARD BLOCKS; `overweightLines` are warnings. + * first/last mile, overweight and every other surcharge). `pairingErrors` and + * `capacityErrors` are HARD BLOCKS; `overweightLines` are warnings. */ export interface ShipmentValidation { overweightLines: Array<{ @@ -56,6 +56,8 @@ export interface ShipmentValidation { overweightSurchargeAmount: number; currency: string | null; pairingErrors: string[]; + /** Lines above the container type's hard max capacity — booking cannot be created. */ + capacityErrors?: string[]; lineItems?: ShipmentPriceLine[]; totalAmount?: number; } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 80ed05eb0..afcd2f777 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -524,11 +524,10 @@ export default function NewBookingPage() { } : { customsClearingEnabled: false }), ...(cargoFreeText ? { cargoFreeText } : {}), - // Multi-route general contracts: routes are pure origin→destination lanes - // the contract covers — they carry NO quantity. Route #1 is the primary - // origin/destination; the rest come from the extra-routes step. The - // contracted quantity lives in a single shared pool (the container - // quantities / bulk total), drawn down per order against a chosen lane. + // A general contract covers exactly ONE route — the same single + // origin→destination pair as a one-time booking (multi-route on bookings + // was dropped). The contracted quantity lives in a single shared pool + // (container quantities / bulk total), drawn down per order. ...(isContract ? { routes: [ @@ -536,12 +535,6 @@ export default function NewBookingPage() { originYardId: data.originYard, destinationYardId: data.destinationYard, }, - ...(data.extraRoutes ?? []) - .filter((r) => r.originYard && r.destinationYard) - .map((r) => ({ - originYardId: r.originYard, - destinationYardId: r.destinationYard, - })), ], } : {}), diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index 2b902bdc8..b137635ae 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -144,10 +144,9 @@ export const bookingFormSchema = z // The contracted quantity now comes from the cargo step (cargoWeight), the // same as a one-time booking, so per-route quantity is no longer entered. primaryRouteQuantity: z.string().default(""), - // Additional routes for a GENERAL contract (the primary origin/destination - // above is route #1). Each route is just an (origin, destination) pair — - // identical to the one-time route — so a contract can cover several routes. - // Ignored for one-time bookings. quantity/km kept for payload back-compat. + // LEGACY — multi-route general contracts were dropped; a contract booking + // now covers exactly one route, like a one-time booking. Field retained only + // so previously saved drafts still hydrate; never collected or sent anymore. extraRoutes: z .array( z.object({ @@ -434,7 +433,6 @@ export const stepFields: Record>> = { "originYard", "destinationYard", "primaryRouteQuantity", - "extraRoutes", // Estimated shipment date now lives in the Route step (one-time bookings only). "scheduledDate", ], diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index 783162a35..15b2c5a8e 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -1,26 +1,9 @@ import type { Freight } from "@edr/types"; -import { - Box, - Button, - Group, - Skeleton, - Stack, - Text, -} from "@mantine/core"; +import { Box, Skeleton, Stack } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; -import { - CalendarDays, - MapPin, - Plus, - Route as RouteIcon, - Trash2, -} from "lucide-react"; +import { CalendarDays, MapPin, Route as RouteIcon } from "lucide-react"; import { useCallback, useEffect, useMemo } from "react"; -import { - Controller, - useFieldArray, - type UseFormReturn, -} from "react-hook-form"; +import { Controller, type UseFormReturn } from "react-hook-form"; import { BookingFormInputValues, type BookingFormValues, @@ -70,16 +53,6 @@ export function Step4Route({ } }, [operationType]); - const { - fields: extraRoutes, - append: appendRoute, - remove: removeRoute, - } = useFieldArray({ control: form.control, name: "extraRoutes" }); - - // useFieldArray's `fields` don't re-render on value change, so watch the live - // route values to filter each row's yard options by what it has selected. - const watchedExtraRoutes = form.watch("extraRoutes") ?? []; - const yardOptions = useMemo(() => { if (!referenceData?.yard) return []; return referenceData.yard.map((y) => ({ value: y.id, label: y.name })); @@ -129,25 +102,6 @@ export function Step4Route({ } }, [destinationCountry, dest, form]); - // Same cleanup for the extra contract routes: when the operation type changes, - // clear any extra-route yard whose country no longer matches the required side - // so an added route can't contradict the operation either. - useEffect(() => { - watchedExtraRoutes.forEach((route, i) => { - const ro = referenceData?.yard.find((y) => y.id === route?.originYard); - if (originCountry && ro && ro.country !== originCountry) { - form.setValue(`extraRoutes.${i}.originYard`, ""); - } - const rd = referenceData?.yard.find( - (y) => y.id === route?.destinationYard, - ); - if (destinationCountry && rd && rd.country !== destinationCountry) { - form.setValue(`extraRoutes.${i}.destinationYard`, ""); - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [originCountry, destinationCountry, referenceData, form]); - const directionStyle: Record = { EXPORT: "bg-sky-50 text-sky-800 border-sky-200", IMPORT: "bg-amber-50 text-amber-800 border-amber-200", @@ -161,11 +115,10 @@ export function Step4Route({ const stationSelectDisabled = yardOptions.length === 0; - // A general contract can cover several routes, but each route is just an - // (origin, destination) pair — the same shape as the one-time route. The - // contracted quantity comes from the cargo step, so no per-route quantity or - // distance is collected here. Cargo handling (hazardous / refrigerated) also - // lives in the Cargo step now, not here. + // A general contract covers exactly ONE route — the same single + // origin/destination pair as a one-time booking. (Multi-route contracts were + // dropped; the multi-lane concept lives on the contracts module, not on + // bookings.) The contracted quantity comes from the cargo step. // Earliest selectable shipment date (today, local) for the date input's `min`. const todayISODate = useMemo(() => { @@ -256,103 +209,6 @@ export function Step4Route({
)} - {isGeneralContract && !isLoading && ( - - - Additional contract routes - - - - A general contract can cover several routes. The route above is your - primary route; add more origin–destination routes the contract should - cover. - - - {extraRoutes.map((rf, i) => { - // Each extra route is constrained by the SAME operation type as the - // primary route: its origin must sit in originCountry and its - // destination in destinationCountry. Watch this row's current values - // so each side also excludes the yard picked on the other side. - const rowOrigin = watchedExtraRoutes[i]?.originYard ?? ""; - const rowDestination = - watchedExtraRoutes[i]?.destinationYard ?? ""; - const rowOriginData = yardsForSide(originCountry, rowDestination); - const rowDestData = yardsForSide(destinationCountry, rowOrigin); - return ( - - - ( - - )} - /> - - - ( - - )} - /> - - - - ); - })} - - - )} - ); } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index ddcff24b7..a2bc985c0 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -299,6 +299,8 @@ function NewShipmentBookingForm({ if (!pendingValues) return; // Guard: never let a booking with unresolved 20ft pairing errors submit. if ((validateMutation.data?.pairingErrors.length ?? 0) > 0) return; + // Guard: a line above the container type's max capacity can never book. + if ((validateMutation.data?.capacityErrors?.length ?? 0) > 0) return; submitMutation.mutate(buildDto(pendingValues)); }; @@ -444,7 +446,10 @@ function PriceConfirmModal({ const overweightSurchargeAmount = validation?.overweightSurchargeAmount ?? 0; const pairingErrors = validation?.pairingErrors ?? []; const hasPairingBlock = pairingErrors.length > 0; - const confirmDisabled = loading || validationLoading || hasPairingBlock; + const capacityErrors = validation?.capacityErrors ?? []; + const hasCapacityBlock = capacityErrors.length > 0; + const confirmDisabled = + loading || validationLoading || hasPairingBlock || hasCapacityBlock; // Authoritative server breakdown — the SAME BookingPricingService pass that // prices the booking on create, so it carries every line the booking will be @@ -550,6 +555,28 @@ function PriceConfirmModal({ )} + {hasCapacityBlock && ( + } + title="Cannot create booking — over maximum capacity" + > + + {capacityErrors.map((msg, i) => ( + + {msg} + + ))} + + Reduce the cargo weight or split it across more containers to + book this shipment. + + + + )} + {overweightLines.length > 0 && ( Date: Sat, 4 Jul 2026 01:13:47 +0000 Subject: [PATCH 070/122] fix --- .../src/pages/operations/FirstMilePage.tsx | 17 +++++++++++++++-- .../src/pages/operations/LastMilePage.tsx | 9 ++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 68dcf62f7..2da6e5bb8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -33,6 +33,7 @@ import { Stack, Text, TextInput, + Tooltip, UnstyledButton, } from "@mantine/core"; @@ -64,6 +65,11 @@ const STATUS_META: Record RECEIVED_TO_PORT: { label: "Received to Port", color: "green" }, }; +// Middle-truncate a long invoice number for the table (full value on hover). +// "INV-20260704-00002" → "INV-2…02" +const shortInvoiceNo = (n: string) => + n && n.length > 9 ? `${n.slice(0, 5)}…${n.slice(-2)}` : n; + // Invoice payment state → badge color, keyed by upper-cased status. const INVOICE_STATUS_META: Record = { PAID: { label: "Paid", color: "green" }, @@ -784,7 +790,9 @@ const FirstMilePage = () => { fw={500} style={{ textDecoration: "underline", cursor: "pointer" }} > - {invoice.number} + + {shortInvoiceNo(invoice.number)} + {status && {badge.label}} @@ -812,7 +820,12 @@ const FirstMilePage = () => { const canReceiveToWarehouse = row.original.status === "RECEIVED_TO_PORT"; return ( - + diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index ace94f85d..31b4a6cff 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -71,6 +71,11 @@ const STATUS_META: Record = DELIVERED: { label: "Delivered", color: "green" }, }; +// Middle-truncate a long invoice number for the table (full value on hover). +// "INV-20260704-00002" → "INV-2…02" +const shortInvoiceNo = (n: string) => + n && n.length > 9 ? `${n.slice(0, 5)}…${n.slice(-2)}` : n; + // Invoice payment state → badge color, keyed by upper-cased status. const INVOICE_STATUS_META: Record = { PAID: { label: "Paid", color: "green" }, @@ -1155,7 +1160,9 @@ const LastMilePage = () => { fw={500} style={{ textDecoration: "underline", cursor: "pointer" }} > - {invoice.number} + + {shortInvoiceNo(invoice.number)} + {status && {badge.label}} From 58c49d34b2a0e73f838ed48459b7fc6979b56d4d Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 4 Jul 2026 01:31:08 +0000 Subject: [PATCH 071/122] fix --- .../src/pages/operations/FirstMilePage.tsx | 33 ++++++++++++----- .../src/pages/operations/LastMilePage.tsx | 36 +++++++++++++------ .../src/services/first-mile.service.ts | 1 + .../src/services/last-mile.service.ts | 1 + 4 files changed, 51 insertions(+), 20 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 2da6e5bb8..f7a4b82bc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -52,8 +52,8 @@ import { vehiclesService } from "@/services/vehicles.service"; import { ratesService } from "@/services/rates.service"; import type { BookingDetail } from "@/types/booking"; -const formatPrice = (amount: number) => - `ETB ${amount.toLocaleString("en-US", { +const formatPrice = (amount: number | string | null | undefined, currency = "ETB") => + `${currency || "ETB"} ${(Number(amount) || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2, })}`; @@ -111,8 +111,17 @@ const vehicleLabel = (record: FirstMileRecord) => { const isAssigned = (record: FirstMileRecord) => Boolean(record.vehicleId); +// Paid = record flag set OR its invoice reached PAID. +const isPaidRecord = (r: FirstMileRecord) => + Boolean((r as { paid?: boolean }).paid) || + (r.invoice?.status ?? "").toUpperCase() === "PAID"; +// Post payment pending = a post payment is owed but not yet paid. +const isPostPaymentPending = (r: FirstMileRecord) => + Number(r.remainingPayment) > 0 && !isPaidRecord(r); + // Map API record → display fields used in modals and trip slip const bookingRef = (r: FirstMileRecord) => r.booking?.reference ?? r.bookingId; +const currencyOf = (r: FirstMileRecord) => r.booking?.paymentCurrency ?? "ETB"; const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—"; const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—"; const cargoDesc = (r: FirstMileRecord) => { @@ -169,8 +178,8 @@ const BookingInfo = ({ record }: { record: FirstMileRecord }) => { {hasPickupAddress && } - - + + @@ -189,8 +198,8 @@ const tripSlipRows = (record: FirstMileRecord): [string, string][] => [ ["Pickup location", pickupLocation(record)], ["Destination yard", destinationYardName(record)], ["Cargo", cargoDesc(record)], - ["Advanced Payment", formatPrice(record.advancedPayment)], - ["Post Payment", formatPrice(record.remainingPayment)], + ["Advanced Payment", formatPrice(record.advancedPayment, currencyOf(record))], + ["Post Payment", formatPrice(record.remainingPayment, currencyOf(record))], ["Vehicle", vehicleLabel(record) ?? "Unassigned"], ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], ["Requested date", requestedDate(record)], @@ -589,6 +598,7 @@ const FirstMilePage = () => { }; const matchesFilter = (r: FirstMileRecord) => { + if (filterPostPaymentPending && !isPostPaymentPending(r)) return false; switch (statusFilter) { case "ALL": return true; case "ASSIGNED": return isAssigned(r); @@ -615,6 +625,11 @@ const FirstMilePage = () => { return counts; }, [records]); + const postPaymentPendingCount = useMemo( + () => records.filter(isPostPaymentPending).length, + [records], + ); + const filteredRecords = useMemo(() => { const term = search.trim().toLowerCase(); return records.filter((r) => { @@ -751,7 +766,7 @@ const FirstMilePage = () => { id: "postPayment", header: "Post Payment", meta: { headerClassName, cellClassName }, - cell: ({ row }) => formatPrice(row.original.remainingPayment), + cell: ({ row }) => formatPrice(row.original.remainingPayment, currencyOf(row.original)), }, { id: "vehicle", @@ -853,7 +868,7 @@ const FirstMilePage = () => { } - disabled={!assigned} + disabled={!assigned || Boolean(row.original.invoice)} onClick={() => openAssign(row.original.id)} > Reassign @@ -975,7 +990,7 @@ const FirstMilePage = () => { setPagination((p) => ({ ...p, pageIndex: 0 })); }} > - Post Payment Pending + Post Payment Pending ({postPaymentPendingCount}) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 31b4a6cff..d7545fd7b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -58,8 +58,8 @@ import { ratesService } from "@/services/rates.service"; import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal"; import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; -const formatPrice = (amount: number) => - `ETB ${amount.toLocaleString("en-US", { +const formatPrice = (amount: number | string | null | undefined, currency = "ETB") => + `${currency || "ETB"} ${(Number(amount) || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2, })}`; @@ -156,6 +156,14 @@ const containerLabels = (record: LastMileRecord): string[] => { const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId); +// Paid = record flag set OR its invoice reached PAID. +const isPaidRecord = (r: LastMileRecord) => + Boolean((r as { paid?: boolean }).paid) || + (r.invoice?.status ?? "").toUpperCase() === "PAID"; +// Post payment pending = a post payment is owed but not yet paid. +const isPostPaymentPending = (r: LastMileRecord) => + Number(r.remainingPayment) > 0 && !isPaidRecord(r); + const fmtStamp = (iso?: string | null) => { if (!iso) return null; const d = new Date(iso); @@ -209,6 +217,7 @@ const computeLastMileSteps = ( }; const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId; +const currencyOf = (r: LastMileRecord) => r.booking?.paymentCurrency ?? "ETB"; const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—"; const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—"; const cargoDesc = (r: LastMileRecord) => { @@ -311,8 +320,8 @@ const BookingInfo = ({ record }: { record: LastMileRecord }) => { {hasDeliveryAddress && } - - + + @@ -358,7 +367,7 @@ const tripSlipRows = ( ["Pickup (origin yard)", originYardName(record)], ["Destination", deliveryLocation(record)], ["Cargo", cargoDesc(record)], - ["Post Payment", formatPrice(record.remainingPayment)], + ["Post Payment", formatPrice(record.remainingPayment, currencyOf(record))], ...vehicleRows, ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], ["Requested date", requestedDate(record)], @@ -870,11 +879,16 @@ const LastMilePage = () => { return counts; }, [records]); + const postPaymentPendingCount = useMemo( + () => records.filter(isPostPaymentPending).length, + [records], + ); + const filteredRecords = useMemo(() => { const term = search.trim().toLowerCase(); return records.filter((r) => { if (!matchesFilter(r)) return false; - if (filterPostPaymentPending && !(r.remainingPayment > 0)) return false; + if (filterPostPaymentPending && !isPostPaymentPending(r)) return false; if (!term) return true; return [bookingRef(r), customerName(r), deliveryLocation(r), cargoDesc(r)] .join(" ") @@ -1089,7 +1103,7 @@ const LastMilePage = () => { id: "postPayment", header: "Post Payment", meta: { headerClassName, cellClassName }, - cell: ({ row }) => formatPrice(row.original.remainingPayment), + cell: ({ row }) => formatPrice(row.original.remainingPayment, currencyOf(row.original)), }, { id: "vehicle", @@ -1242,7 +1256,7 @@ const LastMilePage = () => { } - disabled={!assigned || delivered} + disabled={!assigned || delivered || Boolean(row.original.invoice)} onClick={() => openAssign(row.original.id)} > Reassign @@ -1250,7 +1264,7 @@ const LastMilePage = () => { } - disabled={!assigned || delivered} + disabled={!assigned || delivered || Boolean(row.original.invoice)} onClick={() => setVehiclesMutation.mutate( { id: row.original.id, vehicles: [] }, @@ -1387,7 +1401,7 @@ const LastMilePage = () => { setPagination((p) => ({ ...p, pageIndex: 0 })); }} > - Post Payment Pending + Post Payment Pending ({postPaymentPendingCount}) @@ -1928,7 +1942,7 @@ const LastMilePage = () => { Invoice amount - {formatPrice(invoiceConfirm.remainingPayment)} + {formatPrice(invoiceConfirm.remainingPayment, currencyOf(invoiceConfirm))} This creates the delivery-fee invoice. Confirm the distances and amount are correct. diff --git a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts index 1418cb636..a1c2ec8e2 100644 --- a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts @@ -16,6 +16,7 @@ export interface FirstMileBooking { cargoFreeText?: string | null; cargoTotalWeightVgm: number; totalAmount: number; + paymentCurrency?: string | null; scheduledDate?: string | null; company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null; serviceType?: { id: string; label?: string } | null; diff --git a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts index c68c5a9a2..2f8a1cec5 100644 --- a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts @@ -16,6 +16,7 @@ export interface LastMileBooking { cargoFreeText?: string | null; cargoTotalWeightVgm: number; totalAmount: number; + paymentCurrency?: string | null; scheduledDate?: string | null; company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null; serviceType?: { id: string; name?: string; label?: string } | null; From bd88424167b7a0dece7fbc959f183280725c2891 Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 4 Jul 2026 01:47:28 +0000 Subject: [PATCH 072/122] fix --- ...00000000-AddFirstMileVehicleAssignments.ts | 42 +++ .../first-mile/dto/set-distances.dto.ts | 24 ++ .../first-mile/dto/set-vehicles.dto.ts | 19 + .../first-mile-vehicle-assignment.entity.ts | 39 +++ .../first-mile/entities/first-mile.entity.ts | 4 + .../first-mile/first-mile.controller.ts | 22 ++ .../modules/first-mile/first-mile.module.ts | 3 +- .../modules/first-mile/first-mile.service.ts | 212 ++++++++++- .../src/pages/operations/FirstMilePage.tsx | 331 ++++++++++++++---- .../src/services/first-mile.service.ts | 28 ++ 10 files changed, 637 insertions(+), 87 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1940000000000-AddFirstMileVehicleAssignments.ts create mode 100644 apps/edr-freight-api/src/modules/first-mile/dto/set-distances.dto.ts create mode 100644 apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts create mode 100644 apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts diff --git a/apps/edr-freight-api/src/migrations/1940000000000-AddFirstMileVehicleAssignments.ts b/apps/edr-freight-api/src/migrations/1940000000000-AddFirstMileVehicleAssignments.ts new file mode 100644 index 000000000..42f2c4eba --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1940000000000-AddFirstMileVehicleAssignments.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Allow more than one vehicle per first-mile pickup. Junction table joins + * first_mile ⇄ vehicles, with each truck's container number + actual distance; + * existing single vehicle_id values are backfilled as the first assignment so + * nothing is lost. Mirrors the last-mile vehicle-assignment schema. + */ +export class AddFirstMileVehicleAssignments1940000000000 implements MigrationInterface { + name = "AddFirstMileVehicleAssignments1940000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.first_mile_vehicle_assignments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + first_mile_id uuid NOT NULL REFERENCES freight.first_mile(id) ON DELETE CASCADE, + vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id), + container_number varchar, + distance_km numeric(10,2), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT "UQ_FIRST_MILE_VEHICLE" UNIQUE (first_mile_id, vehicle_id) + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FM_VEHICLE_ASSIGNMENTS_VEHICLE" + ON freight.first_mile_vehicle_assignments (vehicle_id) + `); + // Backfill: existing single-vehicle assignments become the first row + await queryRunner.query(` + INSERT INTO freight.first_mile_vehicle_assignments (first_mile_id, vehicle_id) + SELECT id, vehicle_id FROM freight.first_mile + WHERE vehicle_id IS NOT NULL AND deleted_at IS NULL + ON CONFLICT (first_mile_id, vehicle_id) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.first_mile_vehicle_assignments`); + } +} diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/set-distances.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/set-distances.dto.ts new file mode 100644 index 000000000..84247708b --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/dto/set-distances.dto.ts @@ -0,0 +1,24 @@ +import { IsArray, IsNumber, IsOptional, IsUUID, Min, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class VehicleDistanceInput { + @IsUUID() + vehicleId!: string; + + @IsNumber() + @Min(0) + distanceKm!: number; +} + +/** Per-vehicle actual distances for a first-mile pickup (multi-truck). */ +export class SetDistancesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => VehicleDistanceInput) + distances!: VehicleDistanceInput[]; + + /** Recomputed remaining payment (total km × rate), from the client. */ + @IsOptional() + @IsNumber() + remainingPayment?: number; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts new file mode 100644 index 000000000..8656b2109 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts @@ -0,0 +1,19 @@ +import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class FirstMileVehicleInput { + @IsUUID() + vehicleId!: string; + + @IsOptional() + @IsString() + containerNumber?: string; +} + +/** Replace the full set of vehicles (with their container numbers) on a pickup. */ +export class SetVehiclesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => FirstMileVehicleInput) + vehicles!: FirstMileVehicleInput[]; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts new file mode 100644 index 000000000..39bf51a50 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts @@ -0,0 +1,39 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm'; + +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { FirstMile } from './first-mile.entity'; + +/** + * One row per vehicle assigned to a first-mile pickup. A pickup can be served + * by several vehicles at once (multi-truck bookings); the legacy + * `first_mile.vehicle_id` column keeps pointing at the first assignment for + * backward compatibility. + */ +@Entity({ name: 'first_mile_vehicle_assignments', schema: 'freight' }) +@Unique(['firstMileId', 'vehicleId']) +@Index(['vehicleId']) +export class FirstMileVehicleAssignment extends BaseEntity { + @Column({ name: 'first_mile_id', type: 'uuid' }) + firstMileId!: string; + + @ManyToOne(() => FirstMile, (fm) => fm.vehicleAssignments, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'first_mile_id' }) + firstMile?: FirstMile; + + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { nullable: false, eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle; + + /** Container this truck carries — auto-filled from the booking's container + * number when known, else entered manually at assignment time. */ + @Column({ name: 'container_number', type: 'varchar', nullable: true }) + containerNumber?: string | null; + + /** Actual distance driven by this truck (km), entered per vehicle. */ + @Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) + distanceKm?: number | null; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts index 27dcfef87..45a051028 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts @@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm import { Booking } from '../../bookings/entities/booking.entity'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; import { FirstMileContainerAllocation } from './first-mile-container-allocation.entity'; +import { FirstMileVehicleAssignment } from './first-mile-vehicle-assignment.entity'; export const FIRST_MILE_STATUSES = [ 'PAYMENT_PENDING', @@ -61,4 +62,7 @@ export class FirstMile extends BaseEntity { { eager: false }, ) containerAllocations!: FirstMileContainerAllocation[]; + + @OneToMany(() => FirstMileVehicleAssignment, (va) => va.firstMile) + vehicleAssignments?: FirstMileVehicleAssignment[]; } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 49175a6f3..e43fbcae8 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -18,6 +18,8 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; +import { SetVehiclesDto } from './dto/set-vehicles.dto'; +import { SetDistancesDto } from './dto/set-distances.dto'; import { FirstMileStatus } from './entities/first-mile.entity'; import { FirstMileService } from './first-mile.service'; import { FirstMileInvoiceService } from './first-mile-invoice.service'; @@ -103,6 +105,26 @@ export class FirstMileController { return invoice; } + @Post(':id/vehicles') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Set the vehicles assigned to a first-mile pickup (multi-truck)' }) + async setVehicles( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetVehiclesDto, + ) { + return this.firstMileService.setVehicles(id, dto.vehicles); + } + + @Post(':id/distances') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' }) + async setDistances( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetDistancesDto, + ) { + return this.firstMileService.setDistances(id, dto.distances, dto.remainingPayment); + } + @Delete(':id') @TrainSchedulingManage() @HttpCode(HttpStatus.NO_CONTENT) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts index 799ae14e6..51f5ccf4e 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts @@ -8,6 +8,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; import { VehiclesModule } from '../vehicles/vehicles.module'; import { FirstMile } from './entities/first-mile.entity'; import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; +import { FirstMileVehicleAssignment } from './entities/first-mile-vehicle-assignment.entity'; import { FirstMileController } from './first-mile.controller'; import { FirstMileInvoiceService } from './first-mile-invoice.service'; import { FirstMileRepository } from './first-mile.repository'; @@ -15,7 +16,7 @@ import { FirstMileService } from './first-mile.service'; @Module({ imports: [ - TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]), + TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation, FirstMileVehicleAssignment]), forwardRef(() => BillingModule), forwardRef(() => BookingsModule), VehiclesModule, diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index dba5e48c7..7cdf6398b 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere, IsNull, Not } from 'typeorm'; +import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; @@ -11,6 +11,7 @@ import { CreateFirstMileDto } from "./dto/create-first-mile.dto"; import { UpdateFirstMileDto } from "./dto/update-first-mile.dto"; import { FirstMile, FirstMileStatus } from "./entities/first-mile.entity"; import { FirstMileContainerAllocation } from "./entities/first-mile-container-allocation.entity"; +import { FirstMileVehicleAssignment } from "./entities/first-mile-vehicle-assignment.entity"; import { FirstMileRepository } from "./first-mile.repository"; import { OnEvent } from "@nestjs/event-emitter"; import { BillingService, InvoiceEventPayload } from "../billing/billing.service"; @@ -92,10 +93,15 @@ export class FirstMileService { directVehicleId?: string | null, ): Promise { if (directVehicleId) return true; - const count = await this.dataSource.manager.count(FirstMileContainerAllocation, { - where: { firstMileId: recordId, vehicleId: Not(IsNull()) }, - }); - return count > 0; + const [junction, allocations] = await Promise.all([ + this.dataSource.manager.count(FirstMileVehicleAssignment, { + where: { firstMileId: recordId }, + }), + this.dataSource.manager.count(FirstMileContainerAllocation, { + where: { firstMileId: recordId, vehicleId: Not(IsNull()) }, + }), + ]); + return junction > 0 || allocations > 0; } /** Human booking reference for a first-mile record, for the history timeline. */ @@ -204,6 +210,7 @@ export class FirstMileService { cargoType: true, }, vehicle: true, + vehicleAssignments: { vehicle: true }, }, order: { [sortBy]: sortOrder }, skip: (page - 1) * pageSize, @@ -250,6 +257,7 @@ export class FirstMileService { cargoType: true, }, vehicle: true, + vehicleAssignments: { vehicle: true }, }, }); @@ -490,18 +498,154 @@ export class FirstMileService { * allocations), unless still in use by another active trip. */ private async releaseVehicles(record: FirstMile): Promise { - const recordAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, { - where: { firstMileId: record.id }, - }); - const vehicleIds = recordAllocations - .map((a) => a.vehicleId) - .filter((id): id is string => Boolean(id)); - if (record.vehicleId) { - vehicleIds.push(record.vehicleId); - } + const [assignments, recordAllocations] = await Promise.all([ + this.dataSource.manager.find(FirstMileVehicleAssignment, { + where: { firstMileId: record.id }, + }), + this.dataSource.manager.find(FirstMileContainerAllocation, { + where: { firstMileId: record.id }, + }), + ]); + const vehicleIds = [ + ...new Set( + [ + ...assignments.map((a) => a.vehicleId), + ...recordAllocations.map((a) => a.vehicleId), + record.vehicleId ?? null, + ].filter((id): id is string => Boolean(id)), + ), + ]; await this.vehiclesService.releaseIfUnused(vehicleIds); } + /** + * Replace the full set of vehicles serving a first-mile pickup (multi-truck). + * Diffs against the current junction rows, syncing availability + audit history + * for each added/removed vehicle. The first vehicle is mirrored onto the legacy + * `vehicleId` column for back-compat with single-vehicle readers. + */ + async setVehicles( + id: string, + inputs: Array<{ vehicleId: string; containerNumber?: string | null }>, + ): Promise { + const existing = await this.findById(id); + // Dedupe by vehicleId, keeping the container number; preserve order. + const desiredMap = new Map(); + for (const inp of inputs) { + if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null); + } + const desired = [...desiredMap.keys()]; + const desiredSet = new Set(desired); + + const manager = this.dataSource.manager; + const current = await manager.find(FirstMileVehicleAssignment, { + where: { firstMileId: id }, + }); + const junctionSet = new Set(current.map((a) => a.vehicleId)); + // Fold the legacy vehicleId into the release set — a vehicle assigned via the + // old single-vehicle path has no junction row but must still be freed. + const releaseIds = [...new Set( + current.map((a) => a.vehicleId).concat(existing.vehicleId ? [existing.vehicleId] : []), + )]; + const added = desired.filter((v) => !junctionSet.has(v)); + const removed = releaseIds.filter((v) => !desiredSet.has(v)); + // Vehicles that stay but whose container number changed. + const changed = current.filter( + (a) => + desiredMap.has(a.vehicleId) && + (a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null), + ); + + await this.dataSource.transaction(async (tx) => { + if (removed.length) { + await tx.delete(FirstMileVehicleAssignment, { + firstMileId: id, + vehicleId: In(removed), + }); + } + for (const vehicleId of added) { + await tx.insert(FirstMileVehicleAssignment, { + firstMileId: id, + vehicleId, + containerNumber: desiredMap.get(vehicleId) ?? null, + }); + } + for (const row of changed) { + await tx.update( + FirstMileVehicleAssignment, + { firstMileId: id, vehicleId: row.vehicleId }, + { containerNumber: desiredMap.get(row.vehicleId) ?? null }, + ); + } + }); + + // Legacy primary vehicle = first of the set (null when cleared). + await this.firstMileRepository.update(id, { vehicleId: desired[0] ?? null } as any); + + const bookingRef = await this.resolveBookingRef(existing); + for (const vehicleId of added) { + await this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY); + void this.notifyDriverAssignment(vehicleId, existing); + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId, + firstMileId: id, + driverId: info.driverId, + label: existing.status, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + for (const vehicleId of removed) { + await this.vehiclesService.releaseIfUnused([vehicleId]); + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + firstMileId: id, + driverId: info.driverId, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + + return this.findById(id); + } + + /** + * Record each truck's actual distance. The pickup total (exact_km) is their + * sum and drives billing; `remainingPayment` (total km × rate) is recomputed + * client-side. Does NOT generate an invoice — that's a separate explicit step. + */ + async setDistances( + id: string, + distances: Array<{ vehicleId: string; distanceKm: number }>, + remainingPayment?: number, + ): Promise { + await this.findById(id); + + // Distances are locked once the invoice exists. + const invoices = await this.billing.findBySourceIds('first_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Distances cannot be changed after the invoice is generated', + ); + } + + for (const d of distances) { + await this.dataSource.manager.update( + FirstMileVehicleAssignment, + { firstMileId: id, vehicleId: d.vehicleId }, + { distanceKm: d.distanceKm }, + ); + } + const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0); + await this.firstMileRepository.update(id, { + exactKm: total, + ...(remainingPayment != null ? { remainingPayment } : {}), + } as any); + return this.findById(id); + } + private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); @@ -570,8 +714,44 @@ export class FirstMileService { ); } + // Every vehicle this pickup holds — junction + legacy + container rows. + const [assignments, allocations] = await Promise.all([ + this.dataSource.manager.find(FirstMileVehicleAssignment, { + where: { firstMileId: id }, + }), + this.dataSource.manager.find(FirstMileContainerAllocation, { + where: { firstMileId: id }, + }), + ]); + const vehicleIds = [ + ...new Set( + [ + ...assignments.map((a) => a.vehicleId), + ...allocations.map((a) => a.vehicleId), + existing.vehicleId ?? null, + ].filter((v): v is string => Boolean(v)), + ), + ]; + await this.firstMileRepository.softDelete(id); - // Free the trucks it was holding (direct + container), unless still in use. - await this.releaseVehicles(existing); + if (assignments.length) { + await this.dataSource.manager.softDelete(FirstMileVehicleAssignment, { firstMileId: id }); + } + + // Free every vehicle no longer held by another active trip and audit release. + if (vehicleIds.length) { + await this.vehiclesService.releaseIfUnused(vehicleIds); + const bookingRef = await this.resolveBookingRef(existing); + for (const vehicleId of vehicleIds) { + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + firstMileId: id, + driverId: info.driverId, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + } } } diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index f7a4b82bc..7a5602751 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -5,12 +5,14 @@ import { Eye, MoreHorizontal, PackageCheck, + Plus, Printer, Receipt, RefreshCw, Ruler, Trash, Truck, + X, } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "react-router-dom"; @@ -45,6 +47,7 @@ import { FIRST_MILE_STATUSES, type FirstMileApiStatus, type FirstMileRecord, + type FirstMileVehicle, firstMileService, } from "@/services/first-mile.service"; import { bookingsService } from "@/services/bookings.service"; @@ -109,7 +112,14 @@ const vehicleLabel = (record: FirstMileRecord) => { return parts.join(" · "); }; -const isAssigned = (record: FirstMileRecord) => Boolean(record.vehicleId); +const isAssigned = (record: FirstMileRecord) => + Boolean(record.vehicleId) || Boolean(record.vehicleAssignments?.length); + +/** Container numbers on a booking, in line order (skips lines without one). */ +const bookingContainerNumbers = (record: FirstMileRecord): string[] => + (record.booking?.bookingContainers ?? []) + .map((c) => c.containerNumber) + .filter((n): n is string => Boolean(n)); // Paid = record flag set OR its invoice reached PAID. const isPaidRecord = (r: FirstMileRecord) => @@ -360,7 +370,10 @@ const FirstMilePage = () => { const [tripSlipOpen, setTripSlipOpen] = useState(false); const [tripSlipRecord, setTripSlipRecord] = useState(null); const [activeId, setActiveId] = useState(null); - const [vehicleValue, setVehicleValue] = useState(null); + // Multi-vehicle assign: one row per truck — vehicle + the container it carries. + const [vehicleRows, setVehicleRows] = useState< + Array<{ vehicleId: string | null; containerNumber: string }> + >([{ vehicleId: null, containerNumber: "" }]); const [acceptOpen, setAcceptOpen] = useState(false); const [acceptStep, setAcceptStep] = useState<1 | 2>(1); @@ -369,7 +382,8 @@ const FirstMilePage = () => { const [bookingSearch, setBookingSearch] = useState(""); const [distanceOpen, setDistanceOpen] = useState(false); - const [distanceValue, setDistanceValue] = useState(""); + // Per-vehicle actual distance, keyed by vehicleId. + const [distanceRows, setDistanceRows] = useState>({}); const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false); const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState(null); @@ -435,14 +449,36 @@ const FirstMilePage = () => { }, }); - const updateDistanceMutation = useMutation({ - mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) => - firstMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }), + const setVehiclesMutation = useMutation({ + mutationFn: ({ + id, + vehicles, + }: { + id: string; + vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>; + }) => firstMileService.setVehicles(id, vehicles), onSuccess: () => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() }); - if (activeRecord) { - toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` }); - } + void qc.invalidateQueries({ queryKey: ["vehicles"] }); + }, + onError: () => { + toast({ title: "Assign failed", variant: "destructive" }); + }, + }); + + const setDistancesMutation = useMutation({ + mutationFn: ({ + id, + distances, + remainingPayment, + }: { + id: string; + distances: Array<{ vehicleId: string; distanceKm: number }>; + remainingPayment?: number; + }) => firstMileService.setDistances(id, distances, remainingPayment), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() }); + toast({ title: "Distances saved", description: activeRecord ? bookingRef(activeRecord) : undefined }); closeDistance(); }, onError: () => { @@ -501,6 +537,37 @@ const FirstMilePage = () => { [records, activeId], ); + // Picker options = free vehicles PLUS the ones already on this record (which are + // BUSY, so absent from the free list) so a reassign shows its current trucks + // selected instead of blank. + const assignVehicleOptions = useMemo(() => { + const opts = [...vehicleOptions]; + const seen = new Set(opts.map((o) => o.value)); + const pushVehicle = (v?: FirstMileVehicle | null) => { + if (v && !seen.has(v.id)) { + seen.add(v.id); + const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber]; + if (v.code) parts.unshift(v.code); + opts.push({ value: v.id, label: parts.join(" · ") }); + } + }; + for (const a of activeRecord?.vehicleAssignments ?? []) pushVehicle(a.vehicle); + pushVehicle(activeRecord?.vehicle); + for (const a of activeRecord?.vehicleAssignments ?? []) { + if (!seen.has(a.vehicleId)) { + seen.add(a.vehicleId); + opts.push({ + value: a.vehicleId, + label: a.containerNumber ? `Assigned · ${a.containerNumber}` : "Assigned vehicle", + }); + } + } + if (activeRecord?.vehicleId && !seen.has(activeRecord.vehicleId)) { + opts.push({ value: activeRecord.vehicleId, label: "Assigned vehicle" }); + } + return opts; + }, [vehicleOptions, activeRecord]); + const selectedIds = useMemo( () => Object.keys(rowSelection).filter((id) => rowSelection[id]), [rowSelection], @@ -554,15 +621,20 @@ const FirstMilePage = () => { }; const openDistance = (id: string) => { + const rec = records.find((r) => r.id === id); + const rows: Record = {}; + for (const a of rec?.vehicleAssignments ?? []) { + rows[a.vehicleId] = a.distanceKm != null ? String(a.distanceKm) : ""; + } setActiveId(id); - setDistanceValue(""); + setDistanceRows(rows); setDistanceOpen(true); }; const closeDistance = () => { setDistanceOpen(false); setActiveId(null); - setDistanceValue(""); + setDistanceRows({}); }; @@ -577,24 +649,27 @@ const FirstMilePage = () => { }; const handleSaveDistance = () => { - const distance = parseFloat(distanceValue); - if (!activeId || isNaN(distance) || distance < 0) { - toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" }); + const distances = Object.entries(distanceRows) + .map(([vehicleId, val]) => ({ vehicleId, distanceKm: parseFloat(val) })) + .filter((d) => !Number.isNaN(d.distanceKm) && d.distanceKm >= 0); + + if (!activeId || !distances.length) { + toast({ title: "Invalid distance", description: "Enter a distance for at least one vehicle.", variant: "destructive" }); return; } + const total = distances.reduce((s, d) => s + d.distanceKm, 0); let remainingPayment: number | undefined; if (ratesData?.data) { const firstMileRate = ratesData.data.find( (r) => r.rateType === "FIRST_MILE" && (r.status === "LIVE" || r.status === "DRAFT") ); if (firstMileRate) { - const rateValue = parseFloat(firstMileRate.rateValue); - remainingPayment = distance * rateValue; + remainingPayment = total * parseFloat(firstMileRate.rateValue); } } - updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment }); + setDistancesMutation.mutate({ id: activeId, distances, remainingPayment }); }; const matchesFilter = (r: FirstMileRecord) => { @@ -651,16 +726,29 @@ const FirstMilePage = () => { const openAssign = (id: string | null) => { const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null; + const rec = records.find((r) => r.id === resolved); + // Prefill each row's container number from the booking's container numbers + // (by order) when the assignment doesn't already carry one. + const nums = rec ? bookingContainerNumbers(rec) : []; + const rows = + rec?.vehicleAssignments?.length + ? rec.vehicleAssignments.map((a, i) => ({ + vehicleId: a.vehicleId, + containerNumber: a.containerNumber ?? nums[i] ?? "", + })) + : rec?.vehicleId + ? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }] + : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]; setBulkMode(false); setActiveId(resolved); - setVehicleValue(null); + setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]); setAssignOpen(true); }; const openBulkAssign = () => { setBulkMode(true); setActiveId(null); - setVehicleValue(null); + setVehicleRows([{ vehicleId: null, containerNumber: "" }]); setAssignOpen(true); }; @@ -668,28 +756,31 @@ const FirstMilePage = () => { setAssignOpen(false); setBulkMode(false); setActiveId(null); - setVehicleValue(null); + setVehicleRows([{ vehicleId: null, containerNumber: "" }]); }; const handleAssign = () => { - if (!vehicleValue) { - toast({ title: "Select a vehicle", description: "Choose a vehicle to assign.", variant: "destructive" }); - return; - } - + const seen = new Set(); + const vehicles = vehicleRows + .filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId)) + .filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId))) + .map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null })); + const count = vehicles.length; const targetIds = bulkMode ? selectedIds : [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.id].filter((id): id is string => Boolean(id)); if (!targetIds.length) return; - const selectedLabel = vehicleOptions.find((o) => o.value === vehicleValue)?.label ?? vehicleValue; - - Promise.all(targetIds.map((id) => updateMutation.mutateAsync({ id, data: { vehicleId: vehicleValue } }))) + // Empty set = unassign all (setVehicles releases the removed vehicles). + Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles }))) .then(() => { toast({ - title: "Vehicle assigned", - description: bulkMode ? `${targetIds.length} pickups → ${selectedLabel}` : selectedLabel, + title: count === 0 ? "Vehicles unassigned" : count > 1 ? "Vehicles assigned" : "Vehicle assigned", + description: + count === 0 + ? bulkMode ? `${targetIds.length} pickups` : undefined + : `${bulkMode ? `${targetIds.length} pickups · ` : ""}${count} vehicle${count > 1 ? "s" : ""}`, }); if (bulkMode) setRowSelection({}); closeAssign(); @@ -772,8 +863,39 @@ const FirstMilePage = () => { id: "vehicle", header: "Vehicle", meta: { headerClassName, cellClassName }, - cell: ({ row }) => - vehicleLabel(row.original) ?? Unassigned, + cell: ({ row }) => { + const assigns = row.original.vehicleAssignments ?? []; + if (assigns.length > 1) { + const labelFor = (a: (typeof assigns)[number]) => { + const v = a.vehicle; + const l = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; + return a.containerNumber ? `${l} · ${a.containerNumber}` : l; + }; + return ( + + {assigns.map(labelFor).join("\n")} +
+ } + > + + + {assigns[0].vehicle + ? [assigns[0].vehicle.code, assigns[0].vehicle.plateNumber].filter(Boolean).join(" · ") + : assigns[0].vehicleId} + + + +{assigns.length - 1} + + + + ); + } + return vehicleLabel(row.original) ?? Unassigned; + }, }, { id: "exactKm", @@ -1036,7 +1158,7 @@ const FirstMilePage = () => { {bulkMode ? ( - Assigning a vehicle to{" "} + Assigning vehicles to{" "} {selectedIds.length}{" "} selected {selectedIds.length === 1 ? "pickup" : "pickups"}. @@ -1046,28 +1168,80 @@ const FirstMilePage = () => { No unassigned pickups available. )} - o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value), + )} + value={row.vehicleId} + onChange={(v) => + setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x))) + } + searchable + clearable + disabled={assignVehicleOptions.length === 0} + /> + { + const value = e.currentTarget.value; + setVehicleRows((prev) => + prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value } : x)), + ); + }} + /> + {vehicleRows.length > 1 && ( + setVehicleRows((prev) => prev.filter((_, idx) => idx !== i))} + > + + + )} + + ))} + + @@ -1274,34 +1448,51 @@ const FirstMilePage = () => { {activeRecord && ( - + {bookingRef(activeRecord)} - - Customer - {customerName(activeRecord)} - - - Est. Distance (KM) - {activeRecord.estimatedKm ?? "—"} - - + Est. {activeRecord.estimatedKm ?? "—"} km + )} - setDistanceValue(String(v ?? ""))} - min={0} - step={0.1} - decimalScale={2} - /> + {(activeRecord?.vehicleAssignments?.length ?? 0) === 0 ? ( + Assign a vehicle before entering distance. + ) : ( + + {activeRecord!.vehicleAssignments!.map((a) => { + const v = a.vehicle; + const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; + return ( + + setDistanceRows((prev) => ({ ...prev, [a.vehicleId]: String(val ?? "") })) + } + min={0} + step={0.1} + decimalScale={2} + /> + ); + })} + + Total + + {Object.values(distanceRows) + .reduce((s, val) => s + (parseFloat(val) || 0), 0) + .toFixed(2)}{" "} + km + + + + )} diff --git a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts index a1c2ec8e2..ab5dfb7d9 100644 --- a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts @@ -23,6 +23,14 @@ export interface FirstMileBooking { originYard?: { id: string; label?: string } | null; destinationYard?: { id: string; label?: string } | null; cargoType?: { id: string; label?: string } | null; + /** Container lines — total container count drives how many trucks are needed. */ + bookingContainers?: Array<{ + id: string; + quantity: number; + containerNumber?: string | null; + containerSize?: string | null; + containerType?: { id: string; name?: string; label?: string; code?: string } | null; + }>; } export interface FirstMileVehicle { @@ -30,9 +38,12 @@ export interface FirstMileVehicle { plateNumber: string; manufacturer: string; model: string; + vehicleType?: string | null; code?: string | null; powerPlateNo?: string | null; trailerPlateNo?: string | null; + assignedDriverId?: string | null; + assignedDriverName?: string | null; } export interface FirstMileRecord { @@ -46,6 +57,14 @@ export interface FirstMileRecord { vehicleId?: string | null; booking?: FirstMileBooking | null; vehicle?: FirstMileVehicle | null; + /** Full set of vehicles serving this pickup (multi-truck). */ + vehicleAssignments?: Array<{ + id: string; + vehicleId: string; + containerNumber?: string | null; + distanceKm?: number | null; + vehicle?: FirstMileVehicle | null; + }>; /** Present only when an invoice has actually been generated (not on distance). */ invoice?: { id: string; number: string; status: string } | null; createdAt: string; @@ -69,6 +88,15 @@ export const firstMileService = { api.post(FM.ACCEPT(bookingReference)), remove: (id: string) => api.delete(FM.BY_ID(id)), + setVehicles: ( + id: string, + vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>, + ) => api.post(`${FM.BASE}/${id}/vehicles`, { vehicles }), + setDistances: ( + id: string, + distances: Array<{ vehicleId: string; distanceKm: number }>, + remainingPayment?: number, + ) => api.post(`${FM.BASE}/${id}/distances`, { distances, remainingPayment }), generateInvoice: (id: string) => api.post<{ id: string; invoiceNumber?: string } | null>(`${FM.BASE}/${id}/invoice`), }; From 00d4b729a2a43fafa5a676f47639e0f6e35cd134 Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 4 Jul 2026 01:59:16 +0000 Subject: [PATCH 073/122] fix --- .../src/pages/operations/FirstMilePage.tsx | 159 +++++++++++++++--- 1 file changed, 138 insertions(+), 21 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 7a5602751..efc3af7ca 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -202,21 +202,48 @@ const BookingInfo = ({ record }: { record: FirstMileRecord }) => { ); }; -const tripSlipRows = (record: FirstMileRecord): [string, string][] => [ - ["Customer", customerName(record)], - ["Service", serviceTypeName(record)], - ["Pickup location", pickupLocation(record)], - ["Destination yard", destinationYardName(record)], - ["Cargo", cargoDesc(record)], - ["Advanced Payment", formatPrice(record.advancedPayment, currencyOf(record))], - ["Post Payment", formatPrice(record.remainingPayment, currencyOf(record))], - ["Vehicle", vehicleLabel(record) ?? "Unassigned"], - ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], - ["Requested date", requestedDate(record)], - ["Est. Distance (KM)", record.estimatedKm != null ? String(record.estimatedKm) : "—"], - ["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"], - ["Status", STATUS_META[record.status].label], -]; +type TripSlipVehicle = NonNullable[number]; + +const tripSlipRows = ( + record: FirstMileRecord, + vehicle?: TripSlipVehicle | null, +): [string, string][] => { + // Per-vehicle block when a specific truck is chosen (its own driver, container + // and distance); else fall back to the record-level vehicle summary. + const vehicleRows: [string, string][] = vehicle + ? [ + [ + "Vehicle", + vehicle.vehicle + ? [vehicle.vehicle.code, vehicle.vehicle.plateNumber].filter(Boolean).join(" · ") + : vehicle.vehicleId, + ], + ["Driver", vehicle.vehicle?.assignedDriverName || "—"], + [ + "Container(s)", + vehicle.containerNumber || bookingContainerNumbers(record).join(", ") || "—", + ], + ["Distance (KM)", vehicle.distanceKm != null ? String(vehicle.distanceKm) : "—"], + ] + : [ + ["Vehicle", vehicleLabel(record) ?? "Unassigned"], + ["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"], + ]; + return [ + ["Customer", customerName(record)], + ["Service", serviceTypeName(record)], + ["Pickup location", pickupLocation(record)], + ["Destination yard", destinationYardName(record)], + ["Cargo", cargoDesc(record)], + ["Advanced Payment", formatPrice(record.advancedPayment, currencyOf(record))], + ["Post Payment", formatPrice(record.remainingPayment, currencyOf(record))], + ...vehicleRows, + ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], + ["Requested date", requestedDate(record)], + ["Est. Distance (KM)", record.estimatedKm != null ? String(record.estimatedKm) : "—"], + ["Status", STATUS_META[record.status].label], + ]; +}; const SampleStamp = () => ( @@ -282,7 +309,13 @@ const SignatureBlock = ({ title, stamp }: { title: string; stamp?: ReactNode }) ); -const TripSlipDocument = ({ record }: { record: FirstMileRecord }) => ( +const TripSlipDocument = ({ + record, + vehicle, +}: { + record: FirstMileRecord; + vehicle?: TripSlipVehicle | null; +}) => ( EDR Freight @@ -294,7 +327,7 @@ const TripSlipDocument = ({ record }: { record: FirstMileRecord }) => ( - {tripSlipRows(record).map(([label, value]) => ( + {tripSlipRows(record, vehicle).map(([label, value]) => ( ))} @@ -309,8 +342,8 @@ const TripSlipDocument = ({ record }: { record: FirstMileRecord }) => ( const escapeHtml = (v: string) => v.replace(/&/g, "&").replace(//g, ">"); -const buildTripSlipHtml = (record: FirstMileRecord) => { - const rows = tripSlipRows(record) +const buildTripSlipHtml = (record: FirstMileRecord, vehicle?: TripSlipVehicle | null) => { + const rows = tripSlipRows(record, vehicle) .map(([l, v]) => `${escapeHtml(l)}${escapeHtml(v)}`) .join(""); const sig = (title: string, withStamp: boolean) => ` @@ -369,6 +402,9 @@ const FirstMilePage = () => { const [detailOpen, setDetailOpen] = useState(false); const [tripSlipOpen, setTripSlipOpen] = useState(false); const [tripSlipRecord, setTripSlipRecord] = useState(null); + // Which vehicle the trip slip is for (per-truck), + the pre-print picker. + const [tripSlipVehicleId, setTripSlipVehicleId] = useState(null); + const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false); const [activeId, setActiveId] = useState(null); // Multi-vehicle assign: one row per truck — vehicle + the container it carries. const [vehicleRows, setVehicleRows] = useState< @@ -801,10 +837,27 @@ const FirstMilePage = () => { }; const handlePrintTripSlip = (record: FirstMileRecord) => { + // Always open the picker so the operator chooses which truck to print. setTripSlipRecord(record); + setTripSlipVehicleId(null); + setTripSlipSelectOpen(true); + }; + + const printBookingSlip = () => { + setTripSlipVehicleId(null); + setTripSlipSelectOpen(false); setTripSlipOpen(true); }; + const chooseTripSlipVehicle = (vehicleId: string) => { + setTripSlipVehicleId(vehicleId); + setTripSlipSelectOpen(false); + setTripSlipOpen(true); + }; + + const tripSlipVehicle = + tripSlipRecord?.vehicleAssignments?.find((a) => a.vehicleId === tripSlipVehicleId) ?? null; + const printTripSlip = () => { if (!tripSlipRecord) return; const win = window.open("", "_blank", "width=820,height=920"); @@ -812,8 +865,17 @@ const FirstMilePage = () => { toast({ title: "Pop-up blocked", description: "Allow pop-ups to print the trip slip.", variant: "destructive" }); return; } - win.document.write(buildTripSlipHtml(tripSlipRecord)); + win.document.write(buildTripSlipHtml(tripSlipRecord, tripSlipVehicle)); win.document.close(); + win.focus(); + // Explicit print after the doc paints (onload can miss with document.write). + setTimeout(() => { + try { + win.print(); + } catch { + /* window may have been closed */ + } + }, 250); }; const columns = useMemo((): ColumnDef[] => { @@ -1264,6 +1326,61 @@ const FirstMilePage = () => { + {/* Trip slip — pick a vehicle (multi-truck) */} + setTripSlipSelectOpen(false)} + title={Print trip slip — select vehicle} + radius="lg" + centered + > + + + {tripSlipRecord ? bookingRef(tripSlipRecord) : ""} — pick a truck to print its slip. + + {(tripSlipRecord?.vehicleAssignments ?? []).map((a) => { + const v = a.vehicle; + const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; + return ( + chooseTripSlipVehicle(a.vehicleId)} + style={{ cursor: "pointer" }} + className="hover:bg-gray-50" + > + + + + + {label} + + + Driver: {v?.assignedDriverName || "—"} + + + Container: {a.containerNumber || "—"} + + + + + + + + ); + })} + {(tripSlipRecord?.vehicleAssignments?.length ?? 0) === 0 && ( + No vehicles assigned yet. + )} + + + + + {/* Trip slip modal */} { centered > - {tripSlipRecord && } + {tripSlipRecord && } From cac6e4eaa1da19ddd4a5c52ec8838b3a475be1fc Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 4 Jul 2026 02:07:31 +0000 Subject: [PATCH 074/122] fix --- .../src/pages/operations/FirstMilePage.tsx | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index efc3af7ca..c3dac97d8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -40,6 +40,7 @@ import { } from "@mantine/core"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; +import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; import { ReceiveInventoryModal } from "@/components/warehouses/ReceiveInventoryModal"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { useToast } from "@/hooks/use-toast"; @@ -121,6 +122,50 @@ const bookingContainerNumbers = (record: FirstMileRecord): string[] => .map((c) => c.containerNumber) .filter((n): n is string => Boolean(n)); +/** Progress stages of the first-mile pickup workflow, for the detail stepper. */ +const computeFirstMileSteps = (record: FirstMileRecord): LastMileStepState[] => { + const exactKm = record.exactKm; + const inTransitOrPast = + record.status === "IN_TRANSIT" || record.status === "RECEIVED_TO_PORT"; + const flags = [ + record.status !== "PAYMENT_PENDING", // Ready to Transit + isAssigned(record), // Assign vehicle + inTransitOrPast, // In transit + exactKm != null, // Add distance + Boolean(record.invoice), // Generate Invoice + record.status === "RECEIVED_TO_PORT",// Received to port + ]; + // Current step = earliest incomplete one. + const activeIdx = flags.findIndex((f) => !f); + const labels = [ + "Ready to Transit", + "Assign vehicle", + "In transit", + "Add distance", + "Generate Invoice", + "Received to port", + ]; + const vehCount = record.vehicleAssignments?.length ?? (record.vehicleId ? 1 : 0); + const primaryPlate = + record.vehicle?.plateNumber ?? + record.vehicleAssignments?.[0]?.vehicle?.plateNumber ?? + null; + const details: (string | null)[] = [ + null, + vehCount > 1 ? `${vehCount} vehicles` : primaryPlate, + null, + exactKm != null ? `${exactKm} KM` : null, + record.invoice?.number ?? null, + null, + ]; + return labels.map((label, i) => ({ + label, + done: flags[i], + active: i === activeIdx, + detail: details[i], + })); +}; + // Paid = record flag set OR its invoice reached PAID. const isPaidRecord = (r: FirstMileRecord) => Boolean((r as { paid?: boolean }).paid) || @@ -1320,6 +1365,37 @@ const FirstMilePage = () => { > {activeRecord && } + {activeRecord && (activeRecord.vehicleAssignments?.length ?? 0) > 0 && ( + + Assigned vehicles + + {activeRecord.vehicleAssignments!.map((a) => { + const v = a.vehicle; + const label = v + ? [v.code, v.plateNumber].filter(Boolean).join(" · ") + : a.vehicleId; + return ( + + {label} + {a.containerNumber ? ( + + {a.containerNumber} + + ) : ( + No container no. + )} + + ); + })} + + + )} + {activeRecord && ( + + Pickup steps + + + )} From 2e31fd12e1b25f0adb00c79830ae7409340dbc31 Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 4 Jul 2026 02:26:19 +0000 Subject: [PATCH 075/122] fix --- .../modules/first-mile/first-mile.service.ts | 2 ++ .../modules/last-mile/last-mile.service.ts | 4 ++-- .../src/pages/operations/FirstMilePage.tsx | 24 +++++++++++++++---- .../src/pages/operations/LastMilePage.tsx | 24 +++++++++++++++---- .../src/services/first-mile.service.ts | 3 +++ .../src/services/last-mile.service.ts | 3 +++ 6 files changed, 48 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 7cdf6398b..5c12d94ee 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -208,6 +208,7 @@ export class FirstMileService { originYard: true, destinationYard: true, cargoType: true, + bookingContainers: { containerType: true, units: true }, }, vehicle: true, vehicleAssignments: { vehicle: true }, @@ -255,6 +256,7 @@ export class FirstMileService { originYard: true, destinationYard: true, cargoType: true, + bookingContainers: { containerType: true, units: true }, }, vehicle: true, vehicleAssignments: { vehicle: true }, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index a42ce289a..22f25a6aa 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -170,7 +170,7 @@ export class LastMileService { const [data, total] = await this.lastMileRepository.findAndCount({ where, relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true } }, + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } }, vehicle: true, vehicleAssignments: { vehicle: true }, }, @@ -195,7 +195,7 @@ export class LastMileService { async findById(id: string): Promise { const record = await this.lastMileRepository.findById(id, { relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true } }, + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } }, vehicle: true, vehicleAssignments: { vehicle: true }, }, diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index c3dac97d8..56d84470b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -116,11 +116,25 @@ const vehicleLabel = (record: FirstMileRecord) => { const isAssigned = (record: FirstMileRecord) => Boolean(record.vehicleId) || Boolean(record.vehicleAssignments?.length); -/** Container numbers on a booking, in line order (skips lines without one). */ -const bookingContainerNumbers = (record: FirstMileRecord): string[] => - (record.booking?.bookingContainers ?? []) - .map((c) => c.containerNumber) - .filter((n): n is string => Boolean(n)); +/** Real per-physical-container numbers on a booking, in order. Prefers each + * line's `units` (the actual numbers) over the line-level number (often a + * "TBD-…" placeholder). One entry per physical container, for per-truck prefill. */ +const bookingContainerNumbers = (record: FirstMileRecord): string[] => { + const out: string[] = []; + const real = (n?: string | null): n is string => + Boolean(n) && !/^TBD/i.test(n!.trim()); + for (const c of record.booking?.bookingContainers ?? []) { + const units = [...(c.units ?? [])].sort( + (a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0), + ); + if (units.length) { + for (const u of units) if (real(u.containerNumber)) out.push(u.containerNumber!); + } else if (real(c.containerNumber)) { + out.push(c.containerNumber!); + } + } + return out; +}; /** Progress stages of the first-mile pickup workflow, for the detail stepper. */ const computeFirstMileSteps = (record: FirstMileRecord): LastMileStepState[] => { diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index d7545fd7b..a43790346 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -128,11 +128,25 @@ const requiredVehicles = (record: LastMileRecord) => { return n > 0 ? Math.ceil(n / CONTAINERS_PER_VEHICLE) : 0; }; -/** Container numbers on a booking, in line order (skips lines without one). */ -const bookingContainerNumbers = (record: LastMileRecord): string[] => - (record.booking?.bookingContainers ?? []) - .map((c) => c.containerNumber) - .filter((n): n is string => Boolean(n)); +/** Real per-physical-container numbers on a booking, in order. Prefers each + * line's `units` (the actual numbers) over the line-level number (often a + * "TBD-…" placeholder). One entry per physical container, for per-truck prefill. */ +const bookingContainerNumbers = (record: LastMileRecord): string[] => { + const out: string[] = []; + const real = (n?: string | null): n is string => + Boolean(n) && !/^TBD/i.test(n!.trim()); + for (const c of record.booking?.bookingContainers ?? []) { + const units = [...(c.units ?? [])].sort( + (a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0), + ); + if (units.length) { + for (const u of units) if (real(u.containerNumber)) out.push(u.containerNumber!); + } else if (real(c.containerNumber)) { + out.push(c.containerNumber!); + } + } + return out; +}; /** Container badges for a booking: the container number when known, else the * type × quantity. */ diff --git a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts index ab5dfb7d9..6fc80a282 100644 --- a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts @@ -30,6 +30,9 @@ export interface FirstMileBooking { containerNumber?: string | null; containerSize?: string | null; containerType?: { id: string; name?: string; label?: string; code?: string } | null; + /** Physical containers under this line — their real numbers (line-level + * containerNumber is often a TBD placeholder). */ + units?: Array<{ id: string; containerNumber?: string | null; sortOrder?: number }>; }>; } diff --git a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts index 2f8a1cec5..88e4bb9c0 100644 --- a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts @@ -30,6 +30,9 @@ export interface LastMileBooking { containerNumber?: string | null; containerSize?: string | null; containerType?: { id: string; name?: string; label?: string; code?: string } | null; + /** Physical containers under this line — their real numbers (line-level + * containerNumber is often a TBD placeholder). */ + units?: Array<{ id: string; containerNumber?: string | null; sortOrder?: number }>; }>; } From 8b20924ba8e71c59577270add33686c689fa46c7 Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 4 Jul 2026 02:45:10 +0000 Subject: [PATCH 076/122] fix --- .../src/pages/operations/FirstMilePage.tsx | 15 ++++++++++----- .../src/pages/operations/LastMilePage.tsx | 15 ++++++++++----- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 56d84470b..9f5efd993 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -20,6 +20,7 @@ import type { ColumnDef } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { ActionIcon, + Autocomplete, Badge, Box, Button, @@ -1307,17 +1308,21 @@ const FirstMilePage = () => { clearable disabled={assignVehicleOptions.length === 0} /> - + n === row.containerNumber || + !vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n), + )} value={row.containerNumber} - onChange={(e) => { - const value = e.currentTarget.value; + onChange={(value) => setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value } : x)), - ); - }} + ) + } /> {vehicleRows.length > 1 && ( { clearable disabled={assignVehicleOptions.length === 0} /> - + n === row.containerNumber || + !vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n), + )} value={row.containerNumber} - onChange={(e) => { - const value = e.currentTarget.value; + onChange={(value) => setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value } : x)), - ); - }} + ) + } /> {vehicleRows.length > 1 && ( Date: Sat, 4 Jul 2026 03:02:17 +0000 Subject: [PATCH 077/122] fix --- .../src/pages/operations/FirstMilePage.tsx | 31 +++++++++++++++++- .../src/pages/operations/LastMilePage.tsx | 32 ++++++++++++++++++- .../backoffice/src/types/booking.ts | 2 ++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 9f5efd993..2b130848c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -664,6 +664,35 @@ const FirstMilePage = () => { return opts; }, [vehicleOptions, activeRecord]); + // Full booking (with container units) for the assign modal's container dropdown. + // Fetched on open so container numbers show regardless of what the list embeds. + const { data: assignBooking } = useQuery({ + queryKey: activeRecord?.bookingId + ? QUERY_KEYS.BOOKINGS.byId(activeRecord.bookingId) + : ["bookings", "detail", "none"], + queryFn: () => bookingsService.getById(activeRecord!.bookingId), + enabled: assignOpen && !bulkMode && Boolean(activeRecord?.bookingId), + }); + + // Container-number options for the dropdown = the booking's real per-container + // numbers (units), falling back to whatever the list record carried. + const containerOptions = useMemo(() => { + const real = (n?: string | null): n is string => + Boolean(n) && !/^TBD/i.test(n!.trim()); + const out: string[] = []; + for (const c of assignBooking?.bookingContainers ?? []) { + const units = [...(c.units ?? [])].sort( + (a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0), + ); + if (units.length) { + for (const u of units) if (real(u.containerNumber)) out.push(u.containerNumber); + } else if (real(c.containerNumber)) { + out.push(c.containerNumber); + } + } + return out.length ? out : activeRecord ? bookingContainerNumbers(activeRecord) : []; + }, [assignBooking, activeRecord]); + const selectedIds = useMemo( () => Object.keys(rowSelection).filter((id) => rowSelection[id]), [rowSelection], @@ -1312,7 +1341,7 @@ const FirstMilePage = () => { style={{ flex: 1 }} label={i === 0 ? "Container no." : undefined} placeholder="Container number" - data={(activeRecord ? bookingContainerNumbers(activeRecord) : []).filter( + data={containerOptions.filter( (n) => n === row.containerNumber || !vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n), diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 5ee5f05b4..4b5cec080 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -42,6 +42,7 @@ import { } from "@mantine/core"; import type { ArrivalQueueItem, ImportUnloadedItem, WarehouseInventoryItem } from "@/types/warehouse"; import { warehouseService } from "@/services/warehouse.service"; +import { bookingsService } from "@/services/bookings.service"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; @@ -853,6 +854,35 @@ const LastMilePage = () => { return opts; }, [vehicleOptions, activeRecord]); + // Full booking (with container units) for the assign modal's container dropdown. + // Fetched on open so container numbers show regardless of what the list embeds. + const { data: assignBooking } = useQuery({ + queryKey: activeRecord?.bookingId + ? QUERY_KEYS.BOOKINGS.byId(activeRecord.bookingId) + : ["bookings", "detail", "none"], + queryFn: () => bookingsService.getById(activeRecord!.bookingId), + enabled: assignOpen && !bulkMode && Boolean(activeRecord?.bookingId), + }); + + // Container-number options for the dropdown = the booking's real per-container + // numbers (units), falling back to whatever the list record carried. + const containerOptions = useMemo(() => { + const real = (n?: string | null): n is string => + Boolean(n) && !/^TBD/i.test(n!.trim()); + const out: string[] = []; + for (const c of assignBooking?.bookingContainers ?? []) { + const units = [...(c.units ?? [])].sort( + (a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0), + ); + if (units.length) { + for (const u of units) if (real(u.containerNumber)) out.push(u.containerNumber); + } else if (real(c.containerNumber)) { + out.push(c.containerNumber); + } + } + return out.length ? out : activeRecord ? bookingContainerNumbers(activeRecord) : []; + }, [assignBooking, activeRecord]); + const pickupReadyByBooking = useMemo(() => { const map = new Map(); for (const row of pickupReadyRows) { @@ -1671,7 +1701,7 @@ const LastMilePage = () => { style={{ flex: 1 }} label={i === 0 ? "Container no." : undefined} placeholder="Container number" - data={(activeRecord ? bookingContainerNumbers(activeRecord) : []).filter( + data={containerOptions.filter( (n) => n === row.containerNumber || !vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n), diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index 71d9fd793..2c0be4726 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -83,6 +83,8 @@ export interface BookingContainerUnit { export interface BookingContainerLine { id: string; containerTypeId: string; + /** Line-level number — often a "TBD-…" placeholder; real numbers live in units. */ + containerNumber?: string | null; quantity: number; vgmPerUnitTons: number; containerType?: { From 9d3efe7f15c30ff90e0236447aa69d58a5de7d38 Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 4 Jul 2026 03:47:36 +0000 Subject: [PATCH 078/122] fix --- .../components/ContainerAllocationTable.tsx | 11 +- .../gl-actions/TransportDocumentCard.tsx | 2 +- .../backoffice/src/constants/URLS.ts | 1 - .../src/pages/bookings/BookingDetailPage.tsx | 10 +- .../pages/contracts/GlClearanceDetailPage.tsx | 2 +- .../src/pages/fleet/DriverDetailPage.tsx | 2 +- .../src/pages/fleet/FinancialReportsPage.tsx | 62 +++-- .../src/pages/fleet/FleetDashboard.tsx | 45 ++-- .../src/pages/fleet/FleetResourcePage.tsx | 2 +- .../src/pages/fleet/FuelPurchasePage.tsx | 54 ++-- .../src/pages/fleet/FuelStatsPage.tsx | 33 ++- .../src/pages/fleet/MaintenancePage.tsx | 235 +++++++++++------- .../backoffice/src/pages/fleet/RoutesPage.tsx | 1 - .../src/pages/fleet/TrackingPage.tsx | 51 ++-- .../src/pages/fleet/VehicleDetailPage.tsx | 2 +- .../src/pages/fleet/config/resources.ts | 2 +- .../src/pages/ruleEngine/CargoTypesPage.tsx | 1 - .../src/services/bookings.service.ts | 5 - .../src/services/vehicles.service.ts | 3 + 19 files changed, 304 insertions(+), 220 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx index 8971d4ccb..67407486f 100644 --- a/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx @@ -33,7 +33,6 @@ export interface ContainerAllocationTableProps { * Displays containers with type/qty, vehicle dropdown per row, and save action. */ export function ContainerAllocationTable({ - bookingId, containers, onSave, }: ContainerAllocationTableProps) { @@ -43,7 +42,10 @@ export function ContainerAllocationTable({ const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ queryKey: ["vehicles", "free"], - queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }), + queryFn: async () => { + const res = await vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }); + return res.data ?? []; + }, }); const vehicleOptions = useMemo( @@ -85,13 +87,12 @@ export function ContainerAllocationTable({ }); const allocatedCount = Object.values(allocations).filter(Boolean).length; - const allAllocated = allocatedCount === containers.length; if (vehiclesLoading) { return ( - + - + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/TransportDocumentCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/TransportDocumentCard.tsx index 52bcfef98..1ef5d1139 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/TransportDocumentCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/TransportDocumentCard.tsx @@ -32,7 +32,7 @@ export function TransportDocumentCard({ bookingId }: { bookingId: string }) { if (!file) return; setLoading(true); try { - await contractsService.uploadTransportDocument(bookingId, file); + await contractsService.uploadTransportDocument(bookingId, { transportDocument: file }); toast.success("Transport document uploaded"); } catch (e) { toast.error(e instanceof Error ? e.message : "Upload failed"); diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index d0f09f508..e1e379bf0 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -282,7 +282,6 @@ export const URL_CONSTANTS = { `/train-scheduling/schedules/${id}/run-allocation`, DOC_REVIEW_COMPLETE: (id: string) => `/train-scheduling/schedules/${id}/doc-review-complete`, - RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`, ASSIGN_UNASSIGNED_BOOKING: (id: string) => `/train-scheduling/schedules/${id}/assign-unassigned-booking`, BOOKING_WINDOW: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx index d49e24f95..7408b9aca 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx @@ -18,8 +18,8 @@ import { type BookingDetailView, } from "@/components/bookings/detail"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; -import ContainerAllocationTable from "@/components/ContainerAllocationTable"; -import { api } from "@/services/api"; +import { ContainerAllocationTable } from "@/components/ContainerAllocationTable"; +import { api } from "@/auth/http"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; const BookingDetailPage = () => { @@ -160,9 +160,9 @@ const BookingDetailPage = () => { type: c.containerType?.label ?? "Unknown", qty: c.quantity, }))} - onSave={(allocations) => - allocateMutation.mutateAsync({ allocations }) - } + onSave={async (allocations) => { + await allocateMutation.mutateAsync({ allocations }); + }} /> - + {data.kind === "booking" ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx index dfdf822e4..d5e8655be 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx @@ -70,7 +70,7 @@ const DriverDetailPage = () => { driver?.licenseExpiryDate && new Date(driver.licenseExpiryDate) < new Date(); return ( - + navigate("/dashboard/drivers")} aria-label="Back"> diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx index 73a7456af..a62c4cbc5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx @@ -1,26 +1,10 @@ import { useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Card, Button, Stack, Group, Grid, Select, Text, ThemeIcon, RingProgress, Container } from '@mantine/core'; +import { Card, Stack, Group, Grid, Select, Text, RingProgress, Container, Title } from '@mantine/core'; +import Breadcrumbs from '@/components/ui/Breadcrumbs'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; -import { api } from '@/services/api'; +import { api } from '@/auth/http'; import { vehiclesService } from '@/services/vehicles.service'; -import { freightBrand } from '@/theme/freight-brand'; - -interface FuelStats { - vehicleId: string; - totalPurchases: number; - totalFuel: number; - totalCost: number; - averageCostPerLiter: number; -} - -interface MaintenanceStats { - vehicleId: string; - totalCost: number; - numberOfMaintenanceItems: number; - averageCostPerMaintenance: number; - costByType: Record; -} interface CombinedReport { vehicleId: string; @@ -31,6 +15,9 @@ interface CombinedReport { maintenancePercentage: number; } +const etb = (n: number) => + 'ETB ' + Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + export function FinancialReportsPage() { const [selectedVehicle, setSelectedVehicle] = useState(null); const [months, setMonths] = useState('12'); @@ -45,13 +32,21 @@ export function FinancialReportsPage() { const { data: fuelStats } = useQuery({ queryKey: QUERY_KEYS.FUEL.stats(selectedVehicle || ''), - queryFn: () => selectedVehicle ? api.get(`/fuel/stats/${selectedVehicle}?months=${months}`) : Promise.resolve(null), + queryFn: async () => { + if (!selectedVehicle) return Promise.resolve(null); + const res = await api.get(`/fuel/stats/${selectedVehicle}?months=${months}`); + return res.data; + }, enabled: !!selectedVehicle, }); const { data: maintenanceStats } = useQuery({ queryKey: QUERY_KEYS.MAINTENANCE.stats(selectedVehicle || ''), - queryFn: () => selectedVehicle ? api.get(`/maintenance/stats/${selectedVehicle}`) : Promise.resolve(null), + queryFn: async () => { + if (!selectedVehicle) return Promise.resolve(null); + const res = await api.get(`/maintenance/stats/${selectedVehicle}`); + return res.data; + }, enabled: !!selectedVehicle, }); @@ -60,11 +55,11 @@ export function FinancialReportsPage() { [vehicles] ); - const report = useMemo(() => { - if (!fuelStats || !maintenanceStats) return null; + const report = useMemo(() => { + if (!fuelStats && !maintenanceStats) return null; - const fuelCost = Number(fuelStats.totalCost) || 0; - const maintenanceCost = Number(maintenanceStats.totalCost) || 0; + const fuelCost = Number(fuelStats?.totalCost ?? 0) || 0; + const maintenanceCost = Number(maintenanceStats?.totalCost ?? 0) || 0; const total = fuelCost + maintenanceCost; return { @@ -93,6 +88,9 @@ export function FinancialReportsPage() { return ( + + Financial Reports + Fleet Financial Analysis @@ -126,13 +124,13 @@ export function FinancialReportsPage() { <> - + - + - + @@ -141,7 +139,7 @@ export function FinancialReportsPage() { Monthly Avg - ${(report.totalOperatingCost / parseInt(months)).toFixed(2)} + {etb(report.totalOperatingCost / parseInt(months))} @@ -166,7 +164,7 @@ export function FinancialReportsPage() { + {report.fuelPercentage}% } @@ -184,7 +182,7 @@ export function FinancialReportsPage() { + {report.maintenancePercentage}% } @@ -228,7 +226,7 @@ export function FinancialReportsPage() { Avg Maintenance Cost - ${maintenanceStats?.averageCostPerMaintenance?.toFixed(2) || '0.00'} + {etb(maintenanceStats?.averageCostPerMaintenance ?? 0)}
diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx index a311a16eb..32341505a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx @@ -1,7 +1,8 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Card, Stack, Group, Grid, Text, ThemeIcon, Progress, Badge, Table, RingProgress, Container, Title, Box, Tabs, Button } from '@mantine/core'; -import { Truck, Fuel, Wrench, TrendingUp, AlertCircle, Users, User, MapPin, Calendar, BarChart3 } from 'lucide-react'; +import { Card, Stack, Group, Grid, Text, ThemeIcon, Progress, Badge, Table, RingProgress, Container, Title, Box, Tabs } from '@mantine/core'; +import { Truck, Fuel, Wrench, AlertCircle, Users, User, MapPin } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; import Breadcrumbs from '@/components/ui/Breadcrumbs'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { api } from '@/auth/http'; @@ -39,7 +40,20 @@ interface FleetMetrics { assignedDrivers: number; } -const StatCard = ({ icon: Icon, label, value, color = 'edr-green', change }: any) => ( +/** ETB money, whole-birr for dashboard headlines. */ +const etb = (n: number) => `ETB ${(Number(n) || 0).toLocaleString('en-US', { maximumFractionDigits: 0 })}`; +/** Safe percentage — 0 when the denominator is 0 (empty fleet). */ +const pct = (n: number, d: number) => (d > 0 ? (n / d) * 100 : 0); + +interface StatCardProps { + icon: LucideIcon; + label: string; + value: string | number; + color?: string; + change?: number; +} + +const StatCard = ({ icon: Icon, label, value, color = 'edr-green', change }: StatCardProps) => ( @@ -111,8 +125,8 @@ export function FleetDashboard() { const totalDrivers = (drivers as Driver[]).length; const assignedDrivers = (drivers as Driver[]).filter(d => d.assignedVehicle).length; - const fuelTotal = fuelStats?.totalCost || 0; - const maintenanceTotal = maintenanceStats?.totalCost || 0; + const fuelTotal = Number(fuelStats?.totalCost) || 0; + const maintenanceTotal = Number(maintenanceStats?.totalCost) || 0; return { totalVehicles, @@ -152,10 +166,10 @@ export function FleetDashboard() { - + - + @@ -173,7 +187,7 @@ export function FleetDashboard() { Active Vehicles {metrics.activeVehicles} / {metrics.totalVehicles} - +
@@ -189,7 +203,7 @@ export function FleetDashboard() { Idle / Under Maintenance {metrics.totalVehicles - metrics.activeVehicles} - +
@@ -212,7 +226,7 @@ export function FleetDashboard() { label={
- ${operatingCost.toFixed(0)} + {etb(operatingCost)} Total Cost @@ -325,13 +339,12 @@ export function FleetDashboard() { {d.licenseNumber || 'N/A'} - + {d.phone && ( - - - {d.phone} - - + + + {d.phone} + )} {d.email && {d.email}} 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 ff7caa9d2..f7bee5b41 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -357,7 +357,7 @@ const FleetResourcePage = () => { const itemLabel = config.label.toLowerCase(); return ( - + diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx index b3c168df3..6ca9f8870 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx @@ -1,11 +1,11 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { - Box, Button, Card, Container, Group, + Loader, Modal, NumberInput, Select, @@ -17,13 +17,12 @@ import { Badge, Grid, } from "@mantine/core"; -import { Plus, Trash2 } from "lucide-react"; +import { Plus } from "lucide-react"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { useToast } from "@/hooks/use-toast"; import { api } from "@/auth/http"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service"; -import { freightBrand } from "@/theme/freight-brand"; interface FuelPurchase { id: string; @@ -67,7 +66,7 @@ export default function FuelPurchasePage() { }); // Fetch fuel purchases - const { data: purchasesData = [] } = useQuery({ + const { data: purchasesData = [], isLoading: isLoadingPurchases } = useQuery({ queryKey: ["fuel-purchases"], queryFn: async () => { const res = await api.get("/fuel/purchases"); @@ -104,8 +103,8 @@ export default function FuelPurchasePage() { onError: (error: any) => { toast({ title: "Error recording purchase", - message: error?.response?.data?.message || "Failed to record fuel purchase", - color: "red", + description: error?.response?.data?.message || "Failed to record fuel purchase", + variant: "destructive", }); }, }); @@ -118,6 +117,17 @@ export default function FuelPurchasePage() { const totalCost = formData.liters * formData.costPerLiter; + // Aggregate stats (guarded against divide-by-zero when there are no purchases) + const totalLiters = (purchasesData as FuelPurchase[]).reduce( + (sum, p) => sum + Number(p.liters), + 0 + ); + const totalPurchaseCost = (purchasesData as FuelPurchase[]).reduce( + (sum, p) => sum + Number(p.totalCost), + 0 + ); + const avgPricePerLiter = totalLiters > 0 ? totalPurchaseCost / totalLiters : 0; + return ( @@ -147,10 +157,7 @@ export default function FuelPurchasePage() { Total Liters - {purchasesData - .reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0) - .toFixed(2)}{" "} - L + {totalLiters.toFixed(2)} L @@ -160,9 +167,7 @@ export default function FuelPurchasePage() { Total Cost - ETB {purchasesData - .reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0) - .toLocaleString("en-US", { maximumFractionDigits: 2 })} + ETB {totalPurchaseCost.toLocaleString("en-US", { maximumFractionDigits: 2 })} @@ -172,11 +177,7 @@ export default function FuelPurchasePage() { Avg Price/L - ETB{" "} - {( - purchasesData.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0) / - purchasesData.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0) || 0 - ).toFixed(2)} + ETB {avgPricePerLiter.toFixed(2)} @@ -197,6 +198,23 @@ export default function FuelPurchasePage() { + {isLoadingPurchases ? ( + + + + + + + + ) : purchasesData.length === 0 ? ( + + + + No fuel purchases recorded yet. + + + + ) : null} {(purchasesData as FuelPurchase[])?.map((purchase) => ( {(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx index 04c872966..904347cf0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx @@ -1,20 +1,11 @@ import { useQuery } from "@tanstack/react-query"; -import { Box, Card, Container, Grid, Group, Select, Stack, Table, Text, Title, Badge } from "@mantine/core"; +import { Card, Container, Grid, Group, Loader, Select, Stack, Text, Title } from "@mantine/core"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { api } from "@/auth/http"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service"; import { useState } from "react"; -interface FuelStats { - vehicleId: string; - totalPurchases: number; - totalLiters: number; - totalCost: number; - averagePricePerLiter: number; - dateRange: { startDate: string; endDate: string }; -} - export default function FuelStatsPage() { const [selectedVehicleId, setSelectedVehicleId] = useState(""); const [monthsBack, setMonthsBack] = useState("12"); @@ -29,7 +20,7 @@ export default function FuelStatsPage() { }); // Fetch fuel stats - const { data: statsData } = useQuery({ + const { data: statsData, isFetching: isStatsFetching } = useQuery({ queryKey: ["fuel-stats", selectedVehicleId, monthsBack], queryFn: async () => { if (!selectedVehicleId) return null; @@ -102,7 +93,7 @@ export default function FuelStatsPage() { Total Purchases - {statsData.totalPurchases} + {Number(statsData.totalPurchases) || 0} @@ -112,7 +103,7 @@ export default function FuelStatsPage() { Total Fuel - {statsData.totalLiters.toFixed(2)} L + {(Number(statsData.totalLiters) || 0).toFixed(2)} L @@ -122,7 +113,7 @@ export default function FuelStatsPage() { Total Cost - ETB {statsData.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })} + ETB {(Number(statsData.totalCost) || 0).toLocaleString("en-US", { maximumFractionDigits: 2 })} @@ -132,7 +123,7 @@ export default function FuelStatsPage() { Avg Price/L - ETB {statsData.averagePricePerLiter.toFixed(2)} + ETB {(Number(statsData.averagePricePerLiter) || 0).toFixed(2)} @@ -172,15 +163,15 @@ export default function FuelStatsPage() { {selectedVehicle?.plateNumber} consumed{" "} - {statsData.totalLiters.toFixed(2)} liters + {(Number(statsData.totalLiters) || 0).toFixed(2)} liters {" "} over the last {monthsBack} months, costing{" "} - ETB {statsData.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })} + ETB {(Number(statsData.totalCost) || 0).toLocaleString("en-US", { maximumFractionDigits: 2 })} . Average fuel price was{" "} - ETB {statsData.averagePricePerLiter.toFixed(2)} per liter + ETB {(Number(statsData.averagePricePerLiter) || 0).toFixed(2)} per liter . @@ -188,6 +179,12 @@ export default function FuelStatsPage() { + ) : selectedVehicleId && isStatsFetching ? ( + + + + + ) : ( diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx index b2bd155d3..3aa2506b5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx @@ -1,12 +1,26 @@ -import { useState, useMemo } from 'react'; +import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Card, Button, Modal, Stack, Group, Grid, Select, TextInput, NumberInput, Table, Badge, Text, Container } from '@mantine/core'; -import { DateInput } from '@mantine/dates'; +import { + Card, + Button, + Modal, + Stack, + Group, + Select, + TextInput, + NumberInput, + Table, + Badge, + Text, + Title, + Container, +} from '@mantine/core'; import { Plus } from 'lucide-react'; +import Breadcrumbs from '@/components/ui/Breadcrumbs'; +import { useToast } from '@/hooks/use-toast'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; -import { api } from '@/services/api'; -import { vehiclesService } from '@/services/vehicles.service'; -import { freightBrand } from '@/theme/freight-brand'; +import { api } from '@/auth/http'; +import { vehiclesService, type Vehicle as VehicleType } from '@/services/vehicles.service'; interface MaintenanceSchedule { id: string; @@ -21,21 +35,23 @@ interface MaintenanceSchedule { serviceProvider?: string; } +const emptyForm = { + maintenanceType: 'PREVENTIVE', + description: '', + scheduledDate: new Date().toISOString().split('T')[0], + estimatedCost: 0, + serviceProvider: '', + notes: '', +}; + export function MaintenancePage() { + const { toast } = useToast(); + const queryClient = useQueryClient(); const [selectedVehicle, setSelectedVehicle] = useState(null); const [openScheduleModal, setOpenScheduleModal] = useState(false); - const [formData, setFormData] = useState({ - maintenanceType: 'PREVENTIVE', - description: '', - scheduledDate: new Date(), - estimatedCost: 0, - serviceProvider: '', - notes: '', - }); + const [formData, setFormData] = useState(emptyForm); - const queryClient = useQueryClient(); - - const { data: vehicles } = useQuery({ + const { data: vehiclesData } = useQuery({ queryKey: QUERY_KEYS.VEHICLES.list(), queryFn: async () => { const res = await vehiclesService.getAll({ limit: 1000 }); @@ -45,36 +61,49 @@ export function MaintenancePage() { const { data: upcoming, isLoading } = useQuery({ queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''), - queryFn: () => selectedVehicle ? api.get(`/maintenance/upcoming/${selectedVehicle}`) : Promise.resolve([]), + queryFn: async () => { + if (!selectedVehicle) return []; + const res = await api.get(`/maintenance/upcoming/${selectedVehicle}`); + return res.data || []; + }, enabled: !!selectedVehicle, }); + const upcomingList: MaintenanceSchedule[] = Array.isArray(upcoming) ? upcoming : []; + const scheduleMutation = useMutation({ mutationFn: async () => { if (!selectedVehicle) return; - return api.post('/maintenance/schedules', { + const res = await api.post('/maintenance/schedules', { vehicleId: selectedVehicle, ...formData, }); + return res.data; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || '') }); + toast({ title: 'Maintenance scheduled' }); + queryClient.invalidateQueries({ + queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''), + }); setOpenScheduleModal(false); - setFormData({ - maintenanceType: 'PREVENTIVE', - description: '', - scheduledDate: new Date(), - estimatedCost: 0, - serviceProvider: '', - notes: '', + setFormData(emptyForm); + }, + onError: (err: any) => { + toast({ + title: 'Error', + description: err?.response?.data?.message ?? 'Failed', + variant: 'destructive', }); }, }); - const vehicleOptions = useMemo( - () => vehicles?.map(v => ({ label: v.registrationNumber || v.id, value: v.id })) || [], - [vehicles] - ); + const vehicleOptions = + vehiclesData?.map((v: VehicleType) => ({ + value: v.id, + label: v.plateNumber + ? `${v.plateNumber} - ${v.manufacturer} ${v.model}` + : v.registrationNumber || v.id, + })) || []; const statusColor = (status: string) => { const colors: Record = { @@ -88,66 +117,85 @@ export function MaintenancePage() { return ( + + + + Maintenance + + + - - - - Schedule Maintenance - - - - + - {errors.password && ( -

{errors.password.message}

- )} -
- -
- - - {errors.confirmPassword && ( -

{errors.confirmPassword.message}

- )} -
- diff --git a/apps/edr-passenger-web/portal/src/app/verify-account/page.tsx b/apps/edr-passenger-web/portal/src/app/verify-account/page.tsx new file mode 100644 index 000000000..f9f0b61c9 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/verify-account/page.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { Suspense, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { Train, ArrowLeft, ShieldCheck } from 'lucide-react'; +import { iamAuthApi } from '@/lib/api/auth'; +import { useAuthStore } from '@/lib/auth-store'; + +// Mirrors the IAM set-password requirement (class-validator @IsStrongPassword defaults): +// min length 8, with lower- and upper-case letters, a number, and a symbol. +function isStrongPassword(pw: string): boolean { + return ( + pw.length >= 8 && + /[a-z]/.test(pw) && + /[A-Z]/.test(pw) && + /[0-9]/.test(pw) && + /[^A-Za-z0-9]/.test(pw) + ); +} + +function VerifyAccountContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const login = useAuthStore((s) => s.login); + + const email = searchParams.get('email') || ''; + const userId = searchParams.get('userId') || ''; + const phone = searchParams.get('phone') || ''; + const linkValid = Boolean(email && userId); + + const [verificationCode, setVerificationCode] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [resending, setResending] = useState(false); + const [resent, setResent] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + if (!verificationCode.trim()) { + setError('Enter the verification code sent to your phone.'); + return; + } + if (!isStrongPassword(newPassword)) { + setError('Password must be at least 8 characters and include upper- and lower-case letters, a number, and a symbol.'); + return; + } + if (newPassword !== confirmPassword) { + setError('Passwords do not match.'); + return; + } + setLoading(true); + try { + // Completes signup: PATCH /v1/auth/set-password with the SMS code, which activates + // the account and sets the password. + await iamAuthApi.resetPassword({ + userId, + email, + verificationCode: verificationCode.trim(), + newPassword, + confirmPassword, + }); + // Auto-login with the freshly-set password; login lazy-provisions the passenger record. + await login(email, newPassword); + router.push('/booking/search'); + } catch (err: any) { + const msg = err.response?.data?.message || err.message || ''; + setError(msg || 'Could not verify your account. Check the code and try again, or resend it.'); + setLoading(false); + } + }; + + const handleResend = async () => { + setError(''); + setResent(false); + setResending(true); + try { + await iamAuthApi.resendRegistrationCode({ email, phoneNumber: phone }); + setResent(true); + } catch { + setError('Could not resend the code. Please try again in a moment.'); + } finally { + setResending(false); + } + }; + + return ( +
+
+
+
+
+ +
+
+

Verify your account

+ {linkValid && ( +

+ Enter the code we sent to your phone and choose a password for{' '} + {email}. +

+ )} +
+ +
+ {!linkValid ? ( +
+
+ This verification link is invalid or incomplete. Please start registration again. +
+ + Back to registration + +
+ ) : ( +
+ {error && ( +
+ {error} +
+ )} + {resent && !error && ( +
+ +

A new code has been sent to your phone.

+
+ )} + +
+ + { setVerificationCode(e.target.value); setError(''); }} + className="input-field tracking-widest" + placeholder="123456" + maxLength={6} + required + /> +
+ +
+ + { setNewPassword(e.target.value); setError(''); }} + className="input-field" + placeholder="••••••••" + autoComplete="new-password" + minLength={8} + required + /> +

+ At least 8 characters with upper & lower case, a number, and a symbol. +

+
+ +
+ + { setConfirmPassword(e.target.value); setError(''); }} + className="input-field" + placeholder="••••••••" + autoComplete="new-password" + minLength={8} + required + /> +
+ + + + + + + + Back to registration + + + )} +
+
+
+ ); +} + +export default function VerifyAccountPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/portal/src/lib/api/auth.ts b/apps/edr-passenger-web/portal/src/lib/api/auth.ts index aa5272173..14e9a16a1 100644 --- a/apps/edr-passenger-web/portal/src/lib/api/auth.ts +++ b/apps/edr-passenger-web/portal/src/lib/api/auth.ts @@ -11,6 +11,10 @@ export const iamAuthApi = { forgotPassword: (email: string) => axios.post(`${API_URL}/v1/auth/forgot-password`, { email }), + // Re-sends the registration verification code for a still-pending account. + resendRegistrationCode: (data: { email: string; phoneNumber: string }) => + axios.post(`${API_URL}/auth/register/resend-code`, data), + // Completes the forgot-password flow using the link sent via SMS: // ${FE_BASE_URL}/reset-password?email=..&userId=..&verificationCode=.. resetPassword: (data: { diff --git a/apps/edr-passenger-web/portal/src/lib/auth-store.ts b/apps/edr-passenger-web/portal/src/lib/auth-store.ts index 2157cfc0a..0d6e46fd9 100644 --- a/apps/edr-passenger-web/portal/src/lib/auth-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/auth-store.ts @@ -31,7 +31,7 @@ interface AuthState { isAuthenticated: boolean; isInitialized: boolean; login: (email: string, password: string) => Promise; - register: (data: RegisterData) => Promise; + register: (data: RegisterData) => Promise; logout: () => Promise; setUser: (user: User, token: string) => void; updateUser: (userData: Partial) => void; @@ -43,8 +43,12 @@ interface RegisterData { fullName: string; email: string; phone: string; - password: string; - confirmPassword: string; +} + +interface RegisterResult { + iamUserId: string; + email: string; + phoneNumber: string; } export const useAuthStore = create((set, get) => ({ @@ -118,25 +122,24 @@ export const useAuthStore = create((set, get) => ({ set({ user, token, isAuthenticated: true }); }, - register: async (data: RegisterData) => { + register: async (data: RegisterData): Promise => { // Shape required by the passenger-api RegisterDto; username = email by convention. + // Registration no longer takes a password — the account is created as pending and + // an SMS verification code is sent. The user completes signup on the verify-account + // page (set-password). No token is issued here; the user is NOT logged in yet. const payload = { email: data.email, username: data.email, phoneNumber: data.phone, name: { en: data.fullName, am: data.fullName }, - password: data.password, - confirmPassword: data.confirmPassword, }; const response: any = await apiClient.post('/auth/register', payload); - const { token, user } = response.data || response; - - if (typeof window !== 'undefined') { - localStorage.setItem('auth_token', token); - localStorage.setItem('auth_user', JSON.stringify(user)); - } - - set({ user, token, isAuthenticated: true }); + const result = response.data || response; + return { + iamUserId: result.iamUserId, + email: result.email, + phoneNumber: result.phoneNumber, + }; }, logout: async () => { From 74118165c6530cb3f9c58f8221e376e325dd4e76 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 4 Jul 2026 07:16:23 +0000 Subject: [PATCH 088/122] chages --- .../src/modules/train-scheduling/booking-batch.service.ts | 5 ++++- .../src/modules/train-scheduling/train-scheduling.service.ts | 4 +++- .../src/components/contracts/GlUpcomingWindowsSection.tsx | 2 ++ 3 files changed, 9 insertions(+), 2 deletions(-) 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 46175c7ef..0b239990b 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 @@ -310,7 +310,10 @@ export class BookingBatchService implements OnModuleInit { private async openRouteDayGroups(): Promise { const open = ( await this.trainSchedulesRepository.findAll({ - where: { bookingWindowStatus: "OPEN" }, + where: [ + { bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Draft }, + { bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Scheduled }, + ], }) ).filter((s) => s.windowPhase == null); const groups = new Map(); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 32a046356..3b36fdc9a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -2049,7 +2049,9 @@ export class TrainSchedulingService { await this.trainSchedulesRepository.updateStatus( id, TrainScheduleStatusEnum.Cancelled, - {}, + // Retire the booking window so a canceled schedule never lingers as an + // "open window" in booking-window lists or the legacy batch fill. + { bookingWindowStatus: 'CLOSED', windowPhase: 'DONE' }, manager, ); if (schedule.trainSetId) { diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx index e5e998721..dc95729c1 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx @@ -235,6 +235,8 @@ export function GlUpcomingWindowsSection() { const rows = (data ?? []).filter( (w) => w.windowPhase != null && w.windowPhase !== "DONE" && !isPast(w), ); + // Canceled schedules are retired to windowPhase='DONE' server-side, so the + // guard above already excludes them; they never reach the upcoming list. // Open lanes first, then by opening time. return rows.sort((a, b) => { const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow); From 619ffa5419de62addd46a3f9c5b439a2c61787d1 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 4 Jul 2026 07:17:05 +0000 Subject: [PATCH 089/122] style(WIP): auth ui clean up. --- .../src/components/auth/AuthShell.tsx | 139 +++++++ .../backoffice/src/pages/auth/LoginPage.tsx | 392 +++++------------- .../portal/src/pages/accounts/LoginPage.tsx | 81 ++-- 3 files changed, 274 insertions(+), 338 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx diff --git a/apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx b/apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx new file mode 100644 index 000000000..ecf06c7c9 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx @@ -0,0 +1,139 @@ +import type { ReactNode } from "react"; +import { ArrowUpRight, ChevronDown, Globe } from "lucide-react"; + +const LOGIN_IMAGE = "/assets/login.png"; +const EDR_LOGO = "/assets/logo.svg"; + +const LeftPanelDecor = () => ( +
+ + {[0, 1, 2, 3, 4, 5].map((ring) => ( + + ))} + +
+
+); + +const RightPanelDecor = () => ( +
+
+
+ + + + + + + + +
+); + +export interface AuthShellProps { + children: ReactNode; + /** Tagline shown in the highlighted card over the left image panel. */ + tagline?: string; + taglineBody?: string; +} + +const LeftPanel = ({ + tagline, + taglineBody, +}: Pick) => ( +
+ Ethio Djibouti Railway +
+ + +
+ EDR Freight +
+ +
+
+
+
+ + {tagline ?? "Empower Your Freight Operations"} + +
+

+ {taglineBody ?? + "Sign in to manage bookings, track cargo, and run logistics operations on the Ethio Djibouti Railway freight platform."} +

+
+
+
+); + +const LanguageSelector = () => ( +
+ + Eng + +
+); + +export default function AuthShell({ + children, + tagline, + taglineBody, +}: AuthShellProps) { + return ( +
+
+ + +
+ + +
+ +
+ +
+
+
+ {children} +
+
+
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx index 89e8557c3..849049bc2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx @@ -1,162 +1,40 @@ import { type FormEvent, useState } from "react"; import { - Eye, - EyeOff, - ArrowUpRight, - Globe, - ChevronDown, -} from "lucide-react"; + Alert, + Box, + Button, + Center, + Group, + Image, + PasswordInput, + PinInput, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { AlertCircle, ArrowLeft } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { useAuth } from "@/auth/useAuth"; +import AuthShell from "@/components/auth/AuthShell"; +import { extractApiError } from "@/utils/result"; /** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */ const normaliseIdentifier = (raw: string): string => { const v = raw.trim(); const digits = v.replace(/\D/g, ""); if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) { - const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, ""); + const local = digits.startsWith("251") + ? digits.slice(3) + : digits.replace(/^0/, ""); return `+251${local}`; } return v.toLowerCase(); }; -const LOGIN_IMAGE = "/assets/login.png"; const EDR_LOGO = "/assets/logo.svg"; -const fieldClass = - "h-11 w-full rounded-xl border border-gray-200/90 bg-white px-4 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 outline-none transition-all duration-200 hover:border-gray-300 focus:border-primary focus:bg-white focus:ring-4 focus:ring-primary/10"; - -const primaryButtonClass = - "h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none"; - -const LeftPanelDecor = () => ( -
- - {[0, 1, 2, 3, 4, 5].map((ring) => ( - - ))} - -
-
-); - -const RightPanelDecor = () => ( -
-
-
- - - - - - - - -
-); - -const LeftPanel = () => ( -
- Ethio Djibouti Railway -
- - - - -
-
-
-
- - Empower Your Freight Operations - -
-

- Sign in to manage bookings, track cargo, and run logistics operations - on the Ethio Djibouti Railway freight platform. -

-
-
-
-); - -const LanguageSelector = () => ( -
- - Eng - -
-); - -const FormFooter = () => ( - -); - const LoginPage = () => { const navigate = useNavigate(); const { login, verifyMfa } = useAuth(); @@ -165,7 +43,6 @@ const LoginPage = () => { const [otp, setOtp] = useState(""); const [needsMfa, setNeedsMfa] = useState(false); const [submitting, setSubmitting] = useState(false); - const [showPassword, setShowPassword] = useState(false); const [normalizedIdentifier, setNormalizedIdentifier] = useState(""); const [error, setError] = useState(null); @@ -179,15 +56,14 @@ const LoginPage = () => { setNormalizedIdentifier(normalized); const result = await login({ email: normalized, password }); - console.log(result); if (result.mfaRequired) { setNeedsMfa(true); return; } // navigate("/dashboard/overview", { replace: true }); - } catch { - setError("Unable to sign in with those credentials."); + } catch (err) { + setError(extractApiError(err).message); } finally { setSubmitting(false); } @@ -201,194 +77,132 @@ const LoginPage = () => { try { await verifyMfa({ email: normalizedIdentifier, otp: otp.trim() }); navigate("/dashboard/overview", { replace: true }); - } catch { - setError("Unable to verify the one-time code."); + } catch (err) { + setError(extractApiError(err).message); } finally { setSubmitting(false); } }; const loginForm = ( -
-
- EDR Freight -
+ +
+ EDR Freight +
-
-

- Get Started -

-

+ + + Welcome back! + + Log in to access the freight backoffice & explore all logistics resources. -

-
+ + -
-
- - setIdentifier(event.target.value)} - placeholder="name@company.com or 09XXXXXXXX" - autoComplete="username" - className={fieldClass} - /> -
+ + setIdentifier(event.target.value)} + /> -
- -
- setPassword(event.target.value)} - placeholder="Enter your password" - className={`${fieldClass} pr-11`} - /> - -
-
+ setPassword(event.target.value)} + /> {error ? ( -
+ }> {error} -
+ ) : null} - - -

- Need an account?{" "} - - Contact your admin - -

-
- + + +
); const mfaForm = ( -
-
- EDR Freight -
+ +
+ EDR Freight +
-
-

+ + Multi-factor verification - </h1> - <p className="text-sm leading-relaxed text-gray-500"> + + We sent a verification code to{" "} - + {normalizedIdentifier} - + . Enter it below to complete sign in. -

-

+ + -
-
- - + + + Verification code + + setOtp(event.target.value)} - placeholder="Enter the code" - className={fieldClass} + placeholder="0" + disabled={submitting} + styles={{ input: { textAlign: "center" } }} + onChange={setOtp} /> -
+ {error ? ( -
+ }> {error} -
+ ) : null} -
- - -
-
- + Verify + + + +
); - return ( - <> - - - - -
-
- - -
- - -
- -
- -
-
-
- {!needsMfa ? loginForm : mfaForm} -
-
-
- - -
-
-
- - ); + return {!needsMfa ? loginForm : mfaForm}; }; export default LoginPage; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx index bdd10871b..323b5d5a8 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -1,9 +1,11 @@ import { type FormEvent, useState } from "react"; -import { Eye, EyeOff } from "lucide-react"; +import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core"; +import { AlertCircle } from "lucide-react"; import { useLocation, useNavigate } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; -import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell"; +import AuthShell from "@/components/auth/AuthShell"; +import { extractApiError } from "@/utils/result"; const EDR_LOGO = "/assets/edr-logo.png"; @@ -24,7 +26,6 @@ export default function LoginPage() { const { login } = useAuth(); const [identifier, setIdentifier] = useState(""); const [password, setPassword] = useState(""); - const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -41,8 +42,8 @@ export default function LoginPage() { } else { setError(result.error.message); } - } catch { - setError("An unexpected error occurred"); + } catch (err) { + setError(extractApiError(err).message); } finally { setLoading(false); } @@ -64,60 +65,42 @@ export default function LoginPage() {

-
-
- - setIdentifier(event.target.value)} - placeholder="name@company.com or 09XXXXXXXX" - disabled={loading} - autoComplete="username" - className={fieldClass} - /> -
+ + setIdentifier(event.target.value)} + /> -
-
- +
+ -
- setPassword(event.target.value)} - placeholder="Enter your password" - disabled={loading} - className={`${fieldClass} pr-11`} - /> - -
+ setPassword(event.target.value)} + />
{error ? ( -
+ }> {error} -
+ ) : null} - +

Don't have an account?{" "} @@ -129,7 +112,7 @@ export default function LoginPage() { Create an account

-
+ ); From e8e3e01f312398088f9d473395867957577be05c Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 4 Jul 2026 07:36:53 +0000 Subject: [PATCH 090/122] release order document fix --- apps/edr-freight-api/Dockerfile | 11 ++++++++++- .../train-scheduling/train-scheduling.service.ts | 7 +++++-- .../warehouses/warehouse-release-document.service.ts | 12 ++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index f9107ed23..b781fb4c0 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -7,6 +7,9 @@ RUN apk add --no-cache libc6-compat # `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds. ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" +# Puppeteer uses the system Chromium installed in the runner stage — skip the +# ~150MB bundled-Chromium download during pnpm install. +ENV PUPPETEER_SKIP_DOWNLOAD=true RUN corepack enable WORKDIR /app @@ -32,8 +35,14 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy FROM node:24.15.0-alpine AS runner -RUN apk add --no-cache libc6-compat +# Chromium + fonts for headless PDF rendering (puppeteer). Alpine ships the +# binary at /usr/bin/chromium-browser, which the PDF renderer auto-detects +# (also pinned via PUPPETEER_EXECUTABLE_PATH). Without this, PDF generation +# falls back to a degraded hand-built layout. +RUN apk add --no-cache libc6-compat \ + chromium nss freetype harfbuzz ca-certificates ttf-freefont ENV NODE_ENV=production +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser WORKDIR /app RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 --ingroup nodejs nestjs diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 32a046356..86e37451e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1265,7 +1265,9 @@ export class TrainSchedulingService { performedBy: 'DOCUMENT_GENERATION', }); const html = this.buildImportLoadListHtml(loadList); - const buffer = await this.pdfDocuments.htmlToPdfBuffer(html); + // Generic render — NOT the release-order fallback (would mislabel this as a + // gate-clearance / release order when Chromium is unavailable). + const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Import marshalling / load list'); const reference = loadList.trainNumber ?? loadList.trainScheduleId; return { filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`, @@ -1283,7 +1285,8 @@ export class TrainSchedulingService { } const html = this.buildExportLoadListHtml(schedule); - const buffer = await this.pdfDocuments.htmlToPdfBuffer(html); + // Generic render — NOT the release-order fallback (see importLoadListDocument). + const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Export marshalling / load list'); const reference = schedule.trainNumber ?? schedule.id; return { filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts index f8c0dd355..68e630e0b 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -20,6 +20,18 @@ export class WarehouseReleaseDocumentService { }); } + /** + * Render arbitrary document HTML to PDF via the shared renderer WITHOUT the + * release-order fallback. Non-release documents (e.g. the import/export + * marshalling load list) must use this so a Chromium-less fallback degrades to + * a plain-text dump of *their own* content — instead of masquerading as a + * "Warehouse Gate Clearance / Release Order", which the release-specific + * fallback would otherwise draw regardless of the input HTML. + */ + renderDocumentHtml(html: string, label = 'Document'): Promise { + return this.pdf.htmlToPdfBuffer(html, { label }); + } + private htmlToBasicPdfBuffer(html: string): Buffer { const doc = this.extractReleaseDocument(html); const body: string[] = [ From 145240d3bded71b8da3c36ded965f89c27e6d93d Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 4 Jul 2026 07:43:03 +0000 Subject: [PATCH 091/122] refactor: remove gate pass granting logic from clearance services and UI - Removed the gate pass granting functionality from the BookingClearanceService and ContractClearanceService, replacing it with a new method to retrieve gate pass status from train schedules. - Updated the ContractsController to eliminate endpoints related to gate pass granting. - Refactored the UI components (ExportClearanceStepper and PhasedClearanceActionPanel) to reflect the new gate pass securing process, linking to the train scheduling interface instead. - Cleaned up related constants and query hooks, removing unused code and references to the gate pass functionality. - Adjusted types in the contracts to accommodate changes in the gate pass handling logic. --- .../contracts/booking-clearance.service.ts | 12 +- .../contracts/contract-clearance.service.ts | 14 +- .../modules/contracts/contracts.controller.ts | 40 -- .../contracts/dto/phased-clearance.dto.ts | 8 - .../contracts/gl-operations.service.ts | 222 +++-------- .../contracts/ExportClearanceStepper.tsx | 102 ++--- .../contracts/PhasedClearanceActionPanel.tsx | 103 +----- .../backoffice/src/constants/QUERY_KEYS.ts | 1 - .../backoffice/src/constants/URLS.ts | 5 - .../src/hooks/contracts/useContracts.ts | 9 - .../contracts/GlDjiboutiClearanceListPage.tsx | 349 +++--------------- .../src/services/contracts.service.ts | 29 -- packages/types/src/freight/contracts.ts | 26 +- 13 files changed, 136 insertions(+), 784 deletions(-) diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 3f169c49c..61ac93925 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -205,7 +205,7 @@ export class BookingClearanceService { const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId); const bookingMilestone = (code: string) => milestones.find((m) => m.milestoneCode === code); - const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); + const gatepass = await this.glOperationsService.gatepassForBooking(bookingId); const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); const riskMilestone = bookingMilestone('RISK_ASSIGNED'); const secondDuty = this.glOperationsService.secondDutyState(milestones, files); @@ -242,14 +242,8 @@ export class BookingClearanceService { workflowFiles, t1, train, - gatepassGranted: gatepassMilestone?.status === 'COMPLETED', - gatepassAt: - gatepassMilestone?.status === 'COMPLETED' - ? (gatepassMilestone.metadata?.gatepassAt ?? - (gatepassMilestone.triggeredAt - ? gatepassMilestone.triggeredAt.toISOString() - : null)) - : null, + gatepassGranted: gatepass.granted, + gatepassAt: gatepass.grantedAt, t1Closed: t1ClosedMilestone?.status === 'COMPLETED', t1ClosedAt: t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 532d26359..2c79ba42f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -278,7 +278,9 @@ export class ContractClearanceService { } const bookingMilestone = (code: string) => bookingMilestones.find((m) => m.milestoneCode === code); - const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); + const gatepass = cycle?.bookingId + ? await this.glOperationsService.gatepassForBooking(cycle.bookingId) + : { granted: false, grantedAt: null }; const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); const riskMilestone = bookingMilestone('RISK_ASSIGNED'); const secondDuty = this.glOperationsService.secondDutyState( @@ -344,14 +346,8 @@ export class ContractClearanceService { workflowFiles, t1, train, - gatepassGranted: gatepassMilestone?.status === 'COMPLETED', - gatepassAt: - gatepassMilestone?.status === 'COMPLETED' - ? (gatepassMilestone.metadata?.gatepassAt ?? - (gatepassMilestone.triggeredAt - ? gatepassMilestone.triggeredAt.toISOString() - : null)) - : null, + gatepassGranted: gatepass.granted, + gatepassAt: gatepass.grantedAt, t1Closed: t1ClosedMilestone?.status === 'COMPLETED', t1ClosedAt: t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 06ac31d68..a22c7cad4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -77,7 +77,6 @@ import { } from './dto/gl-operations.dto'; import { AdviseContractDutyDto, - GatepassDto, RoAmendmentDto, } from './dto/phased-clearance.dto'; @@ -688,30 +687,6 @@ export class ContractsController { return this.clearanceService.djQueue(filter); } - @Get('clearance/dj-schedules') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ summary: 'Train schedules carrying customs bookings — GL DJ gate-pass table' }) - djClearanceSchedules() { - return this.glOperationsService.djSchedules(); - } - - @Post('clearance/schedules/:scheduleId/gatepass') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ - summary: 'GL DJ grants the gate pass for every customs booking on a train schedule', - }) - grantScheduleGatepass( - @Param('scheduleId', ParseUUIDPipe) scheduleId: string, - @Body() dto: GatepassDto, - @CurrentUser() user: AuthUserPayload, - ) { - return this.glOperationsService.grantScheduleGatepass( - scheduleId, - dto?.gatepassAt, - resolveAuthUserId(user), - ); - } - // ── Path A self-clearance — Operations reviews the customer's own docs ─────── @Get('clearance/ops-queue') @@ -947,21 +922,6 @@ export class ContractsController { return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user)); } - @Post('bookings/:bookingId/gatepass') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ summary: 'GL DJ grants the gate pass for a customs booking (captures time)' }) - grantGatepass( - @Param('bookingId', ParseUUIDPipe) bookingId: string, - @Body() dto: GatepassDto, - @CurrentUser() user: AuthUserPayload, - ) { - return this.glOperationsService.grantGatepass( - bookingId, - dto?.gatepassAt, - resolveAuthUserId(user), - ); - } - @Post('bookings/:bookingId/final-invoice') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(FileInterceptor('file')) diff --git a/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts index 6b784073b..34a903427 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts @@ -36,11 +36,3 @@ export class RoAmendmentDto { note?: string; } -export class GatepassDto { - @ApiPropertyOptional({ - description: 'When the gate pass was granted (ISO datetime; defaults to now)', - }) - @IsOptional() - @IsString() - gatepassAt?: string; -} diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index 8fed3a8ff..e639f0867 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -4,7 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { DataSource, In, IsNull } from 'typeorm'; +import { DataSource, IsNull } from 'typeorm'; import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types'; import { BillingService } from '../billing/billing.service'; @@ -17,7 +17,6 @@ import { ClearanceIncident, IncidentType, } from './entities/clearance-incident.entity'; -import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { @@ -198,6 +197,7 @@ export class GlOperationsService { } return { + scheduleId: schedule?.id ?? null, wagonAllocated, departedAt: schedule?.actualDepartureAt ? new Date(schedule.actualDepartureAt).toISOString() @@ -208,6 +208,41 @@ export class GlOperationsService { }; } + /** + * Gate pass status for a booking, sourced from the train schedule's Djibouti + * gate-pass operation (secured via the train-scheduling "Save as Secured" + * action) rather than a clearance milestone. For EXPORT bookings this also + * backfills the arrival-chain milestones once secured, same as the retired + * clearance-side grant action used to. + */ + async gatepassForBooking( + bookingId: string, + ): Promise<{ granted: boolean; grantedAt: string | null }> { + const train = await this.trainState(bookingId); + if (!train.scheduleId) return { granted: false, grantedAt: null }; + const operation = await this.dataSource + .getRepository(ImportDjiboutiOperation) + .findOne({ where: { trainScheduleId: train.scheduleId } }); + const grantedAt = operation?.gatepassGrantedAt + ? new Date(operation.gatepassGrantedAt).toISOString() + : null; + + if (grantedAt) { + const booking = await this.getBooking(bookingId); + if ((booking.tradeDirection ?? 'IMPORT') === 'EXPORT') { + const milestones = await this.milestoneService.listForBooking(bookingId); + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) { + if (byCode.get(code)?.status === 'PENDING') { + await this.milestoneService.completeForBooking(bookingId, code); + } + } + } + } + + return { granted: Boolean(grantedAt), grantedAt }; + } + /** * T1 transit-document lifecycle state for an import shipment booking. Wagon * allocation opens the upload window; train departure locks it; train arrival @@ -302,8 +337,11 @@ export class GlOperationsService { 'The transport document must be uploaded before T1 can be closed.', ); } - if (!done('GATEPASS_GRANTED')) { - throw new BadRequestException('Grant the gate pass before closing T1.'); + const gatepass = await this.gatepassForBooking(bookingId); + if (!gatepass.granted) { + throw new BadRequestException( + 'Secure the Djibouti gate pass on the train schedule before closing T1.', + ); } // Export bookings seeded before T1_CLOSED joined the catalog lack the row. await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection); @@ -322,182 +360,6 @@ export class GlOperationsService { 'ARRIVED_AT_DJIBOUTI', ]; - /** - * GL Djibouti grants the gate pass for a customs booking, capturing the time. - * Export: requires the train to have arrived at Djibouti; back-fills the - * arrival-chain milestones. Import: requires wagon allocation (pre-loading). - */ - async grantGatepass( - bookingId: string, - gatepassAt?: string, - userId?: string, - ): Promise<{ bookingId: string; gatepassAt: string }> { - const booking = await this.getBooking(bookingId); - if (!booking.customsClearingEnabled) { - throw new BadRequestException('Gate pass applies to customs bookings only.'); - } - const tradeDirection = booking.tradeDirection ?? 'IMPORT'; - const milestones = await this.milestoneService.listForBooking(bookingId); - const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); - - const existing = byCode.get('GATEPASS_GRANTED'); - if (existing?.status === 'COMPLETED') { - return { - bookingId, - gatepassAt: - existing.metadata?.gatepassAt ?? - (existing.triggeredAt ? new Date(existing.triggeredAt).toISOString() : ''), - }; - } - - const train = await this.trainState(bookingId); - if (tradeDirection === 'EXPORT') { - if (!train.arrivedAt) { - throw new BadRequestException( - 'The train has not arrived at Djibouti yet — gate pass can be granted after arrival.', - ); - } - for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) { - if (byCode.get(code)?.status === 'PENDING') { - await this.milestoneService.completeForBooking(bookingId, code, userId); - } - } - } else if (!train.wagonAllocated) { - throw new BadRequestException( - 'Wagons must be allocated before the gate pass can be granted.', - ); - } - - const at = gatepassAt?.trim() || new Date().toISOString(); - await this.milestoneService.completeWithMetadataForBooking( - bookingId, - 'GATEPASS_GRANTED', - { gatepassAt: at }, - userId, - ); - return { bookingId, gatepassAt: at }; - } - - /** Train schedules carrying ≥1 customs booking — the GL Djibouti gate-pass table. */ - async djSchedules(): Promise { - const schedules = await this.dataSource.getRepository(TrainSchedule).find({ - relations: { - scheduleBookings: { booking: true }, - originStation: true, - destinationStation: true, - }, - order: { scheduledDepartureDate: 'DESC' }, - }); - - const withCustoms = schedules - .filter((s) => s.status !== 'CANCELLED') - .map((s) => ({ - schedule: s, - customs: (s.scheduleBookings ?? []) - .map((sb) => sb.booking) - .filter((b): b is Booking => Boolean(b?.customsClearingEnabled)), - })) - .filter((s) => s.customs.length > 0); - - const bookingIds = withCustoms.flatMap((s) => s.customs.map((b) => b.id)); - const gatepassRows = bookingIds.length - ? await this.dataSource.getRepository(ClearanceMilestone).find({ - where: { bookingId: In(bookingIds), milestoneCode: 'GATEPASS_GRANTED' }, - }) - : []; - const gatepassByBooking = new Map(gatepassRows.map((m) => [m.bookingId, m])); - - return withCustoms.map(({ schedule, customs }) => { - const freightTypes = [...new Set(customs.map((b) => b.freightType).filter(Boolean))]; - return { - id: schedule.id, - trainNumber: schedule.trainNumber ?? null, - routeName: null, - origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, - destination: - schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, - status: schedule.status, - scheduledDepartureDate: schedule.scheduledDepartureDate - ? new Date(schedule.scheduledDepartureDate).toISOString() - : null, - actualDepartureAt: schedule.actualDepartureAt - ? new Date(schedule.actualDepartureAt).toISOString() - : null, - actualArrivalAt: schedule.actualArrivalAt - ? new Date(schedule.actualArrivalAt).toISOString() - : null, - freightType: - freightTypes.length === 1 ? (freightTypes[0] as string) : freightTypes.length ? 'MIXED' : null, - customsBookings: customs.map((b) => { - const m = gatepassByBooking.get(b.id); - const granted = m?.status === 'COMPLETED'; - return { - bookingId: b.id, - reference: b.reference ?? b.id, - tradeDirection: b.tradeDirection ?? 'IMPORT', - contractId: b.contractId ?? null, - gatepassGranted: granted, - gatepassAt: granted - ? (m?.metadata?.gatepassAt ?? - (m?.triggeredAt ? new Date(m.triggeredAt).toISOString() : null)) - : null, - }; - }), - }; - }); - } - - /** - * One-click gate pass for every customs booking on a train schedule. Per-booking - * guard failures are collected, not fatal. Import schedules also get the - * schedule-level ImportDjiboutiOperation gate pass so loading unblocks. - */ - async grantScheduleGatepass( - scheduleId: string, - gatepassAt?: string, - userId?: string, - ): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> { - const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({ - where: { id: scheduleId }, - relations: { scheduleBookings: { booking: true } }, - }); - if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); - - const customs = (schedule.scheduleBookings ?? []) - .map((sb) => sb.booking) - .filter((b): b is Booking => Boolean(b?.customsClearingEnabled)); - if (customs.length === 0) { - throw new BadRequestException('No customs bookings ride this schedule.'); - } - - let granted = 0; - const skipped: Array<{ bookingId: string; error: string }> = []; - for (const booking of customs) { - try { - await this.grantGatepass(booking.id, gatepassAt, userId); - granted += 1; - } catch (e) { - skipped.push({ - bookingId: booking.id, - error: e instanceof Error ? e.message : 'Failed', - }); - } - } - - if (granted > 0 && customs.some((b) => (b.tradeDirection ?? 'IMPORT') === 'IMPORT')) { - const opRepo = this.dataSource.getRepository(ImportDjiboutiOperation); - let operation = await opRepo.findOne({ where: { trainScheduleId: scheduleId } }); - if (!operation) { - operation = opRepo.create({ trainScheduleId: scheduleId }); - } - if (!operation.gatepassGrantedAt) { - operation.gatepassGrantedAt = gatepassAt ? new Date(gatepassAt) : new Date(); - await opRepo.save(operation); - } - } - - return { granted, skipped }; - } /** * GL Djibouti raises the post-offload final invoice (export): manual amount + diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx index d7cd9207b..b13e38961 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -14,7 +14,7 @@ import { Text, Textarea, } from "@mantine/core"; -import { DateInput, DateTimePicker } from "@mantine/dates"; +import { DateInput } from "@mantine/dates"; import { AlertTriangle, CheckCircle2, @@ -397,15 +397,10 @@ export function ExportClearanceStepper({ : } > - + void; -}) { - const [opened, setOpened] = useState(false); - const [at, setAt] = useState(new Date()); - const [loading, setLoading] = useState(false); +/** + * Gate pass status, read-only. Secured on the train schedule's "Save as + * Secured" action (train-scheduling-v2) — clearance no longer grants it directly. + */ +function GatepassStep({ clearance }: { clearance: ClearanceViewLike }) { + const scheduleId = clearance.train?.scheduleId ?? null; if (clearance.gatepassGranted) { return ( @@ -526,68 +513,21 @@ function GatepassStep({ done={false} pendingLabel={ arrived - ? "Train arrived — GL Djibouti can grant the gate pass." + ? "Train arrived — secure the gate pass on the train schedule." : "Available once the train arrives at Djibouti." } doneLabel="" /> - {canAct && bookingId ? ( - <> - - setOpened(false)} - title={Grant gate pass} - radius="md" - size="sm" - > - - setAt(v ? new Date(v) : null)} - required - /> - - - - - - - + {scheduleId ? ( + ) : null} ); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 82ac61382..d0b7f2c87 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -4,7 +4,6 @@ import { Badge, Button, Group, - Modal, NumberInput, Paper, SegmentedControl, @@ -15,7 +14,6 @@ import { Text, TextInput, } from "@mantine/core"; -import { DateTimePicker } from "@mantine/dates"; import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone"; import { TransitPermitMultiUpload, @@ -536,17 +534,12 @@ export function PhasedClearanceActionPanel({ : } > - + void; -}) { - const [opened, setOpened] = useState(false); - const [at, setAt] = useState(new Date()); - const [loading, setLoading] = useState(false); +/** + * Gate pass status, read-only. Secured on the train schedule's "Save as + * Secured" action (train-scheduling-v2) — clearance no longer grants it directly. + */ +function ImportGatepassStep({ clearance }: { clearance: ClearanceViewLike }) { + const scheduleId = clearance.train?.scheduleId ?? null; if (clearance.gatepassGranted) { return ( @@ -852,68 +836,21 @@ function ImportGatepassStep({ done={false} pendingLabel={ wagonAllocated - ? "Wagons allocated — GL Djibouti can grant the gate pass." + ? "Wagons allocated — secure the gate pass on the train schedule." : "Available once wagons are allocated." } doneLabel="" /> - {canAct && bookingId ? ( - <> - - setOpened(false)} - title={Grant gate pass} - radius="md" - size="sm" - > - - setAt(v ? new Date(v) : null)} - required - /> - - - - - - - + {scheduleId ? ( + ) : null} ); diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 3275ef662..bd1c51e47 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -70,7 +70,6 @@ export const QUERY_KEYS = { ["contracts", "clearance-queue", region ?? "ET"] as const, clearanceHistory: (region?: string) => ["contracts", "clearance-history", region ?? "ET"] as const, - djSchedules: ["contracts", "clearance-dj-schedules"] as const, milestones: (id: string) => ["contracts", "milestones", id] as const, capacity: (id: string) => ["contracts", "capacity", id] as const, bookingMilestones: (bookingId: string) => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index d0f09f508..c22427881 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -232,11 +232,6 @@ export const URL_CONSTANTS = { `/contracts/bookings/${bookingId}/t1-documents`, BOOKING_T1_CLOSE: (bookingId: string) => `/contracts/bookings/${bookingId}/t1-close`, - CLEARANCE_DJ_SCHEDULES: "/contracts/clearance/dj-schedules", - CLEARANCE_SCHEDULE_GATEPASS: (scheduleId: string) => - `/contracts/clearance/schedules/${scheduleId}/gatepass`, - BOOKING_GATEPASS: (bookingId: string) => - `/contracts/bookings/${bookingId}/gatepass`, BOOKING_FINAL_INVOICE: (bookingId: string) => `/contracts/bookings/${bookingId}/final-invoice`, BOOKING_FINAL_INVOICE_CONFIRM: (bookingId: string) => diff --git a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts index 56229415f..04a4720aa 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts @@ -68,15 +68,6 @@ export function useDjClearanceQueue(enabled = true) { }); } -/** Train schedules carrying customs bookings — GL DJ gate-pass table. */ -export function useDjClearanceSchedules(enabled = true) { - return useQuery({ - queryKey: QUERY_KEYS.CONTRACTS.djSchedules, - queryFn: () => contractsService.getDjClearanceSchedules(), - enabled, - }); -} - /** Path A self-clearance queue (Operations reviews non-customs contracts). */ export function useOpsClearanceQueue(enabled = true) { return useQuery({ diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx index 3957fb7a5..0b7456851 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx @@ -1,326 +1,65 @@ -import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { - Badge, - Button, - Card, - Group, - Loader, - Modal, - Stack, - Tabs, - Text, -} from "@mantine/core"; -import { DateTimePicker } from "@mantine/dates"; -import { ChevronRight, Ship, Train, Truck } from "lucide-react"; -import { DataTable, type ColumnDef } from "@edr/ui-common"; -import type { Freight } from "@edr/types"; -import toast from "react-hot-toast"; +import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core"; +import { ChevronRight, Ship } from "lucide-react"; import { PageContainer } from "@/components/page/PageContainer"; import { PageHeader } from "@/components/page/PageHeader"; -import { - useDjClearanceQueue, - useDjClearanceSchedules, -} from "@/hooks/contracts/useContracts"; -import { contractsService } from "@/services/contracts.service"; +import { useDjClearanceQueue } from "@/hooks/contracts/useContracts"; export default function GlDjiboutiClearanceListPage() { const navigate = useNavigate(); const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue(); - const schedulesQuery = useDjClearanceSchedules(); const contractItems = contractQueue?.items ?? []; - const scheduleItems = schedulesQuery.data ?? []; - - const [gatepassTarget, setGatepassTarget] = - useState(null); - const [gatepassAt, setGatepassAt] = useState(new Date()); - const [granting, setGranting] = useState(false); - - const columns = useMemo[]>( - () => [ - { - header: "Train", - accessorKey: "trainNumber", - cell: ({ row }) => ( - - {row.original.trainNumber ?? "—"} - - ), - }, - { - header: "Route", - id: "route", - cell: ({ row }) => ( - - {row.original.origin ?? "—"} → {row.original.destination ?? "—"} - - ), - }, - { - header: "Scheduled departure", - id: "scheduled", - cell: ({ row }) => ( - - {row.original.scheduledDepartureDate - ? new Date(row.original.scheduledDepartureDate).toLocaleDateString() - : "—"} - - ), - }, - { - header: "Departed", - id: "departed", - cell: ({ row }) => ( - - {row.original.actualDepartureAt - ? new Date(row.original.actualDepartureAt).toLocaleString() - : "—"} - - ), - }, - { - header: "Arrived", - id: "arrived", - cell: ({ row }) => ( - - {row.original.actualArrivalAt - ? new Date(row.original.actualArrivalAt).toLocaleString() - : "—"} - - ), - }, - { - header: "Status", - accessorKey: "status", - cell: ({ row }) => ( - - {row.original.status} - - ), - }, - { - header: "Customs bookings", - id: "customs", - cell: ({ row }) => { - const bookings = row.original.customsBookings; - const directions = [...new Set(bookings.map((b) => b.tradeDirection))]; - return ( - - - {bookings.length} - - {directions.map((d) => ( - - {d} - - ))} - - ); - }, - }, - { - header: "Gate pass", - id: "gatepass", - cell: ({ row }) => { - const bookings = row.original.customsBookings; - const allGranted = - bookings.length > 0 && bookings.every((b) => b.gatepassGranted); - const grantedAt = bookings.find((b) => b.gatepassAt)?.gatepassAt ?? null; - if (allGranted) { - return ( - - Granted{grantedAt ? ` · ${new Date(grantedAt).toLocaleString()}` : ""} - - ); - } - return ( - - ); - }, - }, - ], - [], - ); return ( - - - Contracts ({contractItems.length}) - }> - Schedules ({scheduleItems.length}) - - - - - {contractsLoading ? ( - - - - ) : ( - - {contractItems.length === 0 ? ( - - No Djibouti customs contracts yet. - - ) : ( - contractItems.map((c) => ( - navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)} - > - - - -
- {c.reference} - - {c.tradeDirection} · {c.status} - -
-
- - - Contract - - - -
-
- )) - )} -
- )} -
- - - void schedulesQuery.refetch(), - } - : undefined - } - emptyMessage="No train schedules carry customs bookings yet." - /> - -
- - setGatepassTarget(null)} - title={ - - - - Gate pass — train {gatepassTarget?.trainNumber ?? ""} + {contractsLoading ? ( + + + + ) : ( + + {contractItems.length === 0 ? ( + + No Djibouti customs contracts yet. - - } - radius="md" - size="sm" - > - - - Grants the gate pass for all{" "} - {gatepassTarget?.customsBookings.length ?? 0} customs booking - {(gatepassTarget?.customsBookings.length ?? 0) === 1 ? "" : "s"} on this - train. - - setGatepassAt(v ? new Date(v) : null)} - required - /> - - - - + ) : ( + contractItems.map((c) => ( + navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)} + > + + + +
+ {c.reference} + + {c.tradeDirection} · {c.status} + +
+
+ + + Contract + + + +
+
+ )) + )}
-
+ )}
); } - -function statusColor(status: string): string { - switch (status) { - case "SCHEDULED": - return "blue"; - case "DISPATCHED": - return "yellow"; - case "ARRIVED": - return "edr-green"; - default: - return "gray"; - } -} diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index 8860df62a..7ad051ce7 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -391,35 +391,6 @@ export const contractsService = { return unwrap(response.data) as Freight.ClearanceT1State; }, - /** Train schedules carrying customs bookings — GL DJ gate-pass table. */ - getDjClearanceSchedules: async (): Promise => { - const response = await client.get(C.CLEARANCE_DJ_SCHEDULES); - return unwrap(response.data) as Freight.DjClearanceSchedule[]; - }, - - /** Gate pass for every customs booking on a train schedule (captures time). */ - grantScheduleGatepass: async ( - scheduleId: string, - gatepassAt?: string, - ): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> => { - const response = await client.post(C.CLEARANCE_SCHEDULE_GATEPASS(scheduleId), { - gatepassAt, - }); - return unwrap(response.data) as { - granted: number; - skipped: Array<{ bookingId: string; error: string }>; - }; - }, - - /** Gate pass for a single customs booking (captures time). */ - grantGatepass: async ( - bookingId: string, - gatepassAt?: string, - ): Promise<{ bookingId: string; gatepassAt: string }> => { - const response = await client.post(C.BOOKING_GATEPASS(bookingId), { gatepassAt }); - return unwrap(response.data) as { bookingId: string; gatepassAt: string }; - }, - /** GL DJ raises the post-offload final invoice (amount + invoice document). */ sendFinalInvoice: async ( bookingId: string, diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index fe653573d..829ae3217 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -265,6 +265,7 @@ export interface ClearanceT1State { /** Train link state for the booking tied to a customs clearance flow. */ export interface ClearanceTrainState { + scheduleId: string | null; wagonAllocated: boolean; departedAt: string | null; arrivedAt: string | null; @@ -305,31 +306,6 @@ export interface ClearanceSecondDuty { paid: boolean; } -/** A customs booking riding a train schedule, as shown on the GL DJ schedules tab. */ -export interface DjClearanceScheduleBooking { - bookingId: string; - reference: string; - tradeDirection: string; - contractId: string | null; - gatepassGranted: boolean; - gatepassAt: string | null; -} - -/** Train schedule row for the GL Djibouti gate-pass table. */ -export interface DjClearanceSchedule { - id: string; - trainNumber: string | null; - routeName: string | null; - origin: string | null; - destination: string | null; - status: string; - scheduledDepartureDate: string | null; - actualDepartureAt: string | null; - actualArrivalAt: string | null; - freightType: string | null; - customsBookings: DjClearanceScheduleBooking[]; -} - export interface ContractClearanceView { contractId: string; /** Overall contract status (e.g. CLEARANCE_UNDER_REVIEW). */ From 8916182a6248018d7058e76c6223f15db3517946 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 4 Jul 2026 07:56:23 +0000 Subject: [PATCH 092/122] Truck assign containers --- .../bookings/booking-transition.service.ts | 11 +++++++ .../CustomerTruckAssignmentCard.tsx | 33 +++++++++++++++---- packages/types/src/freight/index.ts | 4 +++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 35fc7fd54..06edbf04e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -1086,6 +1086,9 @@ export class BookingTransitionService { offeredAmount: number; paymentDeadline: Date; } | null; + /** Flat list of physical container numbers on this booking (for the + * customer truck-assignment container picker). */ + containerNumbers: string[]; } > { // This enrichment runs AFTER the transition has committed. A failure here @@ -1141,12 +1144,20 @@ export class BookingTransitionService { `enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`, ); } + // Physical container numbers entered at booking time (booking_container + // units), flattened for the customer truck-assignment container picker. + const containerNumbers = (booking.bookingContainers ?? []) + .flatMap((bc) => bc.units ?? []) + .map((unit) => unit.containerNumber) + .filter((n): n is string => Boolean(n)); + return { ...booking, latestChangeRequestNote: note?.note ?? null, contractSummary: summary, nextStep, activeBatchOffer, + containerNumbers, }; } } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx index 37e65ab2b..c6ec6bf5d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx @@ -38,6 +38,11 @@ export function CustomerTruckAssignmentCard({ ); const [error, setError] = useState(null); + // Physical container numbers on this booking — the customer picks which one to + // load onto the truck instead of typing it. Falls back to free entry when the + // booking has no container numbers recorded. + const containerOptions = booking.containerNumbers ?? []; + const assignMutation = useMutation(api.bookings.assignCustomerTruck.mutationOptions()); const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions()); @@ -120,13 +125,27 @@ export function CustomerTruckAssignmentCard({ onChange={(value) => setTruckType(value ?? "")} disabled={assigned} /> - setContainerNumberToLoad(e.currentTarget.value.toUpperCase())} - readOnly={assigned} - /> + {containerOptions.length > 0 ? ( + setTruckType(value ?? "")} + /> + + + + + + + ) : ( + trucks.length > 0 && ( + + All containers on this booking have been assigned to a truck. + + ) )} - - setTruckPlateNumber(e.currentTarget.value)} - readOnly={assigned} - /> - setDriverName(e.currentTarget.value)} - readOnly={assigned} - /> - setContainerNumberToLoad(value ?? "")} - searchable - disabled={assigned} - nothingFoundMessage="No matching container" - /> - ) : ( - setContainerNumberToLoad(e.currentTarget.value.toUpperCase())} - readOnly={assigned} - /> - )} - - - - {assigned ? ( + {trucks.length > 0 && ( + - ) : ( - - )} - + + )} ); diff --git a/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts b/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts new file mode 100644 index 000000000..ee317e204 --- /dev/null +++ b/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts @@ -0,0 +1,33 @@ +import type { Freight } from "@edr/types"; + +import { URL_CONSTANTS } from "@/constants/URLS"; +import { client } from "../utils/api"; + +const B = URL_CONSTANTS.BOOKINGS; + +/** + * Multi-truck self-haul assignment for a booking (no EDR first/last mile). + * Each truck carries 1–2 of the booking's containers and tracks its own arrival. + */ +export const customerTrucksService = { + list: async (bookingId: string): Promise => { + const { data } = await client.get(B.CUSTOMER_TRUCKS(bookingId)); + return data.data ?? data; + }, + + add: async ( + bookingId: string, + payload: Freight.AddCustomerTruckPayload, + ): Promise => { + const { data } = await client.post(B.CUSTOMER_TRUCKS(bookingId), payload); + return data.data ?? data; + }, + + remove: async ( + bookingId: string, + assignmentId: string, + ): Promise => { + const { data } = await client.delete(B.CUSTOMER_TRUCK(bookingId, assignmentId)); + return data.data ?? data; + }, +}; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 705439904..698266f67 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -383,6 +383,32 @@ export interface IYard extends BaseEntity { displayOrder: number; } +/** One container number loaded onto a customer self-haul truck. */ +export interface ICustomerTruckContainer { + id: string; + containerNumber: string; +} + +/** A customer self-haul truck on a booking, carrying 1–2 containers. */ +export interface ICustomerTruck { + id: string; + bookingId: string; + plateNumber: string; + driverName: string; + truckType: string; + assignedAt: string; + arrivedAt?: string | null; + containers?: ICustomerTruckContainer[]; +} + +/** Payload to add a customer self-haul truck (1–2 container numbers). */ +export interface AddCustomerTruckPayload { + truckPlateNumber: string; + driverName: string; + truckType: string; + containerNumbers: string[]; +} + export interface IBooking extends BaseEntity { reference: string; customerId: string; @@ -434,6 +460,8 @@ export interface IBooking extends BaseEntity { customerTruckArrivedAt?: string | null; customsClearingEnabled?: boolean; + // (multi-truck self-haul lives in ICustomerTruck[], fetched via the + // /customer-trucks endpoint; the fields above are the booking-level flag.) customsClearingAgent?: string | null; equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN"; From b866b59478086cb62d7c1f17854e3679e1bb90ee Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 4 Jul 2026 09:02:49 +0000 Subject: [PATCH 098/122] feat: add etrade precheck --- .../modules/companies/companies.service.ts | 6 +- .../companies/dto/create-company.dto.ts | 7 +- .../companies/dto/etrade-response.dto.ts | 2 + .../companies/dto/update-profile.dto.ts | 7 +- .../src/components/onboarding/ETradeInfo.tsx | 95 ++++++++++++++----- .../src/pages/accounts/CompanyProfileForm.tsx | 4 +- packages/types/src/freight/etrade.ts | 2 + 7 files changed, 86 insertions(+), 37 deletions(-) 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 02f77b2e0..62be578bb 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -1183,9 +1183,11 @@ export class CompaniesService { const { businessInfo } = await this.etradeService.resolveCompanyData(tin); if (!businessInfo) { throw new BadRequestException( - "No business license found for this TIN. Please check the number and try again.", + "We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.", ); } - return this.etradeService.extractRegistrationData(businessInfo); + const registrationData = this.etradeService.extractRegistrationData(businessInfo); + const tinTaken = await this.companiesRepo.existsByTin(tin); + return { ...registrationData, tinTaken }; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts index e5b686d11..a56ea5ad8 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator'; +import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, IsEmail } from 'class-validator'; import { CompanyType, CompanyStatus } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; @@ -17,10 +17,7 @@ export class CreateCompanyDto { @IsString() @IsNotEmpty() - @Length(10, 10) - @Matches(/^00\d{8}$/, { - message: 'TIN must be 10 digits starting with 00', - }) + @Length(10, 10, { message: 'TIN must be exactly 10 digits' }) tin!: string; @IsOptional() 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 200b69fee..ef7eb2a21 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 @@ -17,6 +17,7 @@ export class ETradeResponseDto implements CompanyRegistrationData { managerName!: string; managerEmail?: string; managerPhone!: string; + tinTaken?: boolean; constructor(data: CompanyRegistrationData) { this.licenceNumber = data.licenceNumber; @@ -35,5 +36,6 @@ export class ETradeResponseDto implements CompanyRegistrationData { this.managerName = data.managerName; this.managerEmail = data.managerEmail; this.managerPhone = data.managerPhone; + this.tinTaken = data.tinTaken; } } 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 316038dc9..9fd8f28ae 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 @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator'; +import { IsString, IsOptional, IsEmail, MaxLength, Length, IsEnum } from 'class-validator'; import { CompanyNationality } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; @@ -34,10 +34,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() - @Length(10, 10) - @Matches(/^00\d{8}$/, { - message: 'TIN must be 10 digits starting with 00', - }) + @Length(10, 10, { message: 'TIN must be exactly 10 digits' }) tin?: string; @IsOptional() 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 98efc2613..6c38bba46 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -7,9 +7,11 @@ import { Text, TextInput, } from "@mantine/core"; +import { useEffect, useRef } from "react"; import type { UseFormRegisterReturn } from "react-hook-form"; -import { AlertCircle, CheckCircle2, Download } from "lucide-react"; +import { AlertCircle, CheckCircle2, Download, Info } from "lucide-react"; import { useETradeData } from "@/hooks/useETradeData"; +import { extractApiError } from "@/utils/result"; import type { CompanyRegistrationData } from "@edr/types"; interface ETradeInfoProps { @@ -22,6 +24,8 @@ interface ETradeInfoProps { onDataLoaded: (data: CompanyRegistrationData) => void; } +const isValidTin = (tin: string) => tin.length === 10; + export default function ETradeInfo({ tin, register, @@ -30,53 +34,100 @@ export default function ETradeInfo({ }: ETradeInfoProps) { const mutation = useETradeData(); const isLoading = mutation.isPending; - const hasData = mutation.data; + const tinTaken = mutation.data?.tinTaken; + const hasData = + mutation.data && !mutation.data.tinTaken ? mutation.data : null; const handleFetch = async () => { - if (!tin || tin.length !== 10 || !tin.startsWith("00")) return; + if (!isValidTin(tin)) return; const result = await mutation.mutateAsync(tin); - if (result) { + if (result && !result.tinTaken) { onDataLoaded(result); } }; - const errorMessage = + // Auto-fetch as soon as the TIN reaches its full 10-digit length — only + // once per distinct value, so retyping the same TIN doesn't refetch. + const lastFetchedTin = useRef(null); + useEffect(() => { + if (isValidTin(tin) && lastFetchedTin.current !== tin) { + lastFetchedTin.current = tin; + handleFetch(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tin]); + + const apiError = mutation.isError && mutation.error - ? (mutation.error as any).message || - "Failed to fetch company information. Please try again." + ? extractApiError(mutation.error) + : null; + // A 400 here means eTrade simply has no record for this TIN — not a + // failure. Soft-pedal it as an FYI, not a red error, so filling in + // manually doesn't feel like something went wrong. + const notFound = apiError?.statusCode === 400; + const errorMessage = + apiError && !notFound + ? apiError.message || + "We couldn't reach eTrade to fetch your company information. Please try again, or fill in the details manually below." : null; return ( TIN Number (10 digits) *} + label={ + <> + TIN Number (10 digits){" "} + * + + } placeholder="0012345678" maxLength={10} error={error} {...register} /> - + {errorMessage && ( + + )} + {notFound && ( + } color="gray"> + We couldn't find a matching business record for this TIN — no + problem, just fill in the details below. + + )} + {errorMessage && ( } color="red" - title="Failed to fetch data" + title="Couldn't fetch eTrade data" > - {errorMessage} You can still fill in the details manually below. + {errorMessage} + + )} + + {tinTaken && ( + } + color="red" + title="TIN already registered" + > + This TIN is already registered to another company account. Please + double-check the number, or contact support if you believe this is a + mistake. )} 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 85af9bfdd..3cce6d4f8 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -450,8 +450,6 @@ export default function CompanyProfileForm({ onDataLoaded={handleETradeDataLoaded} /> - - - + Date: Sat, 4 Jul 2026 09:03:24 +0000 Subject: [PATCH 099/122] changes --- .../train-scheduling.service.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 3b36fdc9a..08c2ea229 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -20,6 +20,7 @@ import { DataSource, EntityManager, In, IsNull, Not } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; import { Container } from '../container-management/entities/container.entity'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { LocomotivesRepository } from '../locomotives/locomotives.repository'; @@ -1168,12 +1169,47 @@ export class TrainSchedulingService { notes: dto.notes ?? operation.notes ?? null, }); + await this.completeGatepassMilestoneForSchedule(scheduleId, securedAt); + console.log( `[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`, ); return this.getImportDjiboutiOperation(schedule.id); } + /** + * Bridge write: also flips the legacy clearance-side GATEPASS_GRANTED + * milestone for every customs booking on this schedule, so contract/booking + * clearance views still reading that milestone (older deployed builds) see + * the gate pass as done. Drop once every clearance-api deployment reads + * ImportDjiboutiOperation.gatepassGrantedAt directly. + */ + private async completeGatepassMilestoneForSchedule( + scheduleId: string, + securedAt: Date, + ): Promise { + const bookings = await this.dataSource.getRepository(Booking).find({ + where: { trainScheduleId: scheduleId, customsClearingEnabled: true }, + }); + if (bookings.length === 0) return; + + const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone); + const rows = await milestoneRepo.find({ + where: { + bookingId: In(bookings.map((b) => b.id)), + milestoneCode: 'GATEPASS_GRANTED', + }, + }); + + for (const row of rows) { + if (row.status === 'COMPLETED') continue; + row.status = 'COMPLETED'; + row.triggeredAt = securedAt; + row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() }; + await milestoneRepo.save(row); + } + } + async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { const schedule = await this.getImportDjiboutiSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); From a70804da1c742dcf231db4030fa68d74e83a8a26 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 4 Jul 2026 09:08:40 +0000 Subject: [PATCH 100/122] fix: gm step --- .../src/pages/accounts/CompanyProfileForm.tsx | 68 +++++++++++-------- 1 file changed, 41 insertions(+), 27 deletions(-) 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 3cce6d4f8..d7c3b913b 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -11,7 +11,7 @@ import { } from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; -import { AlertCircle, ArrowLeft, ArrowRight, UserCheck } from "lucide-react"; +import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -279,22 +279,39 @@ export default function CompanyProfileForm({ }); }; - /** Fill the General Manager from the eTrade business owner. */ - const useOwnerAsManager = () => { - if (!etradeOwner) return; - setValue("generalManagerName", etradeOwner.name); - setValue("generalManagerEmail", user.email); - setValue("generalManagerPhone", etradeOwner.phone ?? "", { - shouldValidate: true, - }); - }; - // "Same as …" links. A checked card prefills the target step's fields from the // source step and disables them (kept mirrored while linked); unchecking clears // them and re-enables editing. + const [gmSameAsOwner, setGmSameAsOwner] = useState(false); const [contactSameAsGm, setContactSameAsGm] = useState(false); const [poaSameAsContact, setPoaSameAsContact] = useState(false); + // General Manager source: the eTrade-registered business owner when a TIN + // lookup found one, otherwise the registering user's own account details. + const gmSourceName = etradeOwner?.name ?? user.name?.en ?? ""; + const gmSourcePhone = etradeOwner + ? etradeOwner.phone + : toEthiopianE164(user.phoneNumber); + + useEffect(() => { + if (!gmSameAsOwner) return; + setValue("generalManagerName", gmSourceName, { shouldValidate: true }); + setValue("generalManagerEmail", user.email ?? "", { shouldValidate: true }); + setValue("generalManagerPhone", gmSourcePhone ?? "", { + shouldValidate: true, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gmSameAsOwner, gmSourceName, gmSourcePhone, user.email]); + + const toggleGmSameAsOwner = (checked: boolean) => { + setGmSameAsOwner(checked); + if (!checked) { + setValue("generalManagerName", ""); + setValue("generalManagerEmail", ""); + setValue("generalManagerPhone", ""); + } + }; + const gmName = watch("generalManagerName"); const gmEmail = watch("generalManagerEmail"); const gmPhone = watch("generalManagerPhone"); @@ -584,22 +601,19 @@ export default function CompanyProfileForm({ {step === "personnel" && ( <> - - - General Manager - - {etradeOwner && ( - - )} - + + General Manager + + Date: Sat, 4 Jul 2026 09:21:16 +0000 Subject: [PATCH 101/122] changes --- apps/edr-freight-api/Dockerfile | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index b781fb4c0..f9107ed23 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -7,9 +7,6 @@ RUN apk add --no-cache libc6-compat # `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds. ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" -# Puppeteer uses the system Chromium installed in the runner stage — skip the -# ~150MB bundled-Chromium download during pnpm install. -ENV PUPPETEER_SKIP_DOWNLOAD=true RUN corepack enable WORKDIR /app @@ -35,14 +32,8 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy FROM node:24.15.0-alpine AS runner -# Chromium + fonts for headless PDF rendering (puppeteer). Alpine ships the -# binary at /usr/bin/chromium-browser, which the PDF renderer auto-detects -# (also pinned via PUPPETEER_EXECUTABLE_PATH). Without this, PDF generation -# falls back to a degraded hand-built layout. -RUN apk add --no-cache libc6-compat \ - chromium nss freetype harfbuzz ca-certificates ttf-freefont +RUN apk add --no-cache libc6-compat ENV NODE_ENV=production -ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser WORKDIR /app RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 --ingroup nodejs nestjs From 7a8e8dbc961bee789e6dda6fc76a71b5c3793304 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Sat, 4 Jul 2026 12:25:41 +0300 Subject: [PATCH 102/122] Update deploy.yml --- .github/workflows/deploy.yml | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 72ad6de66..62530611c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -182,24 +182,6 @@ jobs: set -euo pipefail docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate - - name: Verify deployment health - if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) - run: | - set -euo pipefail - PORT=$(grep '^PORT=' "${SERVICE_ENV_FILE}" | cut -d= -f2) - echo "Waiting for service to become healthy on port ${PORT}..." - for i in $(seq 1 12); do - if wget -qO- "http://localhost:${PORT}/health/ready" 2>/dev/null | grep -q '"status":"ok"'; then - echo "Service is healthy." - exit 0 - fi - echo "Attempt ${i}/12 — not ready yet, waiting 10s..." - sleep 10 - done - echo "Service failed health check after 120s — rolling back" - docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate || true - exit 1 - - name: Remove npm credentials from workspace if: always() run: rm -f .npmrc .npmrc_temp From 1c15a2117b10491c27cdf55aea23352f948ffbfe Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 4 Jul 2026 09:27:22 +0000 Subject: [PATCH 103/122] feat: add verifcation on the document step --- .../components/onboarding/RoleLicenseStep.tsx | 8 ++ .../src/pages/accounts/CompanyProfileForm.tsx | 100 +++++++++++++++--- 2 files changed, 91 insertions(+), 17 deletions(-) diff --git a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx index 451222841..fa1061622 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx @@ -70,6 +70,8 @@ interface RoleLicenseStepProps { /** Newly-selected files per profile id (not yet uploaded). */ value: Record; onChange: (value: Record) => void; + /** "Business license is required" style error, keyed by profile id. */ + errors?: Record; } /** @@ -82,6 +84,7 @@ export default function RoleLicenseStep({ profiles, value, onChange, + errors, }: RoleLicenseStepProps) { const setFiles = (profileId: string, files: File[]) => { onChange({ ...value, [profileId]: files }); @@ -123,6 +126,11 @@ export default function RoleLicenseStep({ file={buildLicenseSetting(profile.id, label)} value={{ [LICENSE_FILE_KEY]: selected }} uploadedKeys={hasExisting ? [LICENSE_FILE_KEY] : undefined} + errors={ + errors?.[profile.id] + ? { [LICENSE_FILE_KEY]: errors[profile.id] } + : undefined + } onChange={(v) => { const next = v[LICENSE_FILE_KEY]; const files = Array.isArray(next) ? next : next ? [next] : []; 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 d7c3b913b..cc9f81a29 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -21,6 +21,7 @@ import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { CompanyRegistrationData } from "@edr/types"; import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField"; import { SmartFileInput } from "@edr/ui-common"; +import { getMinFiles } from "@/types/fileUploadSettings"; import { api } from "@/services/api"; import RoleLicenseStep, { type RoleLicenseProfile, @@ -358,6 +359,72 @@ export default function CompanyProfileForm({ const hasDocuments = Boolean(uploadSetting?.fields?.length); + // Hard verification for the documents step: required company-level + // documents and a business license per operational profile must both be + // present before the user can continue. + const [documentFieldErrors, setDocumentFieldErrors] = useState< + Record + >({}); + const [licenseFieldErrors, setLicenseFieldErrors] = useState< + Record + >({}); + + const validateRequiredDocuments = (): Record => { + const errs: Record = {}; + for (const field of uploadSetting?.fields ?? []) { + const min = getMinFiles(field); + if (min <= 0) continue; + if ((uploadedDocumentKeys ?? []).includes(field.fileKey)) continue; + const v = documentFiles[field.fileKey]; + const count = Array.isArray(v) ? v.length : v ? 1 : 0; + if (count < min) { + errs[field.fileKey] = `${field.fileLabel} is required`; + } + } + return errs; + }; + + // Every role needs at least one license file (existing or newly selected). + const validateLicenses = (): Record => { + const errs: Record = {}; + for (const p of roleProfiles ?? []) { + const hasNew = (licenseFiles?.[p.id]?.length ?? 0) > 0; + const hasExisting = p.existingFiles.length > 0; + if (!hasNew && !hasExisting) { + errs[p.id] = "Business license is required"; + } + } + return errs; + }; + + const handleDocumentFilesChange = ( + next: Record, + ) => { + setDocumentFiles(next); + setDocumentFieldErrors((prev) => { + if (Object.keys(prev).length === 0) return prev; + const updated = { ...prev }; + for (const key of Object.keys(updated)) { + const v = next[key]; + const hasValue = Array.isArray(v) ? v.length > 0 : v != null; + if (hasValue) delete updated[key]; + } + return updated; + }); + }; + + const handleLicenseFilesChange = (next: Record) => { + onLicenseChange?.(next); + setLicenseFieldErrors((prev) => { + if (Object.keys(prev).length === 0) return prev; + const updated = { ...prev }; + for (const id of Object.keys(updated)) { + if ((next[id]?.length ?? 0) > 0) delete updated[id]; + } + return updated; + }); + }; + // The registration/license details come straight from the eTrade lookup and // are not user-editable — shown as a read-only confirmation once a TIN lookup // (or rehydration) has filled them in. The address fields below are separate: @@ -402,18 +469,21 @@ export default function CompanyProfileForm({ } }; - // Every role needs at least one license file (existing or newly selected). - const licenseComplete = (roleProfiles ?? []).every( - (p) => - (licenseFiles?.[p.id]?.length ?? 0) > 0 || p.existingFiles.length > 0, - ); - const nextStep = async () => { userNavigatedRef.current = true; - // The documents step auto-uploads whatever the user selected as they - // continue (partial uploads are allowed — required-doc completeness is - // re-checked on resume). A failed upload holds them on the step. + // The documents step hard-blocks on required company documents and a + // business license per operational profile before it auto-uploads and + // submits — no partial-completion path forward. if (step === "documents") { + const docErrors = validateRequiredDocuments(); + const licenseErrors = validateLicenses(); + if (Object.keys(docErrors).length > 0 || Object.keys(licenseErrors).length > 0) { + setDocumentFieldErrors(docErrors); + setLicenseFieldErrors(licenseErrors); + setSaveError("Please upload all required documents before continuing."); + return; + } + if (onUploadDocuments) { setSaving(true); try { @@ -427,12 +497,6 @@ export default function CompanyProfileForm({ } } - if (!licenseComplete) { - setSaveError( - "Please upload a business license for each of your operational profiles.", - ); - return; - } setSaveError(null); handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; @@ -749,15 +813,17 @@ export default function CompanyProfileForm({ file={uploadSetting} value={documentFiles} uploadedKeys={uploadedDocumentKeys} + errors={documentFieldErrors} containerClassName="lg:grid grid-cols-2 items-stretch" - onChange={setDocumentFiles} + onChange={handleDocumentFilesChange} /> )} { })} + onChange={handleLicenseFilesChange} + errors={licenseFieldErrors} /> )} From 66ffd51d5b355a82c62d58c60ea735dbfe97742f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 4 Jul 2026 09:27:38 +0000 Subject: [PATCH 104/122] style: update the multi file document ui --- .../src/components/SmartFileInput/index.tsx | 600 ++++++++++++------ 1 file changed, 397 insertions(+), 203 deletions(-) diff --git a/packages/ui-common/src/components/SmartFileInput/index.tsx b/packages/ui-common/src/components/SmartFileInput/index.tsx index 0da3e2f37..1e1eaf223 100644 --- a/packages/ui-common/src/components/SmartFileInput/index.tsx +++ b/packages/ui-common/src/components/SmartFileInput/index.tsx @@ -153,6 +153,76 @@ function ExistingFileLink({ ); } +/** + * A file the user just picked (in memory, not yet persisted). Rendered with a + * subtle "just added" entrance + an emerald accent so a fresh upload reads as + * distinct from the neutral surrounding surface. + */ +function NewFileCard({ + file: fileObj, + onRemove, + disabled, + hasError, + inputName, +}: { + file: File; + onRemove: () => void; + disabled?: boolean; + hasError?: boolean; + inputName: string; +}) { + return ( +
+
+
+ +
+ +
+

+ {fileObj.name} +

+
+ + {formatBytes(fileObj.size)} + + + Ready to upload + +
+
+
+ + + + {/* Hidden input to represent file details in traditional form submissions */} + +
+ ); +} + export function SmartFileInput({ file, value, @@ -407,228 +477,352 @@ export function SmartFileInput({

)} - {/* Selected Files List */} - {currentFiles.length > 0 && ( -
- {currentFiles.map((fileObj, idx) => ( -
-
-
- -
- -
-

- {fileObj.name} -

-
- - {formatBytes(fileObj.size)} - - - Ready - -
-
-
- - - - {/* Hidden inputs to represent file details in traditional form submissions */} - -
- ))} -
- )} - - {/* Dropzone area */} - {!reachedLimit && - (variant === "minimal" ? ( -
- + {/* + Multiple-file fields (default variant) render as ONE integrated + drag-and-drop surface. Uploaded files live INSIDE the dropzone as + lightweight rows — part of the surface, not separate cards — with + the "add more" prompt on the same surface below them. A full-cover + transparent input makes clicking anywhere (outside a file row) + open the picker; the prompt is pointer-transparent so clicks fall + through to it, while file rows and their controls sit above it. + */} + {variant === "default" && field.isMultiple ? ( +
handleDrag(e, field.fileKey, true)} + onDragLeave={(e) => handleDrag(e, field.fileKey, false)} + onDrop={(e) => handleDrop(e, field)} + className={cn( + "relative flex flex-col gap-2.5 rounded-xl border-2 border-dashed p-4 transition-all", + isDragOver + ? "border-primary bg-primary/5 dark:bg-primary/10" + : fieldError + ? "border-destructive/70" + : "border-border bg-card/40 hover:border-primary/40", + disabled && "pointer-events-none opacity-50", + )} + > + {/* Click anywhere on the surface (except a file row) to browse */} + {!reachedLimit && ( { - if (fileInputRefs.current) { - fileInputRefs.current[field.fileKey] = el; - } - }} - multiple={field.isMultiple} + multiple accept={acceptString} disabled={disabled} onChange={(e) => handleFileSelect(e, field)} - className="hidden" - /> - - Accepts:{" "} - {field.allowedExtensions.join(", ").toUpperCase() || - "All"} - - {existingForField.length > 0 && ( -
- {existingForField.map((f, idx) => ( - - ))} -
- )} -
- ) : isUploaded ? ( - // Uploaded state: a solid success panel that still doubles as a - // replace target (click anywhere or drag a new file onto it). -
handleDrag(e, field.fileKey, true)} - onDragLeave={(e) => handleDrag(e, field.fileKey, false)} - onDrop={(e) => handleDrop(e, field)} - className={cn( - "group relative flex items-center gap-4 rounded-lg border p-4 transition-all", - isDragOver - ? "border-2 border-dashed border-primary bg-primary/5 dark:bg-primary/10" - : "border-emerald-300/70 bg-emerald-50/60 dark:border-emerald-500/30 dark:bg-emerald-500/10", - disabled && - "opacity-50 pointer-events-none cursor-not-allowed", - )} - > - handleFileSelect(e, field)} - id={`file-input-${field.fileKey}`} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed" - aria-label={`Replace ${field.fileLabel}`} + className="absolute inset-0 z-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed" + aria-label={`Add files to ${field.fileLabel}`} /> + )} -
- {isDragOver ? ( - - ) : ( - - )} -
- -
-

- {isDragOver ? "Drop to replace" : "Document uploaded"} -

- {existingForField.length > 0 ? ( -
- {existingForField.map((f, idx) => ( + {(existingForField.length > 0 || currentFiles.length > 0) && ( +
+ {/* Already-saved (server) files — view/download only */} + {existingForField.map((f, idx) => ( +
+ +
- ))} +
+ + Saved +
- ) : ( -

- {isDragOver - ? "Release to replace the document on file." - : "Saved to your application. Drag a new file here or click to replace it."} -

+ ))} + + {/* Just-added (in-memory) files */} + {currentFiles.map((fileObj, idx) => ( +
+ +
+

+ {fileObj.name} +

+

+ {formatBytes(fileObj.size)} +

+
+ + Ready + + + +
+ ))} +
+ )} + + {reachedLimit ? ( +
+ + Maximum of {maxFiles} files reached +
+ ) : ( +
0 || currentFiles.length > 0 + ? "py-1" + : "py-6", )} -
- - - - Replace - -
- ) : ( -
handleDrag(e, field.fileKey, true)} - onDragLeave={(e) => handleDrag(e, field.fileKey, false)} - onDrop={(e) => handleDrop(e, field)} - className={cn( - "relative border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center transition-all bg-card/50", - isDragOver - ? "border-primary bg-primary/5 dark:bg-primary/10" - : "border-border hover:border-primary/50 hover:bg-muted/10", - fieldError && - "border-destructive hover:border-destructive/80", - disabled && - "opacity-50 pointer-events-none cursor-not-allowed", - )} - > - handleFileSelect(e, field)} - id={`file-input-${field.fileKey}`} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed" - /> - -
- +
0 || currentFiles.length > 0 + ? "p-1.5" + : "p-3", )} - /> + > + 0 || + currentFiles.length > 0 + ? "h-4 w-4" + : "h-6 w-6", + isDragOver && "animate-bounce text-primary", + )} + /> +
+

+ {isDragOver + ? "Drop your files here" + : existingForField.length > 0 || + currentFiles.length > 0 + ? "Add more files, or " + : "Drag & drop your files here, or "} + {!isDragOver && ( + browse + )} +

+

+ {field.allowedExtensions.join(", ").toUpperCase() || + "All formats"} + {" • "} + {currentFiles.length}/{maxFiles} added +

+ )} +
+ ) : ( + <> + {/* Selected Files List */} + {currentFiles.length > 0 && ( +
+ {currentFiles.map((fileObj, idx) => ( + removeFile(field.fileKey, idx)} + /> + ))} +
+ )} -

- Drag & drop your file here, or{" "} - - browse - -

+ {/* Dropzone area */} + {!reachedLimit && + (variant === "minimal" ? ( +
+ + { + if (fileInputRefs.current) { + fileInputRefs.current[field.fileKey] = el; + } + }} + multiple={field.isMultiple} + accept={acceptString} + disabled={disabled} + onChange={(e) => handleFileSelect(e, field)} + className="hidden" + /> + + Accepts:{" "} + {field.allowedExtensions.join(", ").toUpperCase() || + "All"} + + {existingForField.length > 0 && ( +
+ {existingForField.map((f, idx) => ( + + ))} +
+ )} +
+ ) : isUploaded ? ( + // Uploaded state: a solid success panel that still doubles as a + // replace target (click anywhere or drag a new file onto it). +
handleDrag(e, field.fileKey, true)} + onDragLeave={(e) => handleDrag(e, field.fileKey, false)} + onDrop={(e) => handleDrop(e, field)} + className={cn( + "group relative flex items-center gap-4 rounded-lg border p-4 transition-all", + isDragOver + ? "border-2 border-dashed border-primary bg-primary/5 dark:bg-primary/10" + : "border-emerald-300/70 bg-emerald-50/60 dark:border-emerald-500/30 dark:bg-emerald-500/10", + disabled && + "opacity-50 pointer-events-none cursor-not-allowed", + )} + > + handleFileSelect(e, field)} + id={`file-input-${field.fileKey}`} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed" + aria-label={`Replace ${field.fileLabel}`} + /> -

- Supported formats:{" "} - {field.allowedExtensions.join(", ").toUpperCase() || - "All"} -

-
- ))} +
+ {isDragOver ? ( + + ) : ( + + )} +
+ +
+

+ {isDragOver + ? "Drop to replace" + : "Document uploaded"} +

+ {existingForField.length > 0 ? ( +
+ {existingForField.map((f, idx) => ( + + ))} +
+ ) : ( +

+ {isDragOver + ? "Release to replace the document on file." + : "Saved to your application. Drag a new file here or click to replace it."} +

+ )} +
+ + + + Replace + +
+ ) : ( +
handleDrag(e, field.fileKey, true)} + onDragLeave={(e) => handleDrag(e, field.fileKey, false)} + onDrop={(e) => handleDrop(e, field)} + className={cn( + "relative border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center transition-all bg-card/50", + isDragOver + ? "border-primary bg-primary/5 dark:bg-primary/10" + : "border-border hover:border-primary/50 hover:bg-muted/10", + fieldError && + "border-destructive hover:border-destructive/80", + disabled && + "opacity-50 pointer-events-none cursor-not-allowed", + )} + > + handleFileSelect(e, field)} + id={`file-input-${field.fileKey}`} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed" + /> + +
+ +
+ +

+ Drag & drop your file here, or{" "} + + browse + +

+ +

+ Supported formats:{" "} + {field.allowedExtensions.join(", ").toUpperCase() || + "All"} +

+
+ ))} + + )} {/* Validation Error Message */} {fieldError && ( From 00cd1fccf6c6c32e7f3bbdfbbe72ba2a015538cf Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 4 Jul 2026 09:33:07 +0000 Subject: [PATCH 105/122] train loading for import --- ...ContainerReceiptToBookingContainerUnits.ts | 36 +++++ ...1970000000000-AddCustomerTruckDeparture.ts | 26 ++++ .../modules/bookings/bookings.controller.ts | 52 +++++++ .../src/modules/bookings/bookings.module.ts | 3 + .../bookings/container-receipt.service.ts | 145 ++++++++++++++++++ .../bookings/customer-truck.service.ts | 111 ++++++++++++-- .../bookings/dto/add-customer-truck.dto.ts | 14 +- .../bookings/dto/depart-customer-truck.dto.ts | 37 +++++ .../modules/bookings/dto/generate-grn.dto.ts | 17 ++ .../entities/booking-container-unit.entity.ts | 13 ++ .../customer-truck-assignment.entity.ts | 8 + .../warehouses/warehouse-inventory.service.ts | 69 ++++++++- .../CustomerTruckAssignmentCard.tsx | 37 +++-- 13 files changed, 534 insertions(+), 34 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1960000000000-AddContainerReceiptToBookingContainerUnits.ts create mode 100644 apps/edr-freight-api/src/migrations/1970000000000-AddCustomerTruckDeparture.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/generate-grn.dto.ts diff --git a/apps/edr-freight-api/src/migrations/1960000000000-AddContainerReceiptToBookingContainerUnits.ts b/apps/edr-freight-api/src/migrations/1960000000000-AddContainerReceiptToBookingContainerUnits.ts new file mode 100644 index 000000000..59a0441c5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1960000000000-AddContainerReceiptToBookingContainerUnits.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-container receive tracking. A booking's containers arrive individually + * (on separate self-haul trucks), so each container unit tracks whether it has + * been received into the port and, once staff confirm it, the GRN it belongs to. + * A single GRN covers the containers received together — so if the whole booking + * arrives at once, all its units share one GRN (per-booking GRN). + */ +export class AddContainerReceiptToBookingContainerUnits1960000000000 + implements MigrationInterface +{ + name = 'AddContainerReceiptToBookingContainerUnits1960000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_container_units + ADD COLUMN IF NOT EXISTS received_to_port boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS received_at timestamptz, + ADD COLUMN IF NOT EXISTS grn_number varchar(100) + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_booking_container_units_grn" ON freight.booking_container_units (grn_number);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_booking_container_units_grn";`); + await queryRunner.query(` + ALTER TABLE freight.booking_container_units + DROP COLUMN IF EXISTS received_to_port, + DROP COLUMN IF EXISTS received_at, + DROP COLUMN IF EXISTS grn_number + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1970000000000-AddCustomerTruckDeparture.ts b/apps/edr-freight-api/src/migrations/1970000000000-AddCustomerTruckDeparture.ts new file mode 100644 index 000000000..e36420751 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1970000000000-AddCustomerTruckDeparture.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Import self-haul trucks are weighed on leaving. The customer does not + * pre-specify what an import truck takes — staff register the containers loaded + * and the weighed gross when the truck departs. These columns capture that. + */ +export class AddCustomerTruckDeparture1970000000000 implements MigrationInterface { + name = 'AddCustomerTruckDeparture1970000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_assignments + ADD COLUMN IF NOT EXISTS gross_weight_kg numeric(14, 2), + ADD COLUMN IF NOT EXISTS departed_at timestamptz + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_assignments + DROP COLUMN IF EXISTS gross_weight_kg, + DROP COLUMN IF EXISTS departed_at + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 795040eeb..106b7ed5b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, + ForbiddenException, Get, HttpCode, Param, @@ -62,7 +63,10 @@ import { import { ContractViewDto } from './dto/contract-view.dto'; import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; +import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; import { CustomerTruckService } from './customer-truck.service'; +import { GenerateGrnDto } from './dto/generate-grn.dto'; +import { ContainerReceiptService } from './container-receipt.service'; import { SignContractDto } from './dto/sign-contract.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; import { @@ -86,6 +90,7 @@ export class BookingsController { private readonly contractService: BookingContractService, private readonly bookingClearanceService: BookingClearanceService, private readonly customerTruckService: CustomerTruckService, + private readonly containerReceiptService: ContainerReceiptService, ) {} @Post() @@ -353,6 +358,53 @@ export class BookingsController { return this.customerTruckService.removeTruck(id, assignmentId); } + @Post(':id/customer-trucks/:assignmentId/depart') + @ApiOperation({ + summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)', + }) + async departCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Body() dto: DepartCustomerTruckDto, + @CurrentUser() user: TCurrentUser, + ) { + // Weighing + registering the load on exit is a warehouse/gate staff action. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + throw new ForbiddenException('Only warehouse staff can register a truck departure'); + } + return this.customerTruckService.departTruck(id, assignmentId, dto); + } + + @Get(':id/received-pending-grn') + @ApiOperation({ summary: 'Containers received into port but not yet on a GRN' }) + async receivedPendingGrn( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + // GRN is a warehouse-staff action — no customer access. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + throw new ForbiddenException('Only warehouse staff can view or generate GRNs'); + } + return this.containerReceiptService.listReceivedPendingGrn(id); + } + + @Post(':id/generate-grn') + @ApiOperation({ + summary: + 'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch', + }) + async generateGrn( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: GenerateGrnDto, + @CurrentUser() user: TCurrentUser, + ) { + // GRN is a warehouse-staff action — no customer access. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + throw new ForbiddenException('Only warehouse staff can view or generate GRNs'); + } + return this.containerReceiptService.generateGrn(id, dto.containerNumbers); + } + @Get(':id/tracking') @ApiOperation({ summary: "Shipment tracking timeline for a booking", diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 8f750af34..2cb10ce8e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -37,6 +37,7 @@ import { CustomerTruckAssignment } from './entities/customer-truck-assignment.en import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; import { CustomerTruckService } from './customer-truck.service'; +import { ContainerReceiptService } from './container-receipt.service'; import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { ContractsModule } from '../contracts/contracts.module'; import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity"; @@ -99,6 +100,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; ContractPdfService, CustomerTruckAssignmentsRepository, CustomerTruckService, + ContainerReceiptService, ], exports: [ BookingsService, @@ -106,6 +108,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingPricingService, BookingInvoiceService, CustomerTruckService, + ContainerReceiptService, ], }) export class BookingsModule { } diff --git a/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts b/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts new file mode 100644 index 000000000..fde3ab797 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts @@ -0,0 +1,145 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; + +export interface ReceivedUnitRow { + id: string; + containerNumber: string; + receivedToPort: boolean; + receivedAt: string | null; + grnNumber: string | null; +} + +/** + * Per-container receive + GRN tracking on booking_container_units. + * + * Containers arrive individually (on separate self-haul trucks), so each unit is + * flipped `received_to_port` when its truck arrives (auto). Staff then confirm a + * Goods Received Note over the received-but-un-GRN'd containers: one GRN covers a + * batch, so if the whole booking arrives together every unit shares a single GRN + * (per-booking GRN); if trucks arrive separately each batch gets its own GRN. + */ +@Injectable() +export class ContainerReceiptService { + constructor(private readonly dataSource: DataSource) {} + + /** + * Auto-mark the containers loaded on an arrived truck as received into the + * port. Idempotent — only flips units not already received. Runs inside the + * caller's transaction when a manager is supplied. + */ + async markReceivedForAssignment( + bookingId: string, + assignmentId: string, + manager?: EntityManager, + ): Promise { + const m = manager ?? this.dataSource.manager; + await m.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + updated_at = NOW() + FROM freight.booking_containers bc, + freight.customer_truck_containers ctc + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND ctc.assignment_id = $2 + AND ctc.deleted_at IS NULL + AND ctc.container_number = bcu.container_number + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = false`, + [bookingId, assignmentId], + ); + } + + /** Received-into-port containers that have not yet been assigned a GRN. */ + async listReceivedPendingGrn(bookingId: string): Promise { + return this.dataSource.query( + `SELECT bcu.id, + bcu.container_number AS "containerNumber", + bcu.received_to_port AS "receivedToPort", + bcu.received_at AS "receivedAt", + bcu.grn_number AS "grnNumber" + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = true + AND bcu.grn_number IS NULL + ORDER BY bcu.received_at`, + [bookingId], + ); + } + + /** + * Confirm a GRN over the currently received-but-un-GRN'd containers (optionally + * a subset by container number). Assigns one GRN number to the whole batch and + * returns it with the covered containers. If the batch covers every container + * on the booking it is effectively a per-booking GRN. + */ + async generateGrn( + bookingId: string, + containerNumbers?: string[], + ): Promise<{ grnNumber: string; containerNumbers: string[]; perBooking: boolean }> { + const [booking] = await this.dataSource.query( + `SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + + return this.dataSource.transaction(async (manager) => { + const wanted = containerNumbers?.map((n) => n.trim().toUpperCase()); + const pending: ReceivedUnitRow[] = await manager.query( + `SELECT bcu.id, bcu.container_number AS "containerNumber" + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = true + AND bcu.grn_number IS NULL + ${wanted ? 'AND bcu.container_number = ANY($2::varchar[])' : ''}`, + wanted ? [bookingId, wanted] : [bookingId], + ); + if (!pending.length) { + throw new BadRequestException('No received containers are awaiting a GRN'); + } + + // Batch sequence = number of GRNs already issued for this booking + 1. + const [{ batches }]: Array<{ batches: string }> = await manager.query( + `SELECT COUNT(DISTINCT bcu.grn_number) AS batches + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.grn_number IS NOT NULL AND bcu.deleted_at IS NULL`, + [bookingId], + ); + const seq = Number(batches) + 1; + const grnNumber = `GRN-${String(booking.reference).replace(/^BK-?/i, '')}-${String(seq).padStart(2, '0')}`; + + const ids = pending.map((p) => p.id); + await manager.query( + `UPDATE freight.booking_container_units + SET grn_number = $1, updated_at = NOW() + WHERE id = ANY($2::uuid[])`, + [grnNumber, ids], + ); + + // Per-booking when no container on the booking is left un-GRN'd. + const [{ remaining }]: Array<{ remaining: string }> = await manager.query( + `SELECT COUNT(*) AS remaining + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL AND bcu.grn_number IS NULL`, + [bookingId], + ); + + return { + grnNumber, + containerNumbers: pending.map((p) => p.containerNumber), + perBooking: Number(remaining) === 0 && seq === 1, + }; + }); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index 5ea1a898c..5d0650219 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -7,6 +7,7 @@ import { import { DataSource, EntityManager, IsNull } from 'typeorm'; import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; +import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; @@ -41,17 +42,31 @@ export class CustomerTruckService { const booking = await this.loadBookingGuard(bookingId); this.assertSelfHaulPaid(booking); - const requested = dto.containerNumbers.map((n) => n.trim().toUpperCase()); - const bookingNumbers = await this.bookingContainerNumbers(bookingId); - for (const n of requested) { - if (!bookingNumbers.includes(n)) { - throw new BadRequestException(`Container ${n} is not one of this booking's containers`); + const isExport = booking.tradeDirection === 'EXPORT'; + const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + + // EXPORT: the truck delivers 1–2 known containers. IMPORT: containers are + // not pre-specified — they are registered + weighed when the truck leaves. + if (isExport) { + if (requested.length < 1 || requested.length > 2) { + throw new BadRequestException('An export truck must carry 1 or 2 of the booking containers'); } + } else if (requested.length > 2) { + throw new BadRequestException('A truck carries at most 2 containers'); } - const alreadyAssigned = await this.assignedContainerNumbers(bookingId); - for (const n of requested) { - if (alreadyAssigned.includes(n)) { - throw new ConflictException(`Container ${n} is already loaded onto another truck`); + + if (requested.length) { + const bookingNumbers = await this.bookingContainerNumbers(bookingId); + for (const n of requested) { + if (!bookingNumbers.includes(n)) { + throw new BadRequestException(`Container ${n} is not one of this booking's containers`); + } + } + const alreadyAssigned = await this.assignedContainerNumbers(bookingId); + for (const n of requested) { + if (alreadyAssigned.includes(n)) { + throw new ConflictException(`Container ${n} is already loaded onto another truck`); + } } } @@ -118,6 +133,71 @@ export class CustomerTruckService { return this.listTrucks(bookingId); } + /** + * Register an IMPORT self-haul truck leaving the port: the containers it + * actually loaded (replacing any provisional list) and its weighed gross. + * Export bookings have no truck departure — trucks only deliver (receive). + */ + async departTruck( + bookingId: string, + assignmentId: string, + dto: DepartCustomerTruckDto, + ): Promise { + const booking = await this.loadBookingGuard(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException( + 'Truck departure/weighing applies to import self-haul only (export trucks only deliver)', + ); + } + const assignment = await this.assignments.findByIdWithContainers(assignmentId); + if (!assignment || assignment.bookingId !== bookingId) { + throw new NotFoundException('Truck assignment not found for this booking'); + } + // Once filled, the departure record is uneditable. + if (assignment.departedAt) { + throw new ConflictException('This truck has already departed — its exit record is locked'); + } + + const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (requested.length) { + const bookingNumbers = await this.bookingContainerNumbers(bookingId); + for (const n of requested) { + if (!bookingNumbers.includes(n)) { + throw new BadRequestException(`Container ${n} is not one of this booking's containers`); + } + } + const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); + for (const n of requested) { + if (elsewhere.includes(n)) { + throw new ConflictException(`Container ${n} is already loaded onto another truck`); + } + } + } + + await this.dataSource.transaction(async (manager) => { + if (requested.length) { + // Replace the truck's containers with what was actually loaded. + await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); + await manager.getRepository(CustomerTruckContainer).save( + requested.map((containerNumber) => + manager.getRepository(CustomerTruckContainer).create({ + assignmentId, + bookingId, + containerNumber, + }), + ), + ); + } + await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { + grossWeightKg: dto.grossWeightKg, + departedAt: dto.gateOutTime ? new Date(dto.gateOutTime) : new Date(), + arrivedAt: assignment.arrivedAt ?? new Date(), + }); + }); + + return this.listTrucks(bookingId); + } + /** * Mark the truck carrying `containerNumber` as arrived. Called by the warehouse * receive flow. When every truck on the booking has arrived, the booking-level @@ -225,4 +305,17 @@ export class CustomerTruckService { ); return rows.map((r) => r.containerNumber.trim().toUpperCase()); } + + private async assignedContainerNumbersExcept( + bookingId: string, + exceptAssignmentId: string, + ): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT container_number AS "containerNumber" + FROM freight.customer_truck_containers + WHERE booking_id = $1 AND assignment_id <> $2 AND deleted_at IS NULL`, + [bookingId, exceptAssignmentId], + ); + return rows.map((r) => r.containerNumber.trim().toUpperCase()); + } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts index 17458dafa..4356d66ec 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts @@ -1,10 +1,10 @@ import { ArrayMaxSize, - ArrayMinSize, ArrayUnique, IsArray, IsIn, IsNotEmpty, + IsOptional, IsString, Matches, MaxLength, @@ -13,9 +13,11 @@ import { import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; /** - * Add one external customer truck to a booking, carrying 1–2 container numbers. - * Each container must be one of the booking's containers and not already loaded - * onto another truck (enforced in the service + a partial unique index). + * Add one external customer truck to a booking. + * - EXPORT: the truck delivers 1–2 known containers (required, validated in the + * service against the booking's containers). + * - IMPORT: the customer does not pre-specify — containers are registered and + * weighed when the truck leaves, so `containerNumbers` may be omitted/empty. */ export class AddCustomerTruckDto { @IsString() @@ -33,13 +35,13 @@ export class AddCustomerTruckDto { @IsIn(CUSTOMER_TRUCK_TYPES) truckType!: string; + @IsOptional() @IsArray() - @ArrayMinSize(1) @ArrayMaxSize(2) @ArrayUnique() @Matches(/^[A-Z]{4}\d{7}$/, { each: true, message: 'each container number must match ISO container format, e.g. ABCD1234567', }) - containerNumbers!: string[]; + containerNumbers?: string[]; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts new file mode 100644 index 000000000..31ab1b5bd --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts @@ -0,0 +1,37 @@ +import { + ArrayMaxSize, + ArrayUnique, + IsArray, + IsDateString, + IsNumber, + IsOptional, + Matches, + Min, +} from 'class-validator'; + +/** + * Register an import self-haul truck leaving the port: the containers it actually + * loaded (staff read them off the truck) and the weighed gross. Container numbers + * are optional here only because they may already have been recorded; the weighed + * gross is required. + */ +export class DepartCustomerTruckDto { + @IsOptional() + @IsArray() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers?: string[]; + + @IsNumber() + @Min(0) + grossWeightKg!: number; + + /** Gate-out time. Defaults to now when omitted. */ + @IsOptional() + @IsDateString() + gateOutTime?: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-grn.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-grn.dto.ts new file mode 100644 index 000000000..2f5ea86af --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-grn.dto.ts @@ -0,0 +1,17 @@ +import { ArrayUnique, IsArray, IsOptional, Matches } from 'class-validator'; + +/** + * Confirm a Goods Received Note. Omit `containerNumbers` to GRN every + * received-but-un-GRN'd container on the booking (per-booking when that's all of + * them); pass a subset to GRN just those. + */ +export class GenerateGrnDto { + @IsOptional() + @IsArray() + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers?: string[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts index e8ef1b138..619013280 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts @@ -34,4 +34,17 @@ export class BookingContainerUnit extends BaseEntity { @Column({ name: 'sort_order', type: 'smallint', default: 0 }) sortOrder!: number; + + /** Whether this container has been received into the port (auto-set when its + * self-haul truck arrives). */ + @Column({ name: 'received_to_port', type: 'boolean', default: false }) + receivedToPort!: boolean; + + @Column({ name: 'received_at', type: 'timestamptz', nullable: true }) + receivedAt?: Date | null; + + /** The GRN this container was received under (assigned when staff confirm the + * Goods Received Note for a batch of received containers). */ + @Column({ name: 'grn_number', type: 'varchar', length: 100, nullable: true }) + grnNumber?: string | null; } diff --git a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts index 83b70a135..6eeaba963 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts @@ -34,6 +34,14 @@ export class CustomerTruckAssignment extends BaseEntity { @Column({ name: 'arrived_at', type: 'timestamptz', nullable: true }) arrivedAt?: Date | null; + /** Weighed gross of what the truck actually loaded (import), captured on + * leaving. Null until the truck departs. */ + @Column({ name: 'gross_weight_kg', type: 'numeric', precision: 14, scale: 2, nullable: true }) + grossWeightKg?: number | null; + + @Column({ name: 'departed_at', type: 'timestamptz', nullable: true }) + departedAt?: Date | null; + @OneToMany(() => CustomerTruckContainer, (c) => c.assignment, { cascade: true }) containers?: CustomerTruckContainer[]; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 9a9f8caa4..c9844051b 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -920,6 +920,23 @@ export class WarehouseInventoryService { }), ); + // Receiving the booking flags every container unit as received into the + // port (self-haul export: the delivering truck's goods are now in) so + // staff can raise the per-container GRN over what's received. + await manager.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + updated_at = NOW() + FROM freight.booking_containers bc + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND bc.deleted_at IS NULL + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = false`, + [bookingId], + ); + await this.activityLog.record( { activityType: 'INVENTORY_RECEIVED', @@ -1774,6 +1791,26 @@ export class WarehouseInventoryService { await this.applyCapacityDelta(manager, dto, weight, volume, containerCount); + // Per-container receive: flag this container's unit as received into the + // port so staff can raise the GRN over what's received. + if (dto.bookingId && dto.containerId) { + await manager.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + updated_at = NOW() + FROM freight.booking_containers bc, freight.containers cont + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND bc.deleted_at IS NULL + AND cont.id = $2 + AND cont.container_number = bcu.container_number + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = false`, + [dto.bookingId, dto.containerId], + ); + } + await this.activityLog.record( { activityType: 'INVENTORY_RECEIVED', @@ -2084,6 +2121,9 @@ export class WarehouseInventoryService { AND a.deleted_at IS NULL`, [item.bookingId, item.containerId], ); + // NB: import arrival changes nothing on the goods — received_to_port is + // an EXPORT concept (set when a truck delivers into the port). Import + // load + weight are captured on truck departure, not arrival. } // Booking-level flag stamped on the FIRST truck arrival. The import // handover is signed ONCE (before the first truck leaves), even though @@ -2175,12 +2215,16 @@ export class WarehouseInventoryService { truckType: string; containerNumbers: string; truckWeightTons: string | number | null; + grossWeightKg: string | number | null; + departedAt: string | null; } | null = null; if (row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) { const [truckRow] = await this.dataSource.query( `SELECT a.plate_number AS "plateNumber", a.driver_name AS "driverName", a.truck_type AS "truckType", + a.gross_weight_kg AS "grossWeightKg", + a.departed_at AS "departedAt", string_agg(DISTINCT c2.container_number, ', ' ORDER BY c2.container_number) AS "containerNumbers", COALESCE(( SELECT SUM(bcu.vgm_tons) @@ -2231,8 +2275,14 @@ export class WarehouseInventoryService { truckPlateNumber: truck?.plateNumber ?? null, truckDriverName: truck?.driverName ?? null, truckType: truck?.truckType ?? null, - truckContainers: truck?.containerNumbers ?? null, - truckWeightKg: truck ? Number(truck.truckWeightTons ?? 0) * 1000 : null, + truckGateOut: truck?.departedAt ?? null, + // Prefer the weighed gross captured on departure; fall back to the summed + // container VGM when the truck hasn't been weighed yet. + truckWeightKg: truck + ? Number(truck.grossWeightKg ?? 0) > 0 + ? Number(truck.grossWeightKg) + : Number(truck.truckWeightTons ?? 0) * 1000 + : null, }); return { @@ -3165,7 +3215,7 @@ export class WarehouseInventoryService { truckPlateNumber?: string | null; truckDriverName?: string | null; truckType?: string | null; - truckContainers?: string | null; + truckGateOut?: string | null; truckWeightKg?: number | null; }): string { const esc = (value: unknown) => @@ -3208,7 +3258,18 @@ export class WarehouseInventoryService { ['Pickup Truck Plate', data.truckPlateNumber], ['Truck Driver', data.truckDriverName], ['Truck Type', data.truckType], - ['Containers Loaded on Truck', data.truckContainers], + [ + 'Gate-Out Time', + data.truckGateOut + ? new Date(data.truckGateOut).toLocaleString('en-GB', { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + : null, + ], ] as [string, string | null][]) : []), ...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []), diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx index ad7f6b72b..65af584d3 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx @@ -73,6 +73,10 @@ export function CustomerTruckAssignmentCard({ (n) => !assignedNumbers.has(n), ); + // EXPORT trucks deliver known containers (pre-selected). IMPORT trucks don't — + // staff register + weigh what was loaded when the truck leaves. + const isExport = booking.tradeDirection === "EXPORT"; + const resetForm = () => { setPlateNumber(""); setDriverName(""); @@ -87,7 +91,8 @@ export function CustomerTruckAssignmentCard({ truckPlateNumber: plateNumber.trim().toUpperCase(), driverName: driverName.trim(), truckType: truckType.trim(), - containerNumbers: containers, + // Import: containers are registered + weighed on departure, not here. + containerNumbers: isExport ? containers : [], }), onSuccess: (list) => { queryClient.setQueryData(trucksKey, list); @@ -118,7 +123,7 @@ export function CustomerTruckAssignmentCard({ setError("Plate number, driver name and truck type are required."); return; } - if (containers.length < 1 || containers.length > 2) { + if (isExport && (containers.length < 1 || containers.length > 2)) { setError("Select 1 or 2 container numbers for this truck."); return; } @@ -202,8 +207,8 @@ export function CustomerTruckAssignmentCard({ )} - {/* Add-truck form */} - {availableContainers.length > 0 ? ( + {/* Add-truck form. Export needs unassigned containers; import always allows another truck. */} + {(isExport ? availableContainers.length > 0 : true) ? ( <> @@ -226,17 +231,19 @@ export function CustomerTruckAssignmentCard({ value={truckType || null} onChange={(value) => setTruckType(value ?? "")} /> - + {isExport && ( + + )} + Continue + -

- Already have an account?{" "} - -

- - + < p className = "text-center text-sm text-gray-500" > + Already have an account ? { " "} + < button + type = "button" +onClick = {() => navigate("/login")} +className = "font-semibold text-primary hover:underline" + > + Sign In + +

+ + ) : ( - -
- - - -
-
-

- Verify your {otpChannel === "email" ? "email" : "phone"} -

-

- We sent a 6-digit code to{" "} - - {otpChannel === "email" - ? maskEmail(pendingData?.email ?? "") - : maskPhone(pendingData?.phone ?? "")} - - . Enter it to finish creating your account. + +

+ + + +
+ < div className = "space-y-1.5 text-center" > +

+ Verify your { otpChannel === "email" ? "email" : "phone" } +

+ < p className = "text-sm leading-relaxed text-gray-500" > + We sent a 6 - digit code to{ " " } + + { otpChannel === "email" + ? maskEmail(pendingData?.email ?? "") + : maskPhone(pendingData?.phone ?? "")} + + .Enter it to finish creating your account.

-
+
- {otpError ? ( - } +{ + otpError ? ( + } > - {otpError} - + { otpError } + ) : null} - - - Verification code - - - + + + Verification code + + < PinInput +length = { 6} +type = "number" +oneTimeCode +value = { otpCode } +placeholder = "0" +disabled = { verifying } +styles = {{ input: { textAlign: "center" } }} +onChange = { setOtpCode } + /> + - + < Button +color = "edr-green" +fullWidth +loading = { verifying } +disabled = { verifying || otpCode.trim().length !== 6} +onClick = { confirmOtp } + > + Verify & amp; create account + -
- - -
- + Back + + < Button +variant = "subtle" +color = "edr-green" +leftSection = {< RotateCw size = { 14} />} +disabled = { resendIn > 0 || sending || verifying} +onClick = { resendOtp } + > + { resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"} + +
+
)} -
- +
+ ); } diff --git a/apps/edr-freight-web/portal/src/services/auth.service.ts b/apps/edr-freight-web/portal/src/services/auth.service.ts index 58b81c5ba..3f9ef4e53 100644 --- a/apps/edr-freight-web/portal/src/services/auth.service.ts +++ b/apps/edr-freight-web/portal/src/services/auth.service.ts @@ -54,7 +54,7 @@ export const authService = { }, checkAvailability: async (params: CheckAvailabilityPayload) => { - const res = await client.get>( + const res = await client.get( URL_CONSTANTS.USERS.CHECK_AVAILABILITY, { params }, ); From 595be6e123bb428972cdf1ba6c64673946b3331b Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sun, 5 Jul 2026 00:28:06 +0300 Subject: [PATCH 109/122] Tour package booking, app release, new endpoints, more updates and fixes --- .../migration.sql | 9 + .../migration.sql | 2 + .../migration.sql | 13 + apps/edr-passenger-api/prisma/schema.prisma | 25 +- apps/edr-passenger-api/prisma/seed.ts | 70 +- apps/edr-passenger-api/src/app.module.ts | 2 + .../common/filters/http-exception.filter.ts | 17 +- .../app-releases/app-releases.controller.ts | 50 ++ .../app-releases/app-releases.module.ts | 11 + .../app-releases/app-releases.service.ts | 71 ++ .../src/modules/audit/audit.controller.ts | 4 +- .../src/modules/bookings/bookings.dto.ts | 6 + .../src/modules/bookings/bookings.service.ts | 414 +++++++++-- .../modules/bookings/guest-booking.service.ts | 20 +- .../fare-engine/fare-engine.service.ts | 4 +- .../src/modules/fraud/fraud.controller.ts | 27 +- .../src/modules/fraud/fraud.module.ts | 3 +- .../src/modules/fraud/fraud.service.ts | 10 + .../src/modules/loyalty/loyalty.controller.ts | 4 +- .../src/modules/loyalty/loyalty.service.ts | 47 ++ .../modules/packages/packages.controller.ts | 32 +- .../src/modules/packages/packages.dto.ts | 8 +- .../src/modules/packages/packages.module.ts | 3 +- .../src/modules/packages/packages.service.ts | 115 ++- .../modules/passengers/passengers.service.ts | 54 +- .../modules/payments/payments.controller.ts | 9 + .../src/modules/payments/payments.service.ts | 7 + .../src/modules/search/search.controller.ts | 31 +- .../src/modules/search/search.dto.ts | 37 + .../src/modules/search/search.service.ts | 120 +++- .../seat-classes/seat-classes.service.ts | 19 +- .../src/modules/seats/seats.controller.ts | 19 + .../src/modules/seats/seats.service.ts | 69 +- .../src/modules/stations/stations.service.ts | 31 +- .../src/modules/wallet/wallet.controller.ts | 8 +- .../src/modules/wallet/wallet.service.ts | 46 ++ .../src/app/app-releases/layout.tsx | 5 + .../backoffice/src/app/app-releases/page.tsx | 186 +++++ .../backoffice/src/app/audit/page.tsx | 4 +- .../backoffice/src/app/bookings/page.tsx | 104 ++- .../backoffice/src/app/classes/page.tsx | 15 +- .../backoffice/src/app/coaches/page.tsx | 93 ++- .../backoffice/src/app/loyalty/page.tsx | 33 +- .../src/app/package-bookings/layout.tsx | 7 + .../src/app/package-bookings/page.tsx | 255 +++++++ .../backoffice/src/app/payments/page.tsx | 42 +- .../backoffice/src/app/reports/page.tsx | 83 ++- .../backoffice/src/app/routes/page.tsx | 15 +- .../backoffice/src/app/stations/page.tsx | 15 +- .../backoffice/src/app/trains/page.tsx | 15 +- .../src/app/wallet-accounts/layout.tsx | 5 + .../src/app/wallet-accounts/page.tsx | 206 ++++++ .../src/components/layout/Sidebar.tsx | 31 +- .../src/components/ui/ConfirmDialog.tsx | 32 +- .../backoffice/src/lib/api/bookings.ts | 2 + .../backoffice/src/lib/api/index.ts | 41 +- .../backoffice/src/types/index.ts | 2 + .../src/app/booking/confirmation/page.tsx | 21 +- .../portal/src/app/booking/payment/page.tsx | 4 +- .../portal/src/app/booking/results/page.tsx | 56 +- .../portal/src/app/booking/review/page.tsx | 117 +-- .../portal/src/app/booking/seats/page.tsx | 28 +- .../portal/src/app/packages/[id]/page.tsx | 676 +++++++----------- .../portal/src/app/packages/page.tsx | 5 + .../portal/src/components/PackagesSection.tsx | 11 +- .../portal/src/lib/booking-store.ts | 21 +- .../portal/src/types/index.ts | 1 + .../portal/src/utils/fare-utils.ts | 12 +- 68 files changed, 2773 insertions(+), 787 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260704132103_add_package_fields_to_booking/migration.sql create mode 100644 apps/edr-passenger-api/prisma/migrations/20260704212551_add_fraud_alert_acknowledged_at/migration.sql create mode 100644 apps/edr-passenger-api/prisma/migrations/20260705000000_add_app_releases/migration.sql create mode 100644 apps/edr-passenger-api/src/modules/app-releases/app-releases.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/app-releases/app-releases.module.ts create mode 100644 apps/edr-passenger-api/src/modules/app-releases/app-releases.service.ts create mode 100644 apps/edr-passenger-web/backoffice/src/app/app-releases/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/package-bookings/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/wallet-accounts/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/wallet-accounts/page.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/packages/page.tsx diff --git a/apps/edr-passenger-api/prisma/migrations/20260704132103_add_package_fields_to_booking/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260704132103_add_package_fields_to_booking/migration.sql new file mode 100644 index 000000000..365c15559 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260704132103_add_package_fields_to_booking/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "Booking" ADD COLUMN "packageId" TEXT, +ADD COLUMN "priceTierId" TEXT; + +-- AddForeignKey +ALTER TABLE "Booking" ADD CONSTRAINT "Booking_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "TravelPackage"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Booking" ADD CONSTRAINT "Booking_priceTierId_fkey" FOREIGN KEY ("priceTierId") REFERENCES "PackagePriceTier"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260704212551_add_fraud_alert_acknowledged_at/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260704212551_add_fraud_alert_acknowledged_at/migration.sql new file mode 100644 index 000000000..51e09889a --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260704212551_add_fraud_alert_acknowledged_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "FraudAlert" ADD COLUMN "acknowledgedAt" TIMESTAMP(3); diff --git a/apps/edr-passenger-api/prisma/migrations/20260705000000_add_app_releases/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260705000000_add_app_releases/migration.sql new file mode 100644 index 000000000..0ce87d7de --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260705000000_add_app_releases/migration.sql @@ -0,0 +1,13 @@ +CREATE TABLE "passenger"."AppRelease" ( + "id" TEXT NOT NULL, + "os" TEXT NOT NULL, + "version" TEXT NOT NULL, + "forceUpdate" BOOLEAN NOT NULL DEFAULT false, + "storeLink" TEXT, + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "AppRelease_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "AppRelease_os_version_key" ON "passenger"."AppRelease"("os", "version"); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 68b3ac234..ba3571ab1 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -506,6 +506,8 @@ model Booking { bookingRef String @unique passengerId String scheduleId String + packageId String? + priceTierId String? bookingType String @default("ONE_WAY") status BookingStatus @default(DRAFT) currency String @default("ETB") @@ -544,6 +546,8 @@ model Booking { passenger Passenger @relation(fields: [passengerId], references: [id]) schedule TrainSchedule @relation("OutboundSchedule", fields: [scheduleId], references: [id]) returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id]) + package TravelPackage? @relation(fields: [packageId], references: [id]) + priceTier PackagePriceTier? @relation(fields: [priceTierId], references: [id]) seats BookingSeat[] paymentIntent PaymentIntent? tickets Ticket[] @@ -1301,6 +1305,7 @@ model FraudAlert { context Json severity String @default("MEDIUM") acknowledged Boolean @default(false) + acknowledgedAt DateTime? createdAt DateTime @default(now()) @@index([iamUserId, createdAt]) @@index([acknowledged]) @@ -1428,7 +1433,8 @@ model TravelPackage { outboundSchedule TrainSchedule @relation("PackageOutbound", fields: [outboundScheduleId], references: [id]) returnSchedule TrainSchedule @relation("PackageReturn", fields: [returnScheduleId], references: [id]) priceTiers PackagePriceTier[] - bookings PackageBooking[] + bookings Booking[] + packageBookings PackageBooking[] inquiries PackageInquiry[] @@index([status, validFrom]) @@ -1446,7 +1452,8 @@ model PackagePriceTier { bookedSeats Int @default(0) package TravelPackage @relation(fields: [packageId], references: [id]) - bookings PackageBooking[] + bookings Booking[] + packageBookings PackageBooking[] inquiries PackageInquiry[] @@unique([packageId, seatType]) @@ -1536,3 +1543,17 @@ model PackageInquiry { @@index([packageId]) @@schema("passenger") } + +model AppRelease { + id String @id @default(uuid()) + os String // "android" | "ios" + version String + forceUpdate Boolean @default(false) + storeLink String? + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([os, version]) + @@schema("passenger") +} diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 0e9f85079..313ce34b0 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -854,26 +854,64 @@ async function runStep(name: string, step: () => Promise): Promise Promise]> = [ - ['System Users', seedSystemUsers], - ['Stations', seedStations], - ['Coach Types & Classes', seedCoachTypesAndClasses], - ['Route', seedRoute], - ['Coaches', seedCoaches], - ['Trips', seedTrips], - ['Fare Rules', seedFareRules], - ['Currency', seedCurrency], - ['Payment Methods', seedPaymentMethods], - ['Segment Fares', seedSegmentFares], - ['Notification Templates', seedNotificationTemplates], - ['Menu & Food', seedMenuAndFood], - ['Promotions', seedPromotions], - ['FAQ', seedFAQ], - ['Fraud Rules', seedFraudRules], - ['Kulubbi Package', seedKulubbiPackage], + // ['System Users', seedSystemUsers], + // ['Stations', seedStations], + // ['Coach Types & Classes', seedCoachTypesAndClasses], + // ['Route', seedRoute], + // ['Coaches', seedCoaches], + // ['Trips', seedTrips], + // ['Fare Rules', seedFareRules], + // ['Currency', seedCurrency], + // ['Payment Methods', seedPaymentMethods], + // ['Segment Fares', seedSegmentFares], + // ['Notification Templates', seedNotificationTemplates], + // ['Menu & Food', seedMenuAndFood], + // ['Promotions', seedPromotions], + // ['FAQ', seedFAQ], + // ['Fraud Rules', seedFraudRules], + // ['Kulubbi Package', seedKulubbiPackage], + // ['Package Bookings', seedPackageBookings], ]; let failed = 0; diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index ba7a18086..65381f519 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -61,6 +61,7 @@ import { PackagesModule } from './modules/packages/packages.module'; import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module'; import { HealthModule } from './modules/health/health.module'; import { TasksModule } from './modules/tasks/tasks.module'; +import { AppReleasesModule } from './modules/app-releases/app-releases.module'; @Module({ imports: [ @@ -130,6 +131,7 @@ import { TasksModule } from './modules/tasks/tasks.module'; ExcessBaggageModule, HealthModule, TasksModule, + AppReleasesModule, ], providers: [ { provide: APP_GUARD, useClass: DynamicThrottlerGuard }, diff --git a/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts b/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts index 39d492b2c..0cc8d93b6 100644 --- a/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts +++ b/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts @@ -6,6 +6,7 @@ import { HttpStatus, Logger, } from '@nestjs/common'; +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; @Catch() export class HttpExceptionFilter implements ExceptionFilter { @@ -22,15 +23,27 @@ export class HttpExceptionFilter implements ExceptionFilter { const response = ctx.getResponse(); const request = ctx.getRequest(); + let prismaMessage: string | null = null; + if (exception instanceof PrismaClientKnownRequestError) { + if (exception.code === 'P2003') { + const field = (exception.meta?.field_name as string | undefined) ?? 'a related record'; + prismaMessage = `Cannot delete this record because it is still referenced by ${field}. Remove the related records first.`; + } else if (exception.code === 'P2025') { + prismaMessage = 'Record not found.'; + } + } + const status = exception instanceof HttpException ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; + : prismaMessage + ? HttpStatus.BAD_REQUEST + : HttpStatus.INTERNAL_SERVER_ERROR; const messageRaw = exception instanceof HttpException ? exception.getResponse() - : 'Internal server error'; + : prismaMessage ?? 'Internal server error'; const message = typeof messageRaw === 'string' diff --git a/apps/edr-passenger-api/src/modules/app-releases/app-releases.controller.ts b/apps/edr-passenger-api/src/modules/app-releases/app-releases.controller.ts new file mode 100644 index 000000000..7fbfa7de0 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/app-releases/app-releases.controller.ts @@ -0,0 +1,50 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, SetMetadata } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiParam } from '@nestjs/swagger'; +import { AppReleasesService, AppReleaseDto } from './app-releases.service'; +import { PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; + +@ApiTags('App Releases') +@Controller('app-releases') +export class AppReleasesController { + constructor(private service: AppReleasesService) {} + + @Get() + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'List all app releases (public)' }) + getAll() { + return this.service.getAll(); + } + + @Get('latest/:os') + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'Get latest release for a given OS (public)' }) + @ApiParam({ name: 'os', enum: ['android', 'ios'] }) + getLatest(@Param('os') os: string) { + return this.service.getLatest(os); + } + + @Post() + @PassengerStaff(PASSENGER_PERMS.admin) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Create an app release (admin)' }) + create(@Body() dto: AppReleaseDto) { + return this.service.create(dto); + } + + @Patch(':id') + @PassengerStaff(PASSENGER_PERMS.admin) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Update an app release (admin)' }) + update(@Param('id') id: string, @Body() dto: Partial) { + return this.service.update(id, dto); + } + + @Delete(':id') + @PassengerStaff(PASSENGER_PERMS.admin) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Delete an app release (admin)' }) + remove(@Param('id') id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-passenger-api/src/modules/app-releases/app-releases.module.ts b/apps/edr-passenger-api/src/modules/app-releases/app-releases.module.ts new file mode 100644 index 000000000..89e1f733a --- /dev/null +++ b/apps/edr-passenger-api/src/modules/app-releases/app-releases.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AppReleasesController } from './app-releases.controller'; +import { AppReleasesService } from './app-releases.service'; +import { PrismaModule } from '../../common/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [AppReleasesController], + providers: [AppReleasesService], +}) +export class AppReleasesModule {} diff --git a/apps/edr-passenger-api/src/modules/app-releases/app-releases.service.ts b/apps/edr-passenger-api/src/modules/app-releases/app-releases.service.ts new file mode 100644 index 000000000..16b221ecc --- /dev/null +++ b/apps/edr-passenger-api/src/modules/app-releases/app-releases.service.ts @@ -0,0 +1,71 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PrismaService } from '../../common/prisma.service'; + +export class AppReleaseDto { + @ApiProperty({ enum: ['android', 'ios'] }) + @IsIn(['android', 'ios']) + os: string; + + @ApiProperty({ example: '1.2.3' }) + @IsString() + version: string; + + @ApiProperty({ default: false }) + @IsBoolean() + forceUpdate: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + storeLink?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} + +@Injectable() +export class AppReleasesService { + constructor(private prisma: PrismaService) {} + + private get db() { + return (this.prisma as any); + } + + getAll() { + return this.db.appRelease.findMany({ orderBy: [{ os: 'asc' }, { createdAt: 'desc' }] }); + } + + async getLatest(os: string) { + const release = await this.db.appRelease.findFirst({ + where: { os }, + orderBy: { createdAt: 'desc' }, + }); + if (!release) throw new NotFoundException(`No release found for ${os}`); + return release; + } + + async create(dto: AppReleaseDto) { + const existing = await this.db.appRelease.findUnique({ + where: { os_version: { os: dto.os, version: dto.version } }, + }); + if (existing) throw new ConflictException(`Release ${dto.os} ${dto.version} already exists`); + return this.db.appRelease.create({ data: dto }); + } + + async update(id: string, dto: Partial) { + const release = await this.db.appRelease.findUnique({ where: { id } }); + if (!release) throw new NotFoundException('App release not found'); + return this.db.appRelease.update({ where: { id }, data: dto }); + } + + async remove(id: string) { + const release = await this.db.appRelease.findUnique({ where: { id } }); + if (!release) throw new NotFoundException('App release not found'); + await this.db.appRelease.delete({ where: { id } }); + return { deleted: true, id }; + } +} diff --git a/apps/edr-passenger-api/src/modules/audit/audit.controller.ts b/apps/edr-passenger-api/src/modules/audit/audit.controller.ts index 1202e4d45..3a1e94760 100644 --- a/apps/edr-passenger-api/src/modules/audit/audit.controller.ts +++ b/apps/edr-passenger-api/src/modules/audit/audit.controller.ts @@ -30,8 +30,8 @@ export class AuditController { entityType: entityType || undefined, }; - const items = await this.auditService.getLogs(filters); - return { items }; + const result = await this.auditService.getLogs(filters); + return { items: result.data, total: result.total, limit: result.limit, offset: result.offset }; } @Get('logs/:id') diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index 4062acddc..170ff84a3 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -133,6 +133,12 @@ export class CreateBookingDto { @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[]; + @ApiPropertyOptional({ description: 'Package ID — when set, fare is taken from the package price tier instead of the fare engine' }) + @IsOptional() @IsString() packageId?: string; + + @ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' }) + @IsOptional() @IsString() priceTierId?: string; + @ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' }) @IsOptional() @IsString() promoCode?: string; diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 2ca7d5ac4..1aba4ea97 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -202,9 +202,12 @@ export class BookingsService { async findAll(filters: BookingFilters = {}) { const { search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; - + + const onlyPackages = bookingType === 'PACKAGE'; + const includePackageBookings = !returnLegStatus && bookingType !== 'ONE_WAY' && bookingType !== 'ROUND_TRIP' && bookingType !== 'TRANSIT' && bookingType !== 'ROUND_TRIP_TRANSIT'; + const where: any = {}; - + if (search) { const iamRows = await this.dataSource.query<{ id: string }[]>( `SELECT u.id FROM iam.users u @@ -229,10 +232,10 @@ export class BookingsService { { seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } }, ]; } - + if (status) where.status = status; if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus; - if (bookingType) where.bookingType = bookingType; + if (bookingType && !onlyPackages) where.bookingType = bookingType; if (dateFrom || dateTo) { where.createdAt = { ...(dateFrom ? { gte: new Date(dateFrom) } : {}), @@ -240,17 +243,125 @@ export class BookingsService { }; } if (paymentStatus) { - const statusMap: Record = { - PAID: 'SUCCEEDED', - PENDING: 'REQUIRES_ACTION', - FAILED: 'FAILED', - REFUNDED: 'REFUNDED', - }; + const statusMap: Record = { PAID: 'SUCCEEDED', PENDING: 'REQUIRES_ACTION', FAILED: 'FAILED', REFUNDED: 'REFUNDED' }; const mapped = statusMap[paymentStatus] ?? paymentStatus; where.paymentIntent = { is: { status: mapped } }; } - - const [items, total] = await Promise.all([ + + const pkgWhere: any = {}; + if (search) { + pkgWhere.OR = [ + { bookingRef: { contains: search, mode: 'insensitive' } }, + { contactEmail: { contains: search, mode: 'insensitive' } }, + { contactPhone: { contains: search, mode: 'insensitive' } }, + { passengers: { some: { passengerName: { contains: search, mode: 'insensitive' } } } }, + ]; + } + if (status) pkgWhere.status = status; + if (dateFrom || dateTo) pkgWhere.createdAt = where.createdAt; + if (paymentStatus) pkgWhere.paymentIntent = { is: { status: (where.paymentIntent as any)?.is?.status } }; + + if (onlyPackages) { + // Package bookings live in two places: + // 1. PackageBooking table (dedicated package bookings) + // 2. Booking table with packageId != null (round-trip bookings linked to a package) + const bookingPkgWhere: any = { packageId: { not: null } }; + if (status) bookingPkgWhere.status = status; + if (dateFrom || dateTo) bookingPkgWhere.createdAt = where.createdAt; + if (paymentStatus) bookingPkgWhere.paymentIntent = where.paymentIntent; + if (search) bookingPkgWhere.OR = where.OR; + + const [pkgItems, pkgTotal, regPkgItems, regPkgTotal] = await Promise.all([ + this.prisma.packageBooking.findMany({ + where: pkgWhere, + skip, + take: pageSize, + orderBy: { createdAt: 'desc' }, + include: { + package: { select: { id: true, name: true, code: true } }, + priceTier: { select: { id: true, label: true, seatType: true } }, + passengers: true, + paymentIntent: true, + }, + }), + this.prisma.packageBooking.count({ where: pkgWhere }), + this.prisma.booking.findMany({ + where: bookingPkgWhere, + skip, + take: pageSize, + orderBy: { createdAt: 'desc' }, + include: { + passenger: { select: { id: true, iamUserId: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + paymentIntent: true, + seats: { include: { seat: true } }, + }, + }), + this.prisma.booking.count({ where: bookingPkgWhere }), + ]); + + const iamUserIds = regPkgItems.map((b: any) => b.passenger?.iamUserId).filter(Boolean) as string[]; + const iamRows = iamUserIds.length > 0 + ? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>( + `SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`, + [iamUserIds], + ) + : []; + const iamMap = new Map(iamRows.map(r => [r.id, r])); + + const mappedRegPkg = regPkgItems.map((booking: any) => { + const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined; + const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory })); + const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values()); + return { + id: booking.id, bookingRef: booking.bookingRef, status: booking.status, + totalMinor: booking.totalMinor, currency: 'ETB', + displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, + contactEmail: booking.contactEmail, contactPhone: booking.contactPhone, + bookingType: booking.bookingType, packageId: booking.packageId, isPackageBooking: true, + returnLegStatus: (booking as any).returnLegStatus ?? null, + adultCount: booking.adultCount, childCount: booking.childCount, + createdAt: booking.createdAt, + passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null, + passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))], + passengers: uniquePassengers, + schedule: booking.schedule ? { + train: booking.schedule.train, + originStation: booking.schedule.originStation, + destinationStation: booking.schedule.destinationStation, + departureAt: booking.schedule.departureAt, + } : null, + paymentIntent: booking.paymentIntent, + seatCount: booking.seats.length, + }; + }); + + const mappedPkg = pkgItems.map((b: any) => ({ + id: b.id, bookingRef: b.bookingRef, status: b.status, + totalMinor: b.totalMinor, currency: b.currency || 'ETB', + displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor, + contactEmail: b.contactEmail, contactPhone: b.contactPhone, + bookingType: 'PACKAGE', packageId: b.packageId, isPackageBooking: true, + packageName: b.package?.name, packageCode: b.package?.code, + returnLegStatus: null, adultCount: b.passengerCount, childCount: 0, + createdAt: b.createdAt, passenger: null, + passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [], + passengers: b.passengers?.map((p: any) => ({ name: p.passengerName, category: 'ADULT' })) ?? [], + schedule: null, paymentIntent: b.paymentIntent, seatCount: b.passengerCount, + })); + + const total = pkgTotal + regPkgTotal; + const allItems = [...mappedPkg, ...mappedRegPkg] + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, pageSize); + + return { + items: allItems, + meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) }, + }; + } + + const [regularItems, regularTotal, pkgItems, pkgTotal] = await Promise.all([ this.prisma.booking.findMany({ where, skip, @@ -264,9 +375,22 @@ export class BookingsService { }, }), this.prisma.booking.count({ where }), + includePackageBookings + ? this.prisma.packageBooking.findMany({ + where: pkgWhere, + orderBy: { createdAt: 'desc' }, + include: { + package: { select: { id: true, name: true, code: true } }, + priceTier: { select: { id: true, label: true, seatType: true } }, + passengers: true, + paymentIntent: true, + }, + }) + : Promise.resolve([] as any[]), + includePackageBookings ? this.prisma.packageBooking.count({ where: pkgWhere }) : Promise.resolve(0), ]); - const iamUserIds = items.map(b => b.passenger?.iamUserId).filter(Boolean) as string[]; + const iamUserIds = regularItems.map((b: any) => b.passenger?.iamUserId).filter(Boolean) as string[]; const iamRows = iamUserIds.length > 0 ? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>( `SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`, @@ -275,59 +399,80 @@ export class BookingsService { : []; const iamMap = new Map(iamRows.map(r => [r.id, r])); + const mappedRegular = regularItems.map((booking: any) => { + const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined; + const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory })); + const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values()); + return { + id: booking.id, + bookingRef: booking.bookingRef, + status: booking.status, + totalMinor: booking.totalMinor, + currency: 'ETB', + displayCurrency: booking.displayCurrency, + displayTotalMinor: booking.displayTotalMinor, + contactEmail: booking.contactEmail, + contactPhone: booking.contactPhone, + bookingType: booking.bookingType, + packageId: booking.packageId ?? null, + isPackageBooking: !!booking.packageId, + returnLegStatus: (booking as any).returnLegStatus ?? null, + adultCount: booking.adultCount, + childCount: booking.childCount, + createdAt: booking.createdAt, + passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null, + passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))], + passengers: uniquePassengers, + schedule: { + train: booking.schedule.train, + originStation: booking.schedule.originStation, + destinationStation: booking.schedule.destinationStation, + departureAt: booking.schedule.departureAt, + }, + paymentIntent: booking.paymentIntent, + seatCount: booking.seats.length, + }; + }); + + const mappedPkg = pkgItems.map((b: any) => ({ + id: b.id, + bookingRef: b.bookingRef, + status: b.status, + totalMinor: b.totalMinor, + currency: b.currency || 'ETB', + displayCurrency: b.displayCurrency, + displayTotalMinor: b.displayTotalMinor, + contactEmail: b.contactEmail, + contactPhone: b.contactPhone, + bookingType: 'PACKAGE', + packageId: b.packageId, + isPackageBooking: true, + packageName: b.package?.name, + packageCode: b.package?.code, + returnLegStatus: null, + adultCount: b.passengerCount, + childCount: 0, + createdAt: b.createdAt, + passenger: null, + passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [], + passengers: b.passengers?.map((p: any) => ({ name: p.passengerName, category: 'ADULT' })) ?? [], + schedule: null, + paymentIntent: b.paymentIntent, + seatCount: b.passengerCount, + })); + + const total = regularTotal + pkgTotal; + const allItems = [...mappedRegular, ...mappedPkg] + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, pageSize); + return { - items: items.map(booking => { - const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined; - // Build passenger list with categories - const passengerDetails = booking.seats.map((s: any) => ({ - name: s.passengerName, - category: s.passengerCategory // 'ADULT' or 'CHILD' - })); - // Get unique names with their categories - const uniquePassengers = Array.from( - new Map(passengerDetails.map(p => [p.name, p])).values() - ); - - return { - id: booking.id, - bookingRef: booking.bookingRef, - status: booking.status, - totalMinor: booking.totalMinor, - currency: 'ETB', - displayCurrency: booking.displayCurrency, - displayTotalMinor: booking.displayTotalMinor, - contactEmail: booking.contactEmail, - contactPhone: booking.contactPhone, - bookingType: booking.bookingType, - returnLegStatus: (booking as any).returnLegStatus ?? null, - adultCount: booking.adultCount, - childCount: booking.childCount, - createdAt: booking.createdAt, - passenger: iam - ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } - : null, - passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))], - passengers: uniquePassengers, // Include category info - schedule: { - train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - }, - paymentIntent: booking.paymentIntent, - seatCount: booking.seats.length, - }; - }), - meta: { - page, - pageSize, - total, - totalPages: Math.ceil(total / pageSize), - }, + items: allItems, + meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) }, }; } - async create(dto: CreateBookingDto) { + async create(dto: CreateBookingDto) { if (dto.bookingType === 'ROUND_TRIP') return this.createRoundTripBooking(dto); if (dto.bookingType === 'TRANSIT') return this.createTransitBooking(dto); if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createRoundTripTransitBooking(dto); @@ -363,7 +508,9 @@ export class BookingsService { const passengersData = await this.processPassengers(dto.passengers as any[]); const { adultCount, childCount } = this.countPassengers(passengersData); - const fareCalculation = await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints); + const fareCalculation = dto.packageId && dto.priceTierId + ? await this.calculatePackageFare(dto.priceTierId, adultCount, childCount) + : await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints); const displayCurrency = dto.displayCurrency || Currency.ETB; let displayTotalMinor = fareCalculation.totalMinor; @@ -401,6 +548,7 @@ export class BookingsService { childCount, displayCurrency, displayTotalMinor, + ...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}), seats: { create: passengersWithFares.map(p => ({ seat: { connect: { id: p.seatId } }, @@ -421,6 +569,12 @@ export class BookingsService { }); await this.seatsService.confirmSeats(passengersData.map(p => p.seatId)); + if (dto.packageId && dto.priceTierId) { + await this.prisma.packagePriceTier.update({ + where: { id: dto.priceTierId }, + data: { bookedSeats: { increment: passengersData.length } }, + }); + } this.eventEmitter.emit('booking.created', { booking }); return { ...booking, fareBreakdown: fareCalculation }; } @@ -468,23 +622,38 @@ export class BookingsService { const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]); const { adultCount, childCount } = this.countPassengers(passengersData); - const [outboundFare, returnFare] = await Promise.all([ - this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount), - this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount) - ]); - - const combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor; + // Package bookings use fixed tier price split equally across both legs + let outboundFare: Awaited>; + let returnFare: Awaited>; + let combinedBaseFareMinor: number; let discountMinor = 0; - if (dto.promoCode) { - const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); - if (promo?.active && promo.validUntil > new Date()) { - discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); - } - } + let loyaltyMinor = 0; + let totalMinor: number; - const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; + if (dto.packageId && dto.priceTierId) { + const pkgFare = await this.calculatePackageFare(dto.priceTierId, adultCount, childCount); + // Split evenly across both legs for per-seat fare recording + const halfMinor = Math.round(pkgFare.baseFareMinor / 2); + outboundFare = { ...pkgFare, baseFareMinor: halfMinor, totalBaseFareMinor: Math.round(pkgFare.totalBaseFareMinor / 2) }; + returnFare = { ...pkgFare, baseFareMinor: pkgFare.baseFareMinor - halfMinor, totalBaseFareMinor: pkgFare.totalBaseFareMinor - Math.round(pkgFare.totalBaseFareMinor / 2) }; + combinedBaseFareMinor = pkgFare.totalBaseFareMinor; + totalMinor = pkgFare.totalMinor; + } else { + [outboundFare, returnFare] = await Promise.all([ + this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount), + this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount) + ]); + combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor; + if (dto.promoCode) { + const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); + if (promo?.active && promo.validUntil > new Date()) { + discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); + } + } + loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; + totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor); + } const taxesMinor = 0; - const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; let displayTotalMinor = totalMinor; @@ -541,6 +710,7 @@ export class BookingsService { returnHoldId: dto.returnHoldId, returnSeatClassId: dto.returnSeatClassId, returnLegStatus: 'NEITHER_USED', + ...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}), seats: { create: [ ...passengersWithFares.map(p => ({ @@ -586,6 +756,13 @@ export class BookingsService { this.seatsService.confirmSeats(returnSeatIds) ]); + if (dto.packageId && dto.priceTierId) { + await this.prisma.packagePriceTier.update({ + where: { id: dto.priceTierId }, + data: { bookedSeats: { increment: passengersData.length } }, + }); + } + this.eventEmitter.emit('booking.created', { booking }); return { @@ -1048,6 +1225,30 @@ export class BookingsService { return { adultCount, childCount }; } + private async calculatePackageFare( + priceTierId: string, + adultCount: number, + childCount: number, + ) { + const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: priceTierId } }); + const passengerCount = adultCount + childCount; + const totalBaseFareMinor = tier.priceMinor * passengerCount; + return { + baseFareMinor: tier.priceMinor, + adultCount, + adultFareMinor: tier.priceMinor * adultCount, + childCount, + freeChildrenCount: 0, + paidChildrenCount: childCount, + childFareMinor: tier.priceMinor * childCount, + totalBaseFareMinor, + discountMinor: 0, + loyaltyRedemptionMinor: 0, + taxesFeesMinor: 0, + totalMinor: totalBaseFareMinor, + }; + } + private async calculateFare( scheduleId: string, seatClassId: string, @@ -1182,7 +1383,64 @@ export class BookingsService { paymentIntent: true, tickets: { take: 1 }, }, }); - if (!booking) throw new NotFoundException('Booking not found'); + + if (!booking) { + // Fall back to PackageBooking + const pkgBooking = await this.prisma.packageBooking.findUnique({ + where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId }, + include: { + package: { include: { outboundSchedule: { include: { originStation: true, destinationStation: true, train: true } }, returnSchedule: { include: { originStation: true, destinationStation: true } } } }, + priceTier: true, + passengers: true, + paymentIntent: true, + }, + }); + if (!pkgBooking) throw new NotFoundException('Booking not found'); + return { + id: pkgBooking.id, + bookingRef: pkgBooking.bookingRef, + status: pkgBooking.status, + totalMinor: pkgBooking.totalMinor, + currency: pkgBooking.currency || 'ETB', + adultCount: pkgBooking.passengerCount, + childCount: 0, + displayCurrency: pkgBooking.displayCurrency, + displayTotalMinor: pkgBooking.displayTotalMinor ?? undefined, + bookingType: 'PACKAGE', + packageId: pkgBooking.packageId, + priceTierId: pkgBooking.priceTierId, + packageName: (pkgBooking as any).package?.name, + packageCode: (pkgBooking as any).package?.code, + tierLabel: (pkgBooking as any).priceTier?.label, + isPackageBooking: true, + returnLegStatus: null, + contactEmail: pkgBooking.contactEmail, + contactPhone: pkgBooking.contactPhone, + createdAt: pkgBooking.createdAt, + schedule: (pkgBooking as any).package?.outboundSchedule ? { + id: (pkgBooking as any).package.outboundSchedule.id, + trainNumber: (pkgBooking as any).package.outboundSchedule.train?.number, + trainName: (pkgBooking as any).package.outboundSchedule.train?.name, + origin: (pkgBooking as any).package.outboundSchedule.originStation, + destination: (pkgBooking as any).package.outboundSchedule.destinationStation, + departureAt: (pkgBooking as any).package.outboundSchedule.departureAt, + arrivalAt: (pkgBooking as any).package.outboundSchedule.arrivalAt, + } : null, + passengers: (pkgBooking as any).passengers?.map((p: any) => ({ + fullName: p.passengerName, + category: 'ADULT', + leg: 1, + fareMinor: Math.round(pkgBooking.totalMinor / pkgBooking.passengerCount), + verifaydaVerified: false, + seat: null, + })), + payment: (pkgBooking as any).paymentIntent + ? { method: (pkgBooking as any).paymentIntent.method, status: (pkgBooking as any).paymentIntent.status } + : undefined, + ticket: undefined, + }; + } + return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, totalMinor: booking.totalMinor, currency: 'ETB', diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 6907d14a3..5b6b876d6 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -81,8 +81,10 @@ export class GuestBookingService { throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); } - const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId); - const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId); + const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId) + ?? (schedule.stopTimes.length === 0 ? { stationId: schedule.originStationId, sequence: 0, station: schedule.originStation } : undefined); + const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId) + ?? (schedule.stopTimes.length === 0 ? { stationId: schedule.destinationStationId, sequence: 1, station: schedule.destinationStation } : undefined); if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found'); const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; @@ -306,10 +308,16 @@ export class GuestBookingService { throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); } - const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId); - const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId); - const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId); - const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId); + const synth = (sched: any, stationId: string, seq: number) => { + const station = sched.originStationId === stationId ? sched.originStation : sched.destinationStation; + return { stationId, sequence: seq, station }; + }; + const obStops = outboundSchedule.stopTimes.length > 0 ? outboundSchedule.stopTimes : [synth(outboundSchedule, outboundSchedule.originStationId, 0), synth(outboundSchedule, outboundSchedule.destinationStationId, 1)]; + const retStops = returnSchedule.stopTimes.length > 0 ? returnSchedule.stopTimes : [synth(returnSchedule, returnSchedule.originStationId, 0), synth(returnSchedule, returnSchedule.destinationStationId, 1)]; + const outboundOriginStop = obStops.find((s: any) => s.stationId === dto.originStationId) ?? obStops[0]; + const outboundDestStop = obStops.find((s: any) => s.stationId === dto.destinationStationId) ?? obStops[obStops.length - 1]; + const returnOriginStop = retStops.find((s: any) => s.stationId === dto.returnOriginStationId) ?? retStops[0]; + const returnDestStop = retStops.find((s: any) => s.stationId === dto.returnDestinationStationId) ?? retStops[retStops.length - 1]; if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule'); if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule'); diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index 592aadacd..f7f2c4558 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -130,7 +130,7 @@ export class FareEngineService { const adultCount = dto.adultCount ?? 1; const childCount = dto.childCount ?? 0; - const freeChildrenCount = Math.min(childCount, 1); + const freeChildrenCount = Math.min(childCount, adultCount); const paidChildrenCount = Math.max(0, childCount - 1); // Subtotal includes: (distance-based fare + premium + insurance) × passengers @@ -169,7 +169,7 @@ export class FareEngineService { `Total fare/pax: ${farePerPassengerMinor} ETB minor`, ``, `Adults: ${adultCount} × ${farePerPassengerMinor} = ${adultSubtotal} ETB minor`, - `Children: ${childCount} (${freeChildrenCount} free + ${paidChildrenCount} paid)`, + `Children: ${childCount} (${freeChildrenCount} free [1 per adult] + ${paidChildrenCount} paid)`, ` Free child: ${freeChildrenCount} × ${premiumPerPassenger + insurancePerPassenger} = ${freeChildSubtotal} ETB minor`, ` Paid child: ${paidChildrenCount} × ${farePerPassengerMinor} = ${paidChildSubtotal} ETB minor`, ``, diff --git a/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts b/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts index c53d3a5fb..87007f16a 100644 --- a/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts +++ b/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Body, Query, Logger } from '@nestjs/common'; +import { Controller, Get, Post, Patch, Param, Body, Query, Logger } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { FraudService, FraudRuleConfig } from './fraud.service'; import { PassengerStaff } from '../../common/passenger-guards'; @@ -48,6 +48,31 @@ export class FraudController { return { data: rule, message: 'Rule updated successfully' }; } + /** + * Acknowledge a fraud alert + */ + @Patch('alerts/:id/acknowledge') + @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) + @ApiOperation({ summary: 'Acknowledge a fraud alert' }) + async acknowledgeAlert(@Param('id') id: string) { + const alert = await this.fraudService.acknowledgeAlert(id); + return { data: alert, message: 'Alert acknowledged' }; + } + + /** + * Block user via userId + */ + @Post('users/:userId/block') + @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) + @ApiOperation({ summary: 'Block user by userId' }) + async blockUserById( + @Param('userId') userId: string, + @Body() body: { reason?: string; durationMinutes?: number }, + ) { + await this.fraudService.blockUserTemporarily(userId, body.durationMinutes ?? 60); + return { message: `User blocked for ${body.durationMinutes ?? 60} minutes` }; + } + /** * Block user temporarily */ diff --git a/apps/edr-passenger-api/src/modules/fraud/fraud.module.ts b/apps/edr-passenger-api/src/modules/fraud/fraud.module.ts index a95078578..17b86705f 100644 --- a/apps/edr-passenger-api/src/modules/fraud/fraud.module.ts +++ b/apps/edr-passenger-api/src/modules/fraud/fraud.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { FraudService } from './fraud.service'; import { FraudController } from './fraud.controller'; @Module({ - imports: [HttpModule], + imports: [HttpModule, TypeOrmModule], providers: [FraudService], controllers: [FraudController], exports: [FraudService], diff --git a/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts b/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts index a75db4449..2f988fd31 100644 --- a/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts +++ b/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts @@ -164,6 +164,16 @@ export class FraudService { this.logger.log(`Passenger (iamUserId=${iamUserId}) unblocked`); } + /** + * Acknowledge a fraud alert + */ + async acknowledgeAlert(id: string) { + return this.prisma.fraudAlert.update({ + where: { id }, + data: { acknowledged: true, acknowledgedAt: new Date() }, + }); + } + /** * Get all fraud alerts */ diff --git a/apps/edr-passenger-api/src/modules/loyalty/loyalty.controller.ts b/apps/edr-passenger-api/src/modules/loyalty/loyalty.controller.ts index 7095110e4..b4b1a63c6 100644 --- a/apps/edr-passenger-api/src/modules/loyalty/loyalty.controller.ts +++ b/apps/edr-passenger-api/src/modules/loyalty/loyalty.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { Controller, Get, Param, Post, Delete, UseGuards, SetMetadata, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { LoyaltyService } from './loyalty.service'; import { JwtGuard } from '../../common/jwt.guard'; @@ -9,7 +9,9 @@ import { JwtGuard } from '../../common/jwt.guard'; @ApiBearerAuth('JWT-auth') export class LoyaltyController { constructor(private service: LoyaltyService) {} + @Get('accounts') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'List all loyalty accounts' }) getAccounts(@Query() q: any) { return this.service.getAccounts(q); } @Get(':passengerId') @ApiOperation({ summary: 'Get loyalty account with tier progress' }) getAccount(@Param('passengerId') id: string) { return this.service.getAccount(id); } @Get(':passengerId/rewards') @ApiOperation({ summary: 'Get available rewards' }) getRewards(@Param('passengerId') id: string) { return this.service.getRewards(id); } @Post(':passengerId/rewards/:rewardId/redeem') @ApiOperation({ summary: 'Redeem a loyalty reward' }) redeemReward(@Param('passengerId') pid: string, @Param('rewardId') rid: string) { return this.service.redeemReward(pid, rid); } + @Delete('accounts/:id') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Delete loyalty account' }) deleteAccount(@Param('id') id: string) { return this.service.deleteAccount(id); } } diff --git a/apps/edr-passenger-api/src/modules/loyalty/loyalty.service.ts b/apps/edr-passenger-api/src/modules/loyalty/loyalty.service.ts index 4cc69b214..22b919f6f 100644 --- a/apps/edr-passenger-api/src/modules/loyalty/loyalty.service.ts +++ b/apps/edr-passenger-api/src/modules/loyalty/loyalty.service.ts @@ -5,6 +5,42 @@ import { PrismaService } from '../../common/prisma.service'; export class LoyaltyService { constructor(private prisma: PrismaService) {} + async getAccounts(params: { search?: string; tier?: string; page?: string; pageSize?: string } = {}) { + const { search, tier, page = '1', pageSize = '20' } = params; + const skip = (parseInt(page) - 1) * parseInt(pageSize); + const where: any = {}; + if (tier) where.tier = tier; + if (search) { + where.passenger = { + OR: [ + { user: { fullName: { contains: search, mode: 'insensitive' } } }, + { user: { email: { contains: search, mode: 'insensitive' } } }, + ], + }; + } + const [items, total] = await Promise.all([ + this.prisma.loyaltyAccount.findMany({ + where, + skip, + take: parseInt(pageSize), + orderBy: { pointsBalance: 'desc' }, + include: { passenger: { include: { user: true } } }, + }), + this.prisma.loyaltyAccount.count({ where }), + ]); + return { + items: items.map(a => ({ + ...a, + passenger: a.passenger ? { + id: a.passenger.id, + fullName: (a.passenger as any).user?.fullName ?? null, + email: (a.passenger as any).user?.email ?? null, + phone: (a.passenger as any).user?.phone ?? null, + } : null, + })), + meta: { page: parseInt(page), pageSize: parseInt(pageSize), total, totalPages: Math.ceil(total / parseInt(pageSize)) }, + }; + } async getAccount(passengerId: string) { const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId }, include: { ledger: { orderBy: { createdAt: 'desc' }, take: 20 } } }); if (!account) throw new NotFoundException('Loyalty account not found'); @@ -40,4 +76,15 @@ export class LoyaltyService { await this.prisma.loyaltyReward.update({ where: { id: rewardId }, data: { available: false } }); return { redeemed: true, pointsUsed: reward.costPoints, balanceAfter: newBalance }; } + + async deleteAccount(id: string) { + const account = await this.prisma.loyaltyAccount.findUnique({ where: { id } }); + if (!account) throw new NotFoundException('Loyalty account not found'); + await this.prisma.$transaction([ + this.prisma.loyaltyLedgerEntry.deleteMany({ where: { accountId: id } }), + this.prisma.loyaltyReward.deleteMany({ where: { accountId: id } }), + this.prisma.loyaltyAccount.delete({ where: { id } }), + ]); + return { deleted: true, accountId: id }; + } } diff --git a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts index c88bdc15d..f814eb3e7 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts @@ -1,8 +1,8 @@ import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Request, Query } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { PackagesService } from './packages.service'; -import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto } from './packages.dto'; +import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto, PackageBookingContextDto } from './packages.dto'; import { IamGuard } from '../../common/iam-adapter'; import { JwtGuard } from '../../common/jwt.guard'; import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; @@ -63,6 +63,19 @@ export class PackagesController { return this.service.listAll(page ? +page : 1, pageSize ? +pageSize : 20); } + @Get('bookings') + @UseGuards(IamGuard) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'List all package bookings (backoffice)' }) + listBookings( + @Query('packageId') packageId?: string, + @Query('status') status?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.service.listBookings({ packageId, status, page: page ? +page : 1, pageSize: pageSize ? +pageSize : 20 }); + } + @Get('my-bookings') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @@ -78,6 +91,21 @@ export class PackagesController { return this.service.getBookingByRef(ref); } + @Get(':id/booking-context') + @IsPublic() + @ApiOperation({ summary: 'Get booking context for self-service package booking' }) + @ApiQuery({ name: 'tierId', required: true }) + @ApiQuery({ name: 'adultCount', required: true }) + @ApiQuery({ name: 'childCount', required: false }) + getBookingContext( + @Param('id') id: string, + @Query('tierId') tierId: string, + @Query('adultCount') adultCount: string, + @Query('childCount') childCount?: string, + ) { + return this.service.getBookingContext(id, tierId, parseInt(adultCount), childCount ? parseInt(childCount) : 0); + } + @Get(':id') @IsPublic() @ApiOperation({ summary: 'Get package details' }) diff --git a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts index b4dac126d..074d7454d 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsInt, IsBoolean, IsArray, IsDateString, Min, ValidateNested, IsUUID } from 'class-validator'; +import { IsString, IsOptional, IsInt, IsBoolean, IsArray, IsDateString, Min, ValidateNested, IsUUID, IsPositive } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; @@ -93,6 +93,12 @@ export class BookPackagePassengerDto { @ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string; } +export class PackageBookingContextDto { + @ApiProperty() @IsUUID() tierId: string; + @ApiProperty({ example: 1 }) @IsInt() @IsPositive() adultCount: number; + @ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() @Min(0) childCount?: number; +} + export class BookPackageDto { @ApiProperty() @IsUUID() packageId: string; @ApiProperty() @IsUUID() priceTierId: string; diff --git a/apps/edr-passenger-api/src/modules/packages/packages.module.ts b/apps/edr-passenger-api/src/modules/packages/packages.module.ts index f84a23781..32aec44fc 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.module.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.module.ts @@ -3,9 +3,10 @@ import { PrismaModule } from '../../common/prisma.module'; import { PackagesController } from './packages.controller'; import { PackagesService } from './packages.service'; import { CurrencyModule } from '../currency/currency.module'; +import { BookingsModule } from '../bookings/bookings.module'; @Module({ - imports: [PrismaModule, CurrencyModule], + imports: [PrismaModule, CurrencyModule, BookingsModule], controllers: [PackagesController], providers: [PackagesService], exports: [PackagesService], diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index f6c25611c..4a9f9382e 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -3,6 +3,8 @@ import { PrismaService } from '../../common/prisma.service'; import { CurrencyService } from '../currency/currency.service'; import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto, CreateInquiryDto } from './packages.dto'; import { Currency } from '@prisma/client'; +import { BookingsService } from '../bookings/bookings.service'; +import { GuestBookingService } from '../bookings/guest-booking.service'; function generateRef(): string { return 'PKG-' + Array.from({ length: 6 }, () => @@ -15,8 +17,94 @@ export class PackagesService { constructor( private readonly prisma: PrismaService, private readonly currencyService: CurrencyService, + private readonly bookingsService: BookingsService, + private readonly guestBookingService: GuestBookingService, ) {} + async getBookingContext(packageId: string, tierId: string, adultCount: number, childCount = 0) { + const pkg = await this.prisma.travelPackage.findUnique({ + where: { id: packageId }, + include: { + priceTiers: true, + outboundSchedule: { + include: { + originStation: true, + destinationStation: true, + coachAssignments: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } }, + }, + }, + returnSchedule: { include: { originStation: true, destinationStation: true } }, + }, + }); + if (!pkg || pkg.status !== 'ACTIVE') throw new NotFoundException('Package not available'); + + const tier = pkg.priceTiers.find(t => t.id === tierId); + if (!tier) throw new NotFoundException('Price tier not found'); + + const passengerCount = adultCount + childCount; + if (passengerCount < 1) throw new BadRequestException('At least one passenger required'); + + const remaining = tier.availableSeats - tier.bookedSeats; + if (passengerCount > remaining) + throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`); + + const totalMinor = tier.priceMinor * passengerCount; + + // Resolve the seatClassId and coachTypeId that matches this tier's seatType from the outbound schedule coaches + let seatClassId: string | null = null; + let coachTypeId: string | null = null; + for (const a of pkg.outboundSchedule.coachAssignments) { + const sc = a.coach.coachType?.seatClasses?.find( + (s: any) => s.name.toLowerCase().includes(tier.seatType.toLowerCase()) || + tier.seatType.toLowerCase().includes(s.name.toLowerCase()), + ); + if (sc) { seatClassId = sc.id; coachTypeId = a.coach.coachTypeId ?? a.coach.coachType?.id ?? null; break; } + } + // Fallback: use the first coach assignment's coachTypeId if no match found + if (!coachTypeId && pkg.outboundSchedule.coachAssignments.length > 0) { + const first = pkg.outboundSchedule.coachAssignments[0]; + coachTypeId = first.coach.coachTypeId ?? first.coach.coachType?.id ?? null; + } + + return { + packageId: pkg.id, + packageName: pkg.name, + priceTierId: tier.id, + tierLabel: tier.label, + seatType: tier.seatType, + seatClassId, + coachTypeId, + adultCount, + childCount, + passengerCount, + pricePerPassengerMinor: tier.priceMinor, + totalMinor, + currency: tier.currency, + remainingSeats: remaining, + outboundSchedule: { + scheduleId: pkg.outboundScheduleId, + originStationId: pkg.originStationId, + destinationStationId: pkg.destinationStationId, + departureAt: pkg.outboundSchedule.departureAt, + arrivalAt: pkg.outboundSchedule.arrivalAt, + originStation: pkg.outboundSchedule.originStation, + destinationStation: pkg.outboundSchedule.destinationStation, + }, + returnSchedule: pkg.returnSchedule ? { + scheduleId: pkg.returnScheduleId, + originStationId: pkg.destinationStationId, + destinationStationId: pkg.originStationId, + departureAt: pkg.returnSchedule.departureAt, + arrivalAt: pkg.returnSchedule.arrivalAt, + originStation: pkg.returnSchedule.destinationStation, + destinationStation: pkg.returnSchedule.originStation, + } : null, + includedServices: pkg.includedServices, + busTransferIncluded: pkg.busTransferIncluded, + busTransferRoute: pkg.busTransferRoute, + }; + } + async createInquiry(dto: CreateInquiryDto) { return this.prisma.packageInquiry.create({ data: { @@ -74,7 +162,7 @@ export class PackagesService { returnSchedule: { include: { originStation: true, destinationStation: true } }, }, orderBy: { validFrom: 'asc' }, - }); + }).then(pkgs => pkgs.map(p => ({ ...p, journeyType: p.returnScheduleId ? 'ROUND_TRIP' : 'ONE_WAY' }))); } async getById(id: string) { @@ -87,7 +175,7 @@ export class PackagesService { }, }); if (!pkg) throw new NotFoundException('Package not found'); - return pkg; + return { ...pkg, journeyType: pkg.returnScheduleId ? 'ROUND_TRIP' : 'ONE_WAY' }; } create(dto: CreatePackageDto) { @@ -296,6 +384,29 @@ export class PackagesService { return booking; } + async listBookings({ packageId, status, page = 1, pageSize = 20 }: { packageId?: string; status?: string; page?: number; pageSize?: number }) { + const where: any = {}; + if (packageId) where.packageId = packageId; + if (status) where.status = status; + const skip = (page - 1) * pageSize; + const [items, total] = await Promise.all([ + this.prisma.packageBooking.findMany({ + where, + include: { + package: { select: { id: true, name: true, code: true } }, + priceTier: { select: { id: true, label: true, seatType: true } }, + passengers: true, + paymentIntent: true, + }, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + }), + this.prisma.packageBooking.count({ where }), + ]); + return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }; + } + async listAll(page = 1, pageSize = 20) { const skip = (page - 1) * pageSize; const [items, total] = await Promise.all([ diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index ea952d98c..d41fcbfed 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -433,39 +433,49 @@ export class PassengersService { } async deletePassenger(id: string) { - const passenger = await this.prisma.passenger.findUnique({ + // id may be a TravelerProfile.id (from the list endpoint) or a Passenger.id + let passenger = await this.prisma.passenger.findUnique({ where: { id }, - include: { - user: true - } + include: { user: true }, }); - if (!passenger) throw new NotFoundException('Passenger not found'); + + if (!passenger) { + const profile = await this.prisma.travelerProfile.findUnique({ where: { id } }); + if (!profile?.passengerId) throw new NotFoundException('Passenger not found'); + passenger = await this.prisma.passenger.findUnique({ + where: { id: profile.passengerId }, + include: { user: true }, + }); + if (!passenger) throw new NotFoundException('Passenger not found'); + } + + const passengerId = passenger.id; // Check usage before allowing deletion - const usage = await this.checkPassengerUsage(id); + const usage = await this.checkPassengerUsage(passengerId); if (usage.isInUse && usage.constraints) { - const passengerName = (passenger as any).user?.fullName || `Passenger ${id.slice(-8)}`; + const passengerName = (passenger as any).user?.fullName || `Passenger ${passengerId.slice(-8)}`; throw new DeleteOperationException('Passenger', passengerName, usage.constraints); } await this.prisma.$transaction([ - this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId: id } } }), - this.prisma.loyaltyAccount.deleteMany({ where: { passengerId: id } }), - this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId: id } } }), - this.prisma.walletAccount.deleteMany({ where: { passengerId: id } }), - this.prisma.notification.deleteMany({ where: { passengerId: id } }), - this.prisma.travelerProfile.deleteMany({ where: { passengerId: id } }), - this.prisma.savedRoute.deleteMany({ where: { passengerId: id } }), - this.prisma.packageBooking.deleteMany({ where: { passengerId: id } }), - this.prisma.ticket.deleteMany({ where: { booking: { passengerId: id } } }), - this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId: id } } }), - this.prisma.booking.deleteMany({ where: { passengerId: id } }), - this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId: id } } }), - this.prisma.journey.deleteMany({ where: { passengerId: id } }), - this.prisma.passenger.delete({ where: { id } }), + this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId } } }), + this.prisma.loyaltyAccount.deleteMany({ where: { passengerId } }), + this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId } } }), + this.prisma.walletAccount.deleteMany({ where: { passengerId } }), + this.prisma.notification.deleteMany({ where: { passengerId } }), + this.prisma.travelerProfile.deleteMany({ where: { passengerId } }), + this.prisma.savedRoute.deleteMany({ where: { passengerId } }), + this.prisma.packageBooking.deleteMany({ where: { passengerId } }), + this.prisma.ticket.deleteMany({ where: { booking: { passengerId } } }), + this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId } } }), + this.prisma.booking.deleteMany({ where: { passengerId } }), + this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId } } }), + this.prisma.journey.deleteMany({ where: { passengerId } }), + this.prisma.passenger.delete({ where: { id: passengerId } }), ]); - return { deleted: true, passengerId: id }; + return { deleted: true, passengerId }; } async checkPassengerUsage(id: string) { diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 280ef2752..5be11434a 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, HttpStatus, Param, @@ -42,6 +43,14 @@ import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; export class PaymentsController { constructor(private service: PaymentsService) {} + @Delete(":id") + @PassengerStaff([PASSENGER_PERMS.admin]) + @ApiBearerAuth("IAM-auth") + @ApiOperation({ summary: "Delete a payment intent record (admin only)" }) + deletePayment(@Param("id") id: string) { + return this.service.deletePayment(id); + } + @Get("all") @PassengerStaff([PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin]) @ApiBearerAuth("IAM-auth") diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index af496a5a2..b79079c39 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -55,6 +55,13 @@ export class PaymentsService { private currencyService: CurrencyService, ) {} + async deletePayment(id: string) { + const intent = await this.prisma.paymentIntent.findUnique({ where: { id } }); + if (!intent) throw new NotFoundException('Payment intent not found'); + await this.prisma.paymentIntent.delete({ where: { id } }); + return { deleted: true, id }; + } + async getAll(filters: { search?: string; status?: string; diff --git a/apps/edr-passenger-api/src/modules/search/search.controller.ts b/apps/edr-passenger-api/src/modules/search/search.controller.ts index 6bb9d1960..384592dc9 100644 --- a/apps/edr-passenger-api/src/modules/search/search.controller.ts +++ b/apps/edr-passenger-api/src/modules/search/search.controller.ts @@ -1,8 +1,8 @@ -import { Body, Controller, Post } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { Body, Controller, Post, Get, Query } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SearchService } from './search.service'; -import { SearchTripsDto, FareQuoteDto } from './search.dto'; +import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto } from './search.dto'; @ApiTags('Search') @Controller('search') @@ -66,4 +66,29 @@ Nationality-Based: getFareQuote(@Body() dto: FareQuoteDto) { return this.service.getFareQuote(dto); } + + @Get('fare-breakdown') + @ApiOperation({ + summary: 'Per-passenger fare breakdown for booking review page', + description: `Calculates a line-item fare for each individual passenger based on their date of birth, nationality, and chosen seat class. + +- Age is derived from dateOfBirth at request time (ADULT ≥5 yrs, CHILD <5 yrs) +- First CHILD in the list travels free (pays only premium + insurance fees) +- Each passenger can have a different seat class and nationality +- Returns per-passenger lines plus subtotal, discount, and grand total + +**passengers** must be a URL-encoded JSON array, e.g.: +\`[{"passengerName":"Abebe","dateOfBirth":"1985-03-15","seatClassId":"uuid","nationality":"Ethiopian"}]\``, + }) + @ApiQuery({ name: 'scheduleId', description: 'TrainSchedule UUID' }) + @ApiQuery({ name: 'originStationId', description: 'Origin station UUID' }) + @ApiQuery({ name: 'destinationStationId', description: 'Destination station UUID' }) + @ApiQuery({ name: 'passengers', description: 'URL-encoded JSON array of passengers: [{passengerName, dateOfBirth, seatClassId, nationality?}]' }) + @ApiQuery({ name: 'promoCode', required: false }) + @ApiQuery({ name: 'displayCurrency', required: false, enum: ['ETB', 'DJF', 'USD'] }) + @ApiResponse({ status: 200, description: 'Per-passenger fare lines with grand total' }) + @ApiResponse({ status: 404, description: 'Schedule not found' }) + getFareBreakdown(@Query() dto: FareBreakdownRequestDto) { + return this.service.getFareBreakdown(dto); + } } diff --git a/apps/edr-passenger-api/src/modules/search/search.dto.ts b/apps/edr-passenger-api/src/modules/search/search.dto.ts index 9eb035ef2..cfb1075ca 100644 --- a/apps/edr-passenger-api/src/modules/search/search.dto.ts +++ b/apps/edr-passenger-api/src/modules/search/search.dto.ts @@ -75,6 +75,43 @@ export class CoachTypeOptionClass { @ApiProperty({ example: 35000 }) baseFareMinor: number; } +export class FareBreakdownPassengerDto { + @ApiProperty({ example: 'Abebe Kebede', description: 'Passenger name (for display only)' }) + @IsString() passengerName: string; + + @ApiProperty({ example: '1985-03-15', description: 'Date of birth — determines ADULT (≥5 yrs) or CHILD (<5 yrs)' }) + @IsDateString() dateOfBirth: string; + + @ApiProperty({ example: 'seat-class-uuid', description: 'SeatClass UUID for this passenger' }) + @IsString() seatClassId: string; + + @ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality — affects billing currency and seat class variant' }) + @IsOptional() @IsString() nationality?: string; +} + +export class FareBreakdownRequestDto { + @ApiProperty({ example: 'schedule-uuid' }) + @IsString() scheduleId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Origin station UUID (must be a stop on the schedule)' }) + @IsString() originStationId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' }) + @IsString() destinationStationId: string; + + @ApiProperty({ + example: '[{"passengerName":"Abebe","dateOfBirth":"1985-03-15","seatClassId":"uuid","nationality":"Ethiopian"}]', + description: 'URL-encoded JSON array of passengers. Each entry: { passengerName, dateOfBirth (YYYY-MM-DD), seatClassId, nationality? }', + }) + @IsString() passengers: string; + + @ApiPropertyOptional({ example: 'WEEKEND15' }) + @IsOptional() @IsString() promoCode?: string; + + @ApiPropertyOptional({ example: 'USD', enum: Currency }) + @IsOptional() @IsEnum(Currency) displayCurrency?: Currency; +} + export class CoachTypeOption { @ApiProperty({ example: 'coach-type-uuid' }) coachTypeId: string; @ApiProperty({ example: 'Economy' }) coachTypeName: string; diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 6f3bc5a67..621e5487d 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; -import { SearchTripsDto, FareQuoteDto } from './search.dto'; +import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto, FareBreakdownPassengerDto } from './search.dto'; import { CurrencyService } from '../currency/currency.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; import { SegmentsService } from '../segments/segments.service'; @@ -477,6 +477,124 @@ export class SearchService { }; } + async getFareBreakdown(dto: FareBreakdownRequestDto) { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: dto.scheduleId }, + select: { routeId: true, originStationId: true, destinationStationId: true }, + }); + if (!schedule) throw new NotFoundException('Schedule not found'); + if (!schedule.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation'); + + const now = new Date(); + const displayCurrency = dto.displayCurrency ?? Currency.ETB; + + let parsedPassengers: FareBreakdownPassengerDto[]; + try { + parsedPassengers = JSON.parse(dto.passengers as unknown as string); + } catch { + throw new NotFoundException('passengers must be a valid JSON array'); + } + + // Categorise passengers by age + const categorised = parsedPassengers.map(p => { + const ageMs = now.getTime() - new Date(p.dateOfBirth).getTime(); + const ageYears = ageMs / (1000 * 60 * 60 * 24 * 365.25); + return { ...p, category: (ageYears >= 5 ? 'ADULT' : 'CHILD') as 'ADULT' | 'CHILD', ageYears }; + }); + + const adultCount = categorised.filter(p => p.category === 'ADULT').length; + const childCount = categorised.filter(p => p.category === 'CHILD').length; + + // Ask the fare engine for the authoritative free-child count using the full group + // Use the first passenger's seatClassId as a representative — freeChildrenCount + // depends only on adultCount/childCount, not on seat class. + const groupFare = await this.fareEngine.calculate({ + routeId: schedule.routeId!, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + seatClassId: categorised[0].seatClassId, + nationality: categorised[0].nationality, + scheduleId: dto.scheduleId, + adultCount, + childCount, + }); + const freeChildrenAllowed = groupFare.freeChildrenCount; + + // Calculate per-passenger fare rate (engine called with 1 adult, 0 children — pure rate lookup) + let freeChildrenUsed = 0; + const passengerLines = await Promise.all( + categorised.map(async (p) => { + const fare = await this.fareEngine.calculate({ + routeId: schedule.routeId!, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + seatClassId: p.seatClassId, + nationality: p.nationality, + scheduleId: dto.scheduleId, + adultCount: 1, + childCount: 0, + }); + + const isFree = p.category === 'CHILD' && freeChildrenUsed < freeChildrenAllowed; + if (isFree) freeChildrenUsed++; + + const fareMinor = isFree + ? fare.premiumPerPassenger + fare.insurancePerPassenger + : fare.farePerPassengerMinor; + const displayFareMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(fareMinor, Currency.ETB, displayCurrency) + : fareMinor; + + return { + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + category: p.category, + ageYears: Math.floor(p.ageYears), + seatClassId: fare.seatClassId, + seatClassName: fare.seatClassName, + nationality: p.nationality ?? null, + baseFareMinor: fare.baseFarePerPassengerMinor, + premiumMinor: fare.premiumPerPassenger, + insuranceFeeMinor: fare.insurancePerPassenger, + fareMinor, + isFree, + displayCurrency, + displayFareMinor, + }; + }), + ); + + let subtotalMinor = passengerLines.reduce((sum, l) => sum + l.fareMinor, 0); + + let discountMinor = 0; + if (dto.promoCode) { + const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); + if (promo?.active && promo.validUntil > now) { + discountMinor = promo.percentOff + ? Math.round(subtotalMinor * promo.percentOff / 100) + : (promo.amountOffMinor ?? 0); + } + } + + const totalMinor = subtotalMinor - discountMinor; + const displayTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) + : totalMinor; + + return { + scheduleId: dto.scheduleId, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + passengers: passengerLines, + subtotalMinor, + discountMinor, + totalMinor, + currency: 'ETB', + displayCurrency, + displayTotalMinor, + }; + } + private async calculateFaresForSegment( schedule: ScheduleWithIncludes, originStationId: string, diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts index 79151bdc9..63f5e1f29 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts @@ -1,5 +1,6 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; +import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; @Injectable() export class SeatClassesService { @@ -45,8 +46,24 @@ export class SeatClassesService { } async deleteSeatClass(id: string) { - const sc = await this.prisma.seatClass.findUnique({ where: { id } }); + const sc = await this.prisma.seatClass.findUnique({ + where: { id }, + include: { + _count: { select: { fareRules: true, routeFareRules: true, segmentFares: true } }, + }, + }); if (!sc) throw new NotFoundException('SeatClass not found'); + + const totalFareRules = + (sc as any)._count.fareRules + + (sc as any)._count.routeFareRules + + (sc as any)._count.segmentFares; + + if (totalFareRules > 0) + throw new DeleteOperationException('Seat Class', sc.name, [ + { entityName: 'fare rule', count: totalFareRules, action: 'delete' }, + ]); + return this.prisma.seatClass.delete({ where: { id } }); } } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index a8d5d724c..4a9783101 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -28,6 +28,25 @@ import { IamGuard } from "../../common/iam-adapter"; export class SeatsController { constructor(private service: SeatsService) {} + // ── Coach Availability ──────────────────────────────────────────────────── + @Get('coaches/:scheduleId') + @SetMetadata('isPublic', true) + @ApiOperation({ + summary: 'List coaches with remaining seat counts for a schedule', + description: 'Returns each coach assigned to the schedule with total, available, held, and booked seat counts. Optionally scoped to a specific origin→destination leg.', + }) + @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) + @ApiQuery({ name: 'originStationId', required: false, description: 'Scope availability to this origin station' }) + @ApiQuery({ name: 'destinationStationId', required: false, description: 'Scope availability to this destination station' }) + @ApiResponse({ status: 200, description: 'Coaches with seat availability counts' }) + getCoachesWithAvailability( + @Param('scheduleId') scheduleId: string, + @Query('originStationId') originStationId?: string, + @Query('destinationStationId') destinationStationId?: string, + ) { + return this.service.getCoachesWithAvailability(scheduleId, originStationId, destinationStationId); + } + // ── Seat Map ────────────────────────────────────────────────────────────── @Get("seatmap/:scheduleId") @SetMetadata('isPublic', true) diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 1562a600b..5c237e6ba 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -367,7 +367,24 @@ export class SeatsService { where: { scheduleId: dto.scheduleId }, select: { stationId: true, sequence: true }, }); - const seqOf = (stationId: string) => stopTimes.find(s => s.stationId === stationId)?.sequence; + + // When no stop times exist, fall back to the schedule's own origin/destination + // with synthetic sequences so the hold can still be created. + let effectiveStopTimes = stopTimes; + if (stopTimes.length === 0) { + const sched = await tx.trainSchedule.findUnique({ + where: { id: dto.scheduleId }, + select: { originStationId: true, destinationStationId: true }, + }); + if (sched) { + effectiveStopTimes = [ + { stationId: sched.originStationId, sequence: 0 }, + { stationId: sched.destinationStationId, sequence: 1 }, + ]; + } + } + + const seqOf = (stationId: string) => effectiveStopTimes.find(s => s.stationId === stationId)?.sequence; const reqFrom = seqOf(dto.originStationId); const reqTo = seqOf(dto.destinationStationId); @@ -604,6 +621,56 @@ export class SeatsService { await this.prisma.journey.deleteMany({ where: { bookingId } as any }); } + async getCoachesWithAvailability(scheduleId: string, originStationId?: string, destinationStationId?: string) { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + select: { originStationId: true, destinationStationId: true }, + }); + if (!schedule) throw new NotFoundException('Schedule not found'); + + const assignments = await this.prisma.coachAssignment.findMany({ + where: { scheduleId }, + include: { + coach: { + include: { + seats: { select: { id: true, status: true, seatNumber: true } }, + coachType: { include: { seatClasses: { select: { name: true } } } }, + }, + }, + }, + orderBy: { positionNumber: 'asc' }, + }); + + const allSeatIds = assignments.flatMap(a => a.coach.seats.map(s => s.id)); + const effectiveStatuses = await this.resolveEffectiveStatuses( + scheduleId, + allSeatIds, + originStationId ?? schedule.originStationId, + destinationStationId ?? schedule.destinationStationId, + ); + + return assignments.map(a => { + const seats = a.coach.seats.filter(s => s.seatNumber && !s.seatNumber.startsWith('-')); + const totalSeats = seats.length; + const unavailable = seats.filter(s => { + const status = effectiveStatuses.get(s.id) ?? s.status; + return status === 'HELD' || status === 'BOOKED' || status === 'BLOCKED'; + }).length; + + return { + coachId: a.coach.id, + coachNumber: a.coach.number, + positionNumber: a.positionNumber, + coachTypeName: a.coach.coachType?.name ?? '', + seatClasses: a.coach.coachType?.seatClasses.map(sc => sc.name) ?? [], + totalSeats, + availableSeats: totalSeats - unavailable, + heldSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? s.status) === 'HELD').length, + bookedSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? s.status) === 'BOOKED').length, + }; + }); + } + async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise { const seats = await this.prisma.seat.findMany({ where: { diff --git a/apps/edr-passenger-api/src/modules/stations/stations.service.ts b/apps/edr-passenger-api/src/modules/stations/stations.service.ts index 9e9fc824d..ec2480b21 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.service.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.service.ts @@ -3,6 +3,7 @@ import { REQUEST } from '@nestjs/core'; import { PrismaService } from '../../common/prisma.service'; import { AuditService } from '../../common/audit.service'; import { CreateStationDto } from './stations.dto'; +import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; interface StationFilters { search?: string; @@ -96,7 +97,35 @@ export class StationsService { } async remove(id: string) { - const station = await this.findOne(id); + const station = await this.prisma.station.findUnique({ + where: { id }, + include: { + _count: { select: { stopTimes: true } }, + originSchedules: { take: 1, select: { id: true } }, + destinationSchedules: { take: 1, select: { id: true } }, + }, + }); + if (!station) throw new NotFoundException('Station not found'); + + const [routeStopCount, originCount, destCount, stopTimeCount] = await Promise.all([ + this.prisma.routeStop.count({ where: { stationId: id } }), + this.prisma.trainSchedule.count({ where: { originStationId: id } }), + this.prisma.trainSchedule.count({ where: { destinationStationId: id } }), + (station as any)._count.stopTimes as number, + ]); + + const constraints = []; + if (routeStopCount > 0) + constraints.push({ entityName: 'route', count: routeStopCount, action: 'delete' as const }); + const scheduleCount = originCount + destCount; + if (scheduleCount > 0) + constraints.push({ entityName: 'schedule', count: scheduleCount, action: 'delete' as const }); + if (stopTimeCount > 0) + constraints.push({ entityName: 'stop time', count: stopTimeCount, action: 'delete' as const }); + + if (constraints.length > 0) + throw new DeleteOperationException('Station', `${station.name} (${station.code})`, constraints); + const deleted = await this.prisma.station.delete({ where: { id } }); await this.auditService.log({ diff --git a/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts b/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts index 1ecb2edea..8b0c5ed2e 100644 --- a/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts +++ b/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Delete, UseGuards, SetMetadata, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { WalletService } from './wallet.service'; @@ -11,6 +11,8 @@ import { JwtGuard } from '../../common/jwt.guard'; @Throttle({ strict: { limit: 20, ttl: 60_000 } }) export class WalletController { constructor(private service: WalletService) {} - @Get(':passengerId') @ApiOperation({ summary: 'Get wallet balance and ledger' }) getWallet(@Param('passengerId') id: string) { return this.service.getWallet(id); } - @Post(':passengerId/topup') @ApiOperation({ summary: 'Top up wallet' }) topUp(@Param('passengerId') id: string, @Body('amountMinor') amount: number) { return this.service.topUp(id, amount); } + @Get('accounts') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'List all wallet accounts' }) getAccounts(@Query() q: any) { return this.service.getAccounts(q); } + @Get(':passengerId') @ApiOperation({ summary: 'Get wallet balance and ledger' }) getWallet(@Param('passengerId') id: string) { return this.service.getWallet(id); } + @Post(':passengerId/topup') @ApiOperation({ summary: 'Top up wallet' }) topUp(@Param('passengerId') id: string, @Body('amountMinor') amount: number) { return this.service.topUp(id, amount); } + @Delete('accounts/:id') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Delete wallet account' }) deleteAccount(@Param('id') id: string) { return this.service.deleteAccount(id); } } diff --git a/apps/edr-passenger-api/src/modules/wallet/wallet.service.ts b/apps/edr-passenger-api/src/modules/wallet/wallet.service.ts index a83d97e0b..ac092ee41 100644 --- a/apps/edr-passenger-api/src/modules/wallet/wallet.service.ts +++ b/apps/edr-passenger-api/src/modules/wallet/wallet.service.ts @@ -5,6 +5,42 @@ import { PrismaService } from '../../common/prisma.service'; export class WalletService { constructor(private prisma: PrismaService) {} + async getAccounts(params: { search?: string; page?: string; pageSize?: string } = {}) { + const { search, page = '1', pageSize = '20' } = params; + const skip = (parseInt(page) - 1) * parseInt(pageSize); + const where: any = {}; + if (search) { + where.passenger = { + OR: [ + { user: { fullName: { contains: search, mode: 'insensitive' } } }, + { user: { email: { contains: search, mode: 'insensitive' } } }, + ], + }; + } + const [items, total] = await Promise.all([ + this.prisma.walletAccount.findMany({ + where, + skip, + take: parseInt(pageSize), + orderBy: { balanceMinor: 'desc' }, + include: { passenger: { include: { user: true } } }, + }), + this.prisma.walletAccount.count({ where }), + ]); + return { + items: items.map(w => ({ + ...w, + passenger: w.passenger ? { + id: w.passenger.id, + fullName: (w.passenger as any).user?.fullName ?? null, + email: (w.passenger as any).user?.email ?? null, + phone: (w.passenger as any).user?.phone ?? null, + } : null, + })), + meta: { page: parseInt(page), pageSize: parseInt(pageSize), total, totalPages: Math.ceil(total / parseInt(pageSize)) }, + }; + } + async getWallet(passengerId: string) { const wallet = await this.prisma.walletAccount.findUnique({ where: { passengerId }, include: { ledger: { orderBy: { createdAt: 'desc' }, take: 20 } } }); if (!wallet) throw new NotFoundException('Wallet not found'); @@ -18,4 +54,14 @@ export class WalletService { await this.prisma.walletAccount.update({ where: { passengerId }, data: { balanceMinor: newBalance } }); return this.prisma.walletLedgerEntry.create({ data: { walletId: wallet.id, type: 'CREDIT', amountMinor, balanceAfterMinor: newBalance, description } }); } + + async deleteAccount(id: string) { + const wallet = await this.prisma.walletAccount.findUnique({ where: { id } }); + if (!wallet) throw new NotFoundException('Wallet account not found'); + await this.prisma.$transaction([ + this.prisma.walletLedgerEntry.deleteMany({ where: { walletId: id } }), + this.prisma.walletAccount.delete({ where: { id } }), + ]); + return { deleted: true, accountId: id }; + } } diff --git a/apps/edr-passenger-web/backoffice/src/app/app-releases/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/app-releases/layout.tsx new file mode 100644 index 000000000..86d53715f --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/app-releases/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function Layout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx b/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx new file mode 100644 index 000000000..2e036f31e --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx @@ -0,0 +1,186 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Pencil, Trash2 } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { appReleasesApi } from '@/lib/api'; +import { formatDateTime } from '@/lib/utils'; + +const EMPTY_FORM = { os: 'android', version: '', forceUpdate: false, storeLink: '', notes: '' }; + +export default function AppReleasesPage() { + const queryClient = useQueryClient(); + const [formOpen, setFormOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState({ ...EMPTY_FORM }); + const [formError, setFormError] = useState(''); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteError, setDeleteError] = useState(null); + const [successMessage, setSuccessMessage] = useState(''); + + const { data, isLoading } = useQuery({ + queryKey: ['app-releases'], + queryFn: () => appReleasesApi.getAll(), + }); + + const flash = (msg: string) => { setSuccessMessage(msg); setTimeout(() => setSuccessMessage(''), 3000); }; + + const saveMutation = useMutation({ + mutationFn: (payload: any) => + editing ? appReleasesApi.update(editing.id, payload) : appReleasesApi.create(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['app-releases'] }); + setFormOpen(false); + setEditing(null); + setForm({ ...EMPTY_FORM }); + setFormError(''); + flash(editing ? 'Release updated.' : 'Release created.'); + }, + onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to save.'), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => appReleasesApi.remove(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['app-releases'] }); + setDeleteTarget(null); + setDeleteError(null); + flash('Release deleted.'); + }, + onError: (e: any) => setDeleteError(e?.response?.data?.message || e?.message || 'Failed to delete.'), + }); + + const openCreate = () => { setEditing(null); setForm({ ...EMPTY_FORM }); setFormError(''); setFormOpen(true); }; + const openEdit = (r: any) => { + setEditing(r); + setForm({ os: r.os, version: r.version, forceUpdate: r.forceUpdate, storeLink: r.storeLink || '', notes: r.notes || '' }); + setFormError(''); + setFormOpen(true); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!form.version.trim()) { setFormError('Version is required.'); return; } + saveMutation.mutate({ ...form, version: form.version.trim(), storeLink: form.storeLink || undefined, notes: form.notes || undefined }); + }; + + const releases: any[] = Array.isArray(data) ? data : []; + + const columns = [ + { + key: 'os', label: 'OS', + render: (r: any) => ( + + {r.os === 'ios' ? '🍎 iOS' : '🤖 Android'} + + ), + }, + { key: 'version', label: 'Version', render: (r: any) => {r.version} }, + { + key: 'forceUpdate', label: 'Force Update', + render: (r: any) => {r.forceUpdate ? 'Yes' : 'No'}, + }, + { + key: 'storeLink', label: 'Store Link', + render: (r: any) => r.storeLink + ? {r.storeLink} + : , + }, + { key: 'notes', label: 'Notes', render: (r: any) => {r.notes || '—'} }, + { key: 'createdAt', label: 'Created', render: (r: any) => {formatDateTime(r.createdAt)} }, + ]; + + const actions = [ + { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Pencil }, + { label: 'Delete', onClick: (r: any) => { setDeleteError(null); setDeleteTarget(r); }, variant: 'danger' as const, icon: Trash2 }, + ]; + + return ( +
+
+
+

App Releases

+

Manage mobile app version release control

+
+ New Release +
+ + {successMessage && ( +
✓ {successMessage}
+ )} + +
+ +
+ + {/* Create / Edit Modal */} + setFormOpen(false)} title={editing ? 'Edit Release' : 'New Release'} size="md"> +
+
+
+ + +
+
+ + setForm({ ...form, version: e.target.value })} /> +
+
+ +
+ +
+ {(['true', 'false'] as const).map((val) => ( + + ))} +
+
+ +
+ + setForm({ ...form, storeLink: e.target.value })} /> +
+ +
+ +