From 63f638d1bba63dca9999c2161f72843e69a7bcf5 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 7 Jul 2026 08:52:35 +0000 Subject: [PATCH 01/34] fix: session expiry during usage and also login error message --- apps/edr-freight-web/backoffice/.env.example | 4 + .../backoffice/src/auth/AuthProvider.tsx | 16 ++++ .../backoffice/src/auth/http.ts | 39 +++++--- .../backoffice/src/auth/refreshScheduler.ts | 82 +++++++++++++++++ .../backoffice/src/utils/result.ts | 34 ++++++- apps/edr-freight-web/portal/src/App.tsx | 19 +++- apps/edr-freight-web/portal/src/utils/api.ts | 89 ++++++++++--------- .../portal/src/utils/refreshScheduler.ts | 81 +++++++++++++++++ .../portal/src/utils/result.ts | 34 ++++++- 9 files changed, 340 insertions(+), 58 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts create mode 100644 apps/edr-freight-web/portal/src/utils/refreshScheduler.ts diff --git a/apps/edr-freight-web/backoffice/.env.example b/apps/edr-freight-web/backoffice/.env.example index cbbdd289f..a5e34a35d 100644 --- a/apps/edr-freight-web/backoffice/.env.example +++ b/apps/edr-freight-web/backoffice/.env.example @@ -1,2 +1,6 @@ VITE_API_URL=http://localhost:3001 VITE_BASE_API_URL=http://localhost:3001 + +# Proactive token refresh cadence (minutes). Must stay well under the 60-min +# server session window. Default: 10. +VITE_TOKEN_REFRESH_INTERVAL_MINUTES=10 diff --git a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx index 66834c9f5..8266d96d7 100644 --- a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx +++ b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx @@ -16,6 +16,10 @@ import { setCookie, } from "./cookies"; import { applyTokens } from "./http"; +import { + startTokenRefreshScheduler, + stopTokenRefreshScheduler, +} from "./refreshScheduler"; import type { AuthTokens, AuthUser } from "./types"; interface LoginPayload { @@ -99,6 +103,18 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { void bootstrap(); }, []); + // Keep the server session alive while a user is logged in. Runs after + // login, MFA verification, and page-reload bootstrap alike. + useEffect(() => { + if (!user) { + stopTokenRefreshScheduler(); + return; + } + + startTokenRefreshScheduler(); + return stopTokenRefreshScheduler; + }, [user]); + const value = useMemo( () => ({ user, diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts index 5aa78e2d3..2b0cf1021 100644 --- a/apps/edr-freight-web/backoffice/src/auth/http.ts +++ b/apps/edr-freight-web/backoffice/src/auth/http.ts @@ -28,6 +28,30 @@ const applyTokens = ({ token, refreshToken }: AuthTokens) => { setCookie(REFRESH_TOKEN_COOKIE, refreshToken); }; +/** + * Single-flight token refresh: concurrent callers (the 401 interceptor and + * the proactive scheduler) share one in-flight request so the refresh token + * is only rotated once. Throws if no refresh token is stored or the server + * rejects it — callers decide how to end the session. + */ +const refreshSessionTokens = async (): Promise => { + const refreshToken = getCookie(REFRESH_TOKEN_COOKIE); + if (!refreshToken) { + throw new Error("missing refresh token"); + } + + refreshPromise ??= api + .post("/auth/refresh-token", { refreshToken }) + .then((response) => response.data) + .finally(() => { + refreshPromise = null; + }); + + const tokens = await refreshPromise; + applyTokens(tokens); + return tokens; +}; + api.interceptors.request.use((config) => { const token = getCookie(AUTH_TOKEN_COOKIE); @@ -65,8 +89,7 @@ api.interceptors.response.use( return Promise.reject(error); } - const refreshToken = getCookie(REFRESH_TOKEN_COOKIE); - if (!refreshToken) { + if (!getCookie(REFRESH_TOKEN_COOKIE)) { clearSessionCookies(); return Promise.reject(error); } @@ -74,15 +97,7 @@ api.interceptors.response.use( originalRequest._retry = true; try { - refreshPromise ??= api - .post("/auth/refresh-token", { refreshToken }) - .then((response) => response.data) - .finally(() => { - refreshPromise = null; - }); - - const tokens = await refreshPromise; - applyTokens(tokens); + const tokens = await refreshSessionTokens(); originalRequest.headers = { ...originalRequest.headers, Authorization: `Bearer ${tokens.token}`, @@ -97,4 +112,4 @@ api.interceptors.response.use( }, ); -export { api, applyTokens }; +export { api, applyTokens, refreshSessionTokens }; diff --git a/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts b/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts new file mode 100644 index 000000000..1d2c14db2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts @@ -0,0 +1,82 @@ +import { isAxiosError } from "axios"; + +import { + REFRESH_TOKEN_COOKIE, + clearSessionCookies, + getCookie, +} from "./cookies"; +import { refreshSessionTokens } from "./http"; + +/** + * Proactively refreshes the token pair on a fixed cadence so the server-side + * session (a sliding 1-hour window, extended only by /auth/refresh-token) is + * kept alive while the app is open. The 401 interceptor in http.ts remains + * the reactive fallback; both share the same single-flight refresh call. + * + * The interval MUST stay well under the server session window (60 min). + */ +const DEFAULT_INTERVAL_MINUTES = 10; + +const getIntervalMs = () => { + const minutes = Number(import.meta.env.VITE_TOKEN_REFRESH_INTERVAL_MINUTES); + return ( + (Number.isFinite(minutes) && minutes > 0 + ? minutes + : DEFAULT_INTERVAL_MINUTES) * 60_000 + ); +}; + +let timerId: number | null = null; +let lastRefreshAt = 0; + +const refreshNow = async () => { + if (!getCookie(REFRESH_TOKEN_COOKIE)) { + // Logged out elsewhere; nothing to keep alive. + stopTokenRefreshScheduler(); + return; + } + + try { + await refreshSessionTokens(); + lastRefreshAt = Date.now(); + } catch (error) { + // Network hiccups are retried on the next tick; only an explicit server + // rejection means the session is dead. + if (isAxiosError(error) && error.response) { + stopTokenRefreshScheduler(); + clearSessionCookies(); + window.location.replace("/auth"); + } + } +}; + +/** + * Browsers freeze timers in background tabs — a tab waking up past its + * refresh deadline refreshes immediately instead of waiting a full interval. + */ +const onVisibilityChange = () => { + if (document.visibilityState !== "visible") return; + if (Date.now() - lastRefreshAt >= getIntervalMs()) { + void refreshNow(); + } +}; + +export const startTokenRefreshScheduler = () => { + stopTokenRefreshScheduler(); + + // Token age is unknown here (fresh login vs. hours-old page reload), so + // refresh right away to extend the session window from "now". + lastRefreshAt = 0; + void refreshNow(); + + timerId = window.setInterval(() => void refreshNow(), getIntervalMs()); + document.addEventListener("visibilitychange", onVisibilityChange); +}; + +export const stopTokenRefreshScheduler = () => { + if (timerId !== null) { + window.clearInterval(timerId); + timerId = null; + } + document.removeEventListener("visibilitychange", onVisibilityChange); +}; diff --git a/apps/edr-freight-web/backoffice/src/utils/result.ts b/apps/edr-freight-web/backoffice/src/utils/result.ts index 3e9627445..54104442c 100644 --- a/apps/edr-freight-web/backoffice/src/utils/result.ts +++ b/apps/edr-freight-web/backoffice/src/utils/result.ts @@ -8,6 +8,32 @@ export type ApiError = { statusCode?: number; }; +/** + * Backend errors arrive as snake_case i18n-style codes (e.g. + * "unable_to_log_in"). Map the known ones to friendly copy and prettify + * anything else so raw codes never reach the UI. `code` stays raw for + * programmatic checks. + */ +const API_ERROR_MESSAGES: Record = { + unable_to_log_in: "Incorrect email or password.", + invalid_refresh_token: "Your session has expired. Please sign in again.", + session_expired: "Your session has expired. Please sign in again.", + session_not_found: "Your session has expired. Please sign in again.", + user_not_found: "No account found for these credentials.", +}; + +const SNAKE_CASE_CODE = /^[a-z0-9]+(?:_[a-z0-9]+)+$/; + +function humanizeApiMessage(raw: string): string { + const known = API_ERROR_MESSAGES[raw]; + if (known) return known; + if (SNAKE_CASE_CODE.test(raw)) { + const text = raw.replaceAll("_", " "); + return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`; + } + return raw; +} + export function extractApiError(err: unknown): ApiError { if (err && typeof err === "object") { const obj = err as Record; @@ -16,8 +42,12 @@ export function extractApiError(err: unknown): ApiError { const statusCode = response.status as number | undefined; const data = response.data as Record | undefined; return { - code: (data?.error as string) || (data?.message as string) || "api_error", - message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred", + code: (data?.message as string) || (data?.error as string) || "api_error", + message: humanizeApiMessage( + (data?.message as string) || + (data?.error as string) || + "An unexpected error occurred", + ), statusCode, }; } diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 7567c485d..697c2f89c 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -24,6 +24,10 @@ import OnboardingResumeBanner, { } from "./components/onboarding/OnboardingResumeBanner"; import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog"; import useAuth from "./hooks/useAuth"; +import { + startTokenRefreshScheduler, + stopTokenRefreshScheduler, +} from "./utils/refreshScheduler"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import MyPortalPage from "./pages/MyPortalPage"; import MySignaturePage from "./pages/MySignaturePage"; @@ -212,7 +216,20 @@ const sidebarItems: SidebarItem[] = [ const App = () => { const navigate = useNavigate(); const location = useLocation(); - const { user, company, companyType, createProfileAndSwitch } = useAuth(); + const { user, company, companyType, createProfileAndSwitch, isAuthenticated } = + useAuth(); + + // Keep the server session alive while a user is logged in. Runs after + // login, signup, and page-reload bootstrap alike. + useEffect(() => { + if (!isAuthenticated) { + stopTokenRefreshScheduler(); + return; + } + + startTokenRefreshScheduler(); + return stopTokenRefreshScheduler; + }, [isAuthenticated]); const displayName = user?.name?.en || user?.username || user?.email || "User"; const userEmail = user?.email; diff --git a/apps/edr-freight-web/portal/src/utils/api.ts b/apps/edr-freight-web/portal/src/utils/api.ts index 289709906..6cc569477 100644 --- a/apps/edr-freight-web/portal/src/utils/api.ts +++ b/apps/edr-freight-web/portal/src/utils/api.ts @@ -42,21 +42,41 @@ client.interceptors.request.use((config) => { }); // Token refresh state -let isRefreshing = false; -let failedQueue: { - resolve: (token: string) => void; - reject: (error: unknown) => void; -}[] = []; +let refreshPromise: Promise | null = null; -function processQueue(error: unknown, token?: string) { - failedQueue.forEach(({ resolve, reject }) => { - if (error) { - reject(error); - } else { - resolve(token!); +/** + * Single-flight token refresh: concurrent callers (the 401 interceptor and + * the proactive scheduler) share one in-flight request so the refresh token + * is only rotated once. Throws if no refresh token is stored or the server + * rejects it — callers decide how to end the session. + */ +async function refreshSessionTokens(): Promise { + refreshPromise ??= (async () => { + const refreshToken = getCookie("refresh-token"); + if (!refreshToken) { + throw new Error("missing refresh token"); } + + type TokenPair = { token: string; refreshToken: string }; + const { data } = await client.post & { data?: TokenPair }>( + URL_CONSTANTS.AUTH.REFRESH_TOKEN, + { refreshToken }, + ); + // The API returns the pair flat ({ success, token, refreshToken }); accept + // a { data: { ... } }-wrapped shape too so a transform change can't + // silently break refresh again. + const payload = data.data ?? data; + if (!payload.token || !payload.refreshToken) { + throw new Error("malformed refresh-token response"); + } + setCookie("auth-token", payload.token, 7); + setCookie("refresh-token", payload.refreshToken, 7); + return payload.token; + })().finally(() => { + refreshPromise = null; }); - failedQueue = []; + + return refreshPromise; } // Handle auth errors globally with token refresh @@ -72,54 +92,41 @@ client.interceptors.response.use( // - status is not 401 // - already retried // - it's the refresh endpoint itself + // - it's a credential endpoint (401 there = wrong credentials, not an + // expired session — refreshing would mask the real error) if ( !error.response || error.response.status !== 401 || originalRequest._retry || - originalRequest.url === URL_CONSTANTS.AUTH.REFRESH_TOKEN + originalRequest.url === URL_CONSTANTS.AUTH.REFRESH_TOKEN || + originalRequest.url === URL_CONSTANTS.AUTH.LOGIN || + originalRequest.url === URL_CONSTANTS.USERS.SIGN_UP ) { return Promise.reject(error); } - if (isRefreshing) { - return new Promise((resolve, reject) => { - failedQueue.push({ resolve, reject }); - }).then((token) => { - originalRequest.headers.Authorization = `Bearer ${token}`; - return client(originalRequest); - }); - } - - originalRequest._retry = true; - isRefreshing = true; - - const refreshToken = getCookie("refresh-token"); - - if (!refreshToken) { - isRefreshing = false; - clearAuthCookies(); + // Nothing to refresh with (e.g. not logged in yet) — surface the + // original error instead of a confusing refresh failure. + if (!getCookie("refresh-token")) { + if (getCookie("auth-token")) { + // Half-broken cookie state; reset it. + clearAuthCookies(); + } return Promise.reject(error); } + originalRequest._retry = true; + try { - const { data } = await client.post<{ - data: { token: string; refreshToken: string }; - }>(URL_CONSTANTS.AUTH.REFRESH_TOKEN, { refreshToken }); - const { token, refreshToken: newRefreshToken } = data.data; - setCookie("auth-token", token, 7); - setCookie("refresh-token", newRefreshToken, 7); + const token = await refreshSessionTokens(); originalRequest.headers.Authorization = `Bearer ${token}`; - processQueue(null, token); return client(originalRequest); } catch (refreshError) { - processQueue(refreshError, undefined); clearAuthCookies(); return Promise.reject(refreshError); - } finally { - isRefreshing = false; } }, ); -export { client }; +export { client, clearAuthCookies, getCookie, refreshSessionTokens }; export type { UseQueryOptions, QueryObserverOptions }; diff --git a/apps/edr-freight-web/portal/src/utils/refreshScheduler.ts b/apps/edr-freight-web/portal/src/utils/refreshScheduler.ts new file mode 100644 index 000000000..dceca856e --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/refreshScheduler.ts @@ -0,0 +1,81 @@ +import { isAxiosError } from "axios"; + +import { + clearAuthCookies, + getCookie, + refreshSessionTokens, +} from "./api"; + +/** + * Proactively refreshes the token pair on a fixed cadence so the server-side + * session (a sliding 1-hour window, extended only by /auth/refresh-token) is + * kept alive while the app is open. The 401 interceptor in api.ts remains + * the reactive fallback; both share the same single-flight refresh call. + * + * The interval MUST stay well under the server session window (60 min). + */ +const DEFAULT_INTERVAL_MINUTES = 10; + +const getIntervalMs = () => { + const minutes = Number(import.meta.env.VITE_TOKEN_REFRESH_INTERVAL_MINUTES); + return ( + (Number.isFinite(minutes) && minutes > 0 + ? minutes + : DEFAULT_INTERVAL_MINUTES) * 60_000 + ); +}; + +let timerId: number | null = null; +let lastRefreshAt = 0; + +const refreshNow = async () => { + if (!getCookie("refresh-token")) { + // Logged out elsewhere; nothing to keep alive. + stopTokenRefreshScheduler(); + return; + } + + try { + await refreshSessionTokens(); + lastRefreshAt = Date.now(); + } catch (error) { + // Network hiccups are retried on the next tick; only an explicit server + // rejection means the session is dead. + if (isAxiosError(error) && error.response) { + stopTokenRefreshScheduler(); + clearAuthCookies(); + window.location.replace("/login"); + } + } +}; + +/** + * Browsers freeze timers in background tabs — a tab waking up past its + * refresh deadline refreshes immediately instead of waiting a full interval. + */ +const onVisibilityChange = () => { + if (document.visibilityState !== "visible") return; + if (Date.now() - lastRefreshAt >= getIntervalMs()) { + void refreshNow(); + } +}; + +export const startTokenRefreshScheduler = () => { + stopTokenRefreshScheduler(); + + // Token age is unknown here (fresh login vs. hours-old page reload), so + // refresh right away to extend the session window from "now". + lastRefreshAt = 0; + void refreshNow(); + + timerId = window.setInterval(() => void refreshNow(), getIntervalMs()); + document.addEventListener("visibilitychange", onVisibilityChange); +}; + +export const stopTokenRefreshScheduler = () => { + if (timerId !== null) { + window.clearInterval(timerId); + timerId = null; + } + document.removeEventListener("visibilitychange", onVisibilityChange); +}; diff --git a/apps/edr-freight-web/portal/src/utils/result.ts b/apps/edr-freight-web/portal/src/utils/result.ts index 3e9627445..54104442c 100644 --- a/apps/edr-freight-web/portal/src/utils/result.ts +++ b/apps/edr-freight-web/portal/src/utils/result.ts @@ -8,6 +8,32 @@ export type ApiError = { statusCode?: number; }; +/** + * Backend errors arrive as snake_case i18n-style codes (e.g. + * "unable_to_log_in"). Map the known ones to friendly copy and prettify + * anything else so raw codes never reach the UI. `code` stays raw for + * programmatic checks. + */ +const API_ERROR_MESSAGES: Record = { + unable_to_log_in: "Incorrect email or password.", + invalid_refresh_token: "Your session has expired. Please sign in again.", + session_expired: "Your session has expired. Please sign in again.", + session_not_found: "Your session has expired. Please sign in again.", + user_not_found: "No account found for these credentials.", +}; + +const SNAKE_CASE_CODE = /^[a-z0-9]+(?:_[a-z0-9]+)+$/; + +function humanizeApiMessage(raw: string): string { + const known = API_ERROR_MESSAGES[raw]; + if (known) return known; + if (SNAKE_CASE_CODE.test(raw)) { + const text = raw.replaceAll("_", " "); + return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`; + } + return raw; +} + export function extractApiError(err: unknown): ApiError { if (err && typeof err === "object") { const obj = err as Record; @@ -16,8 +42,12 @@ export function extractApiError(err: unknown): ApiError { const statusCode = response.status as number | undefined; const data = response.data as Record | undefined; return { - code: (data?.error as string) || (data?.message as string) || "api_error", - message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred", + code: (data?.message as string) || (data?.error as string) || "api_error", + message: humanizeApiMessage( + (data?.message as string) || + (data?.error as string) || + "An unexpected error occurred", + ), statusCode, }; } From 1c18fdbd529b7b080fd2abdb8dcd8a7a6937dd66 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 7 Jul 2026 09:49:12 +0000 Subject: [PATCH 02/34] Enhance contract management by adding booking windows section and updating invoice logic for offloaded cargo --- .../contracts/gl-operations.service.ts | 12 +- .../contracts/ExportClearanceStepper.tsx | 6 +- .../contracts/GlUpcomingWindowsSection.tsx | 75 +++- .../contracts/ContractClearanceDetailPage.tsx | 5 + .../backoffice/src/types/trainScheduling.ts | 2 + .../ContractBookingWindowsSection.tsx | 325 ++++++++++++++++++ .../pages/contracts/ContractDetailPage.tsx | 13 +- .../src/pages/contracts/NewContractPage.tsx | 6 +- 8 files changed, 422 insertions(+), 22 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx 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 181d8b688..28a31c331 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 @@ -395,14 +395,20 @@ export class GlOperationsService { } if (!file) throw new BadRequestException('Attach the invoice document.'); + // Invoiceable once cargo is offloaded, or — for export, where OFFLOADED is a + // DJ doc milestone that may never be recorded — once the Djibouti gate pass + // is secured. The invoice itself stays optional; nothing forces GL DJ to send one. const milestones = await this.milestoneService.listForBooking(bookingId); const offloaded = milestones.find( (m) => m.milestoneCode === 'OFFLOADED' && m.status === 'COMPLETED', ); if (!offloaded) { - throw new BadRequestException( - 'Cargo must be offloaded before the final invoice can be raised.', - ); + const gatepass = await this.gatepassForBooking(bookingId); + if (!gatepass.granted) { + throw new BadRequestException( + 'Cargo must be offloaded (or the gate pass secured) before the final invoice can be raised.', + ); + } } const existing = await this.billingService.findInvoice( 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 db6e79430..dc59c4dfc 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -646,7 +646,9 @@ function FinalInvoiceStep({ const invoice = clearance.finalInvoice ?? null; const paid = invoice?.status === "PAID"; - if (!clearance.offloaded && !invoice) { + // Export: OFFLOADED is a DJ doc milestone that may never be recorded, so the + // secured gate pass is enough to open invoicing. Sending an invoice is optional. + if (!clearance.offloaded && !clearance.gatepassGranted && !invoice) { return ( - Cargo offloaded — send the final invoice to the customer. + Send the final invoice to the customer if post-arrival charges apply (optional). )} + {hasFile && ( + + + + )} 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 75e901d10..6256bd9cc 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -727,9 +727,9 @@ function ImportT1UploadStep({ ); } - const departed = Boolean(t1.trainDepartedAt); - const canUpload = - canDjAct && t1.wagonAllocated && gatepassGranted && !departed && !t1.closed; + // Departure no longer locks T1 docs — GL DJ may replace them until GL Ethiopia + // closes/accepts the T1. + const canUpload = canDjAct && t1.wagonAllocated && gatepassGranted && !t1.closed; return ( @@ -769,10 +769,6 @@ function ImportT1UploadStep({ pendingLabel="Waiting for the gate pass to be secured on the train schedule." doneLabel="" /> - ) : departed ? ( - }> - The train has departed — T1 documents are locked and can no longer be changed. - ) : uploaded.length === 0 && !canUpload ? ( = { APPROVED: "cyan", PAID: "edr-green", IN_TRANSIT: "blue", + ARRIVED: "teal", COMPLETED: "indigo", REJECTED: "red", CANCELLED: "red", 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 de010e380..887b235c4 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx @@ -33,7 +33,9 @@ const FleetRecordActions = ({ const isVehicle = config.slug === "vehicles"; const showHistory = Boolean(onHistory) && - (config.slug === "drivers" || config.slug === "vehicles"); + (config.slug === "drivers" || + config.slug === "vehicles" || + config.slug === "wagons"); const handleDetail = () => { if (!config.detailPath || !("id" in record)) return; diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/WagonMovementHistoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/WagonMovementHistoryModal.tsx new file mode 100644 index 000000000..e2713fdcb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/fleet/WagonMovementHistoryModal.tsx @@ -0,0 +1,133 @@ +import type { ReactNode } from "react"; +import { Badge, Center, Group, Loader, Modal, Text, Timeline } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { ArrowRight, PackageCheck, TrainFront, Wrench } from "lucide-react"; + +import { api } from "@/services/api"; +import type { FleetRecord } from "@/services/fleet/fleet.service"; +import type { WagonMovementRecord } from "@/services/wagon.service"; + +export interface WagonMovementHistoryModalProps { + opened: boolean; + onClose: () => void; + record: FleetRecord | null; +} + +const asObj = (r: FleetRecord | null) => (r ?? {}) as Record; + +/** Chip style per wagon_movements ledger kind. */ +const KIND_META: Record = { + LOADED: { + label: "Loaded leg", + color: "edr-green", + icon: , + }, + EMPTY_REPOSITION: { + label: "Empty reposition", + color: "blue", + icon: , + }, + MANUAL: { + label: "Manual move", + color: "orange", + icon: , + }, +}; + +const yardLabel = ( + yard: { label?: string; code?: string } | null | undefined, + yardId: string | null, +) => yard?.label ?? yard?.code ?? yardId ?? "Unknown"; + +const fmt = (iso: string) => { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); +}; + +/** + * Movement ledger for one wagon: every relocation between yards — booking legs, + * empty reposition rides, and manual staff corrections — newest first. + */ +const WagonMovementHistoryModal = ({ + opened, + onClose, + record, +}: WagonMovementHistoryModalProps) => { + const r = asObj(record); + const id = r.id ? String(r.id) : ""; + const wagonNumber = r.wagonNumber ? String(r.wagonNumber) : ""; + + const { data, isLoading } = useQuery( + api.wagons.movements.queryOptions({ + input: { id }, + enabled: opened && Boolean(id), + }), + ); + + const movements: WagonMovementRecord[] = data ?? []; + + return ( + {`Wagon history — ${wagonNumber}`.trim()}} + radius="lg" + size="lg" + centered + > + {isLoading ? ( +
+ +
+ ) : movements.length === 0 ? ( + + No movements recorded yet. Every yard-to-yard move appears here — a + booking's loaded leg, an empty reposition ride, or a manual correction. + + ) : ( + + {movements.map((movement) => { + const meta = KIND_META[movement.kind] ?? { + label: movement.kind, + color: "gray", + icon: , + }; + const from = yardLabel(movement.fromYard, movement.fromYardId); + const to = yardLabel(movement.toYard, movement.toYardId); + return ( + + + {from} + + + + {to} + + + {meta.label} + + + } + > + {movement.note && ( + + {movement.note} + + )} + + {fmt(movement.occurredAt)} + + + ); + })} + + )} +
+ ); +}; + +export default WagonMovementHistoryModal; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/YardWorkPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/YardWorkPanel.tsx new file mode 100644 index 000000000..76ccdfde2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/YardWorkPanel.tsx @@ -0,0 +1,333 @@ +import { + Alert, + Badge, + Button, + Divider, + Group, + Loader, + Paper, + Stack, + Table, + Text, + Tooltip, +} from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AlertCircle, MapPin, PackageCheck, PackageOpen, TrainFront } from "lucide-react"; +import { Freight } from "@edr/types"; + +import { api } from "@/services/api"; +import { useToast } from "@/hooks/use-toast"; +import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; +import type { YardWorkBookingRow, YardWorkYard } from "@/types/trainScheduling"; + +const parseError = (error: unknown, fallback: string) => { + const message = (error as { response?: { data?: { message?: string | string[] } } }) + ?.response?.data?.message; + if (Array.isArray(message)) return message.join("; "); + return message || (error as Error)?.message || fallback; +}; + +const fmtDate = (iso: string) => { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); +}; + +const DIRECTION_COLORS: Record = { + IMPORT: "blue", + EXPORT: "teal", + DOMESTIC: "violet", +}; + +/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */ +const DIRECTION_LABELS: Record = Freight.TRADE_DIRECTION_LABELS; + +function DirectionChip({ direction }: { direction: string }) { + return ( + + {DIRECTION_LABELS[direction] ?? direction} + + ); +} + +function BookingCell({ row }: { row: YardWorkBookingRow }) { + return ( + + + {row.reference ?? row.id.slice(0, 8)} + + {row.isGovernment && ( + + GOV + + )} + + ); +} + +function WorkTable({ + rows, + side, + trainHere, + onLoad, + onUnload, + pendingBookingId, +}: { + rows: YardWorkBookingRow[]; + side: "load" | "unload"; + trainHere: boolean; + onLoad: (bookingId: string) => void; + onUnload: (bookingId: string) => void; + pendingBookingId: string | null; +}) { + if (rows.length === 0) { + return ( + + {side === "load" ? "No bookings board here." : "No bookings alight here."} + + ); + } + return ( + + + + + Booking + Customer + Direction + Status + {side === "load" ? "Loaded" : "Arrived"} + + + + + {rows.map((row) => { + const timestamp = side === "load" ? row.loadedAt : row.arrivedAt; + const canAct = side === "load" ? row.canLoad : row.canUnload; + return ( + + + + + + {row.customer} + + + + + + + + + {timestamp ? ( + + {fmtDate(timestamp)} + + ) : ( + + — + + )} + + + + {side === "load" ? ( + + + + ) : ( + + + + )} + + + + ); + })} + +
+
+ ); +} + +/** + * Per-yard load/unload worklist for one schedule — every trade direction. Each + * booking boards at its origin yard and alights at its destination yard; the + * operator confirms both while the train's last recorded checkpoint is at that + * yard (the server validates the position). Unloading stamps the booking's own + * arrival — ARRIVED for import/export, COMPLETED for intercity. + */ +export function YardWorkPanel({ scheduleId }: { scheduleId: string }) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + + const yardWorkQuery = useQuery( + api.trainScheduling.yardWork.queryOptions({ + input: { scheduleId }, + refetchInterval: 60_000, + }), + ); + + const invalidate = () => + queryClient.invalidateQueries({ + queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }), + }); + + const load = useMutation( + api.trainScheduling.loadScheduleBooking.mutationOptions({ + onSuccess: () => { + void invalidate(); + toast({ title: "Cargo loaded" }); + }, + onError: (err) => + toast({ + title: "Load failed", + description: parseError(err, "Could not confirm loading"), + variant: "destructive", + }), + }), + ); + + const unload = useMutation( + api.trainScheduling.unloadScheduleBooking.mutationOptions({ + onSuccess: (result) => { + void invalidate(); + toast({ + title: + result.status === "COMPLETED" + ? "Cargo unloaded — booking completed" + : "Cargo unloaded — booking arrived", + }); + }, + onError: (err) => + toast({ + title: "Unload failed", + description: parseError(err, "Could not confirm unloading"), + variant: "destructive", + }), + }), + ); + + const data = yardWorkQuery.data; + const yards: YardWorkYard[] = data?.yards ?? []; + const trainAtYardId = data?.trainAtYardId ?? null; + const pendingLoadId = load.isPending ? (load.variables?.bookingId ?? null) : null; + const pendingUnloadId = unload.isPending ? (unload.variables?.bookingId ?? null) : null; + + return ( + + + + + Yard load / unload + + + {yardWorkQuery.isLoading ? ( + + + + Loading yard worklists… + + + ) : yardWorkQuery.isError ? ( + }> + {parseError(yardWorkQuery.error, "Could not load the yard worklist")} + + ) : yards.length === 0 ? ( + + No bookings are assigned to this schedule yet. + + ) : ( + <> + + What boards and alights at each stop. Confirm loading at a booking's + origin and unloading at its destination while the train is at that + yard — unloading stamps the booking's own arrival, even before the + train's final stop. + + {yards.map((yard, index) => { + const trainHere = trainAtYardId === yard.yardId; + return ( + + {index > 0 && } + + {yard.yard} + {trainHere && ( + } + > + Train here + + )} + + + + Board here + + load.mutate({ scheduleId, bookingId })} + onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })} + pendingBookingId={pendingLoadId} + /> + + + + Alight here + + load.mutate({ scheduleId, bookingId })} + onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })} + pendingBookingId={pendingUnloadId} + /> + + + ); + })} + + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index a42c7c01b..05bb06372 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -326,6 +326,11 @@ 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`, + YARD_WORK: (id: string) => `/train-scheduling/schedules/${id}/yard-work`, + BOOKING_LOAD: (id: string, bookingId: string) => + `/train-scheduling/schedules/${id}/bookings/${bookingId}/load`, + BOOKING_UNLOAD: (id: string, bookingId: string) => + `/train-scheduling/schedules/${id}/bookings/${bookingId}/unload`, INTERCITY_CANDIDATES: (id: string) => `/train-scheduling/schedules/${id}/intercity-candidates`, INTERCITY_ACCEPT: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts index 127c8e708..25f4b51bc 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts @@ -66,6 +66,10 @@ export const BOOKING_STATUS_STYLES: Record = { label: "In Transit", color: "bg-sky-50 text-sky-700 border-sky-200", }, + ARRIVED: { + label: "Arrived", + color: "bg-emerald-50 text-emerald-700 border-emerald-200", + }, COMPLETED: { label: "Completed", color: "bg-indigo-50 text-indigo-700 border-indigo-200", @@ -208,6 +212,12 @@ export const BOOKING_STATUS_META: Record = { color: "text-sky-600", stage: 4, }, + ARRIVED: { + title: "Arrived", + description: "Cargo unloaded at its destination yard.", + color: "text-emerald-600", + stage: 4, + }, COMPLETED: { title: "Completed", description: "Booking fulfilled.", @@ -290,7 +300,7 @@ export const BOOKING_LIST_TABS = [ { key: "operations", label: "Operations", - statuses: ["PAID", "IN_TRANSIT", "ROAD_DISPATCH_PENDING"], + statuses: ["PAID", "IN_TRANSIT", "ARRIVED", "ROAD_DISPATCH_PENDING"], }, { key: "completed", label: "Completed", statuses: ["COMPLETED"] }, { key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] }, @@ -328,7 +338,7 @@ export const WORKFLOW_STAGES = [ }, { label: "Operations", - statuses: ["PAID", "IN_TRANSIT"], + statuses: ["PAID", "IN_TRANSIT", "ARRIVED"], }, { label: "Done", statuses: ["COMPLETED"] }, ] as const; 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 f7bee5b41..820424283 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -13,6 +13,7 @@ import FleetFormDialog from "@/components/fleet/FleetFormDialog"; import FleetHistoryModal from "@/components/fleet/FleetHistoryModal"; import FleetRecordActions from "@/components/fleet/FleetRecordActions"; import FleetToolbar from "@/components/fleet/FleetToolbar"; +import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal"; import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat"; import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; @@ -585,12 +586,20 @@ const FleetResourcePage = () => {
- setHistoryTarget(null)} - entity={slug === "vehicles" ? "vehicle" : "driver"} - record={historyTarget} - /> + {slug === "wagons" ? ( + setHistoryTarget(null)} + record={historyTarget} + /> + ) : ( + setHistoryTarget(null)} + entity={slug === "vehicles" ? "vehicle" : "driver"} + record={historyTarget} + /> + )} ); }; 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 2014f9c65..20b632710 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 @@ -92,6 +92,12 @@ const TRADE_DIRECTIONS = [ { label: "Both", value: "BOTH" }, ]; +// Mirrors the YardCountry enum in @edr/types — the only two countries on the line. +const YARD_COUNTRIES = [ + { label: "Ethiopia", value: "Ethiopia" }, + { label: "Djibouti", value: "Djibouti" }, +]; + const APPROVAL_ROLES = [ { label: "Line staff", value: "LINE_STAFF" }, { label: "Director", value: "DIRECTOR" }, @@ -431,7 +437,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ ], formFields: [ { name: "label", label: "Label", type: "text", required: true }, - { name: "country", label: "Country", type: "text", required: true }, + { + name: "country", + label: "Country", + type: "select", + required: true, + options: YARD_COUNTRIES, + }, { name: "isActive", label: "Active", type: "boolean" }, ], }, 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 7ee0f7896..ca2b7c39e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -49,6 +49,7 @@ import { import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid"; import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel"; +import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel"; // import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal"; @@ -1131,6 +1132,7 @@ export default function TrainScheduleV2DetailPage() { void detailQuery.refetch(); }} /> + {scheduleId ? : null} {scheduleId ? ( TRAIN_SCHEDULING_INVALIDATIONS, ), + yardWork: endpoint< + { scheduleId: string }, + import("@/types/trainScheduling").YardWorkResult + >( + "train-scheduling", + "yard-work", + ({ scheduleId }) => trainSchedulingService.getYardWork(scheduleId), + ({ scheduleId }) => ["train-scheduling", "yard-work", scheduleId], + ), + + loadScheduleBooking: endpoint< + { scheduleId: string; bookingId: string }, + import("@/types/trainScheduling").BookingLoadResult + >( + "train-scheduling", + "booking-load", + ({ scheduleId, bookingId }) => + trainSchedulingService.loadScheduleBooking(scheduleId, bookingId), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + unloadScheduleBooking: endpoint< + { scheduleId: string; bookingId: string }, + import("@/types/trainScheduling").BookingUnloadResult + >( + "train-scheduling", + "booking-unload", + ({ scheduleId, bookingId }) => + trainSchedulingService.unloadScheduleBooking(scheduleId, bookingId), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + intercityCandidates: endpoint< { scheduleId: string }, import("@/types/trainScheduling").IntercityCandidatesResult @@ -1493,6 +1528,13 @@ export const api = { wagonService.getById(id).then((r) => r.data), ), + movements: endpoint<{ id: string }, WagonMovementRecord[]>( + "wagons", + "movements", + ({ id }) => wagonService.getMovements(id).then((r) => r.data), + ({ id }) => ["wagons", "movements", id], + ), + assignToTrain: endpoint< { wagonId: string; trainId: string; sequenceNumber?: number }, Wagon 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 3f4c6822f..be05c6d8c 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -8,6 +8,8 @@ import type { BookableSchedule, BookingWindow, AssignBookingsPayload, + BookingLoadResult, + BookingUnloadResult, CompositionRemovalEntry, UnassignedBookingsResponse, CreateTrainSchedulePayload, @@ -35,6 +37,7 @@ import type { UploadImportDjiboutiDocumentPayload, WagonAllocationAttemptResult, YardOption, + YardWorkResult, } from "@/types/trainScheduling"; interface BookingReferenceDataResponse { @@ -330,6 +333,35 @@ export const trainSchedulingService = { return unwrap(response.data); }, + getYardWork: async (scheduleId: string): Promise => { + const response = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.YARD_WORK(scheduleId), + ); + return unwrap(response.data); + }, + + loadScheduleBooking: async ( + scheduleId: string, + bookingId: string, + ): Promise => { + const response = await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_LOAD(scheduleId, bookingId), + {}, + ); + return unwrap(response.data); + }, + + unloadScheduleBooking: async ( + scheduleId: string, + bookingId: string, + ): Promise => { + const response = await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_UNLOAD(scheduleId, bookingId), + {}, + ); + return unwrap(response.data); + }, + getIntercityCandidates: async ( scheduleId: string, ): Promise => { diff --git a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts index da7b2b509..a200195e0 100644 --- a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts @@ -37,6 +37,27 @@ export interface WagonListFilters { trainId?: string; } +/** + * One row of the wagon_movements ledger: every physical relocation between + * yards — a booking's loaded leg, an empty reposition ride, or a manual staff + * correction. Returned newest first by the API. + */ +export interface WagonMovementRecord { + id: string; + wagonId: string; + fromYardId: string | null; + toYardId: string; + fromYard?: { id?: string; label?: string; code?: string } | null; + toYard?: { id?: string; label?: string; code?: string } | null; + trainScheduleId: string | null; + bookingId: string | null; + kind: Freight.WagonMovementKind; + movedByUserId: string | null; + occurredAt: string; + note: string | null; + createdAt: string; +} + export const wagonService = { getAll: (filters: WagonListFilters = {}) => { const params = new URLSearchParams(); @@ -49,6 +70,8 @@ export const wagonService = { return apiClient.get(`/wagons${qs ? `?${qs}` : ''}`); }, getById: (id: string) => apiClient.get(`/wagons/${id}`), + getMovements: (id: string) => + apiClient.get(`/wagons/${id}/movements`), getByTrain: (trainId: string) => apiClient.get(`/wagons?trainId=${trainId}`), assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) => apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }), diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index 2c0be4726..41c4a155a 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -19,6 +19,7 @@ export const BOOKING_STATUSES = [ "PAYMENT_VERIFICATION_IN_PROGRESS", "PAID", "IN_TRANSIT", + "ARRIVED", "COMPLETED", "REJECTED", "CANCELLED", diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index a67f1732f..e92d75c89 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -118,6 +118,7 @@ export type CustomerBookingStatus = | "APPROVED" | "PAID" | "IN_TRANSIT" + | "ARRIVED" | "COMPLETED" | "REJECTED" | "CANCELLED"; diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 395a8c5de..5e3ab1b0e 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -796,3 +796,52 @@ export interface IntercityAcceptResult { rejected: Array<{ bookingId: string; reason: string }>; remaining: IntercityCapacity; } + +// ── Yard load / unload worklist ────────────────────────────────────────────── +// Per-booking journey along the train's corridor: every booking boards at its +// origin yard and alights at its destination yard, confirmed by the yard +// operator while the train's latest checkpoint is at that yard. + +export interface YardWorkBookingRow { + id: string; + reference: string | null; + status: string; + tradeDirection: string; + isGovernment: boolean; + customer: string; + originYardId: string; + destinationYardId: string; + origin: string; + destination: string; + loadedAt: string | null; + arrivedAt: string | null; + canLoad: boolean; + canUnload: boolean; +} + +export interface YardWorkYard { + yardId: string; + yard: string; + toLoad: YardWorkBookingRow[]; + toUnload: YardWorkBookingRow[]; +} + +export interface YardWorkResult { + scheduleId: string; + scheduleStatus: string; + trainAtYardId: string | null; + yards: YardWorkYard[]; +} + +export interface BookingLoadResult { + bookingId: string; + status: string; + loadedAt: string; +} + +export interface BookingUnloadResult { + bookingId: string; + /** 'ARRIVED' for import/export, 'COMPLETED' for intercity. */ + status: string; + arrivedAt: string; +} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx index a65aa009e..ea06ae3e5 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx @@ -17,13 +17,15 @@ export const ActivityRow = memo(function ActivityRow({ const verb = booking.status === "IN_TRANSIT" ? "departed" - : booking.status === "COMPLETED" - ? "delivered" - : booking.status === "PENDING_APPROVAL" - ? "quote ready" - : booking.status === "SUBMITTED" - ? "submitted for review" - : "created"; + : booking.status === "ARRIVED" + ? "arrived" + : booking.status === "COMPLETED" + ? "delivered" + : booking.status === "PENDING_APPROVAL" + ? "quote ready" + : booking.status === "SUBMITTED" + ? "submitted for review" + : "created"; return ( = { badgeDot: "edr-green.5", action: { label: "Track", kind: "outline", icon: MapPin }, }, + ARRIVED: { + stage: 3, + icon: MapPin, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Arrived at destination yard · awaiting release", + step: "edr-green.5", + badgeLabel: "Arrived", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "Track", kind: "outline", icon: MapPin }, + }, COMPLETED: { stage: 4, icon: CheckCircle2, 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 6d274f22f..40994d589 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 @@ -118,7 +118,9 @@ export function ReadonlyBookingView({ const canAssignCustomerTruck = booking.paymentStatus === "PAID" && usesCustomerTruck && - ["PAID", "IN_TRANSIT", "COMPLETED", "TRUCK_ASSIGNED"].includes(status); + ["PAID", "IN_TRANSIT", "ARRIVED", "COMPLETED", "TRUCK_ASSIGNED"].includes( + status, + ); const showCountdown = canPay && !!booking.paymentDeadline; const isExpired = status === "EXPIRED"; const isPendingConsolidation = status === "PENDING_CONSOLIDATION"; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx index 127901186..13404f8f6 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx @@ -66,10 +66,12 @@ export function StatusHero({ }) { const status = booking.status; const stage = resolveStage(booking); - // The Arrival stage has no booking status of its own — it lights up from the - // train's ARRIVED state, so the headline is overridden here. + // Legacy bookings never reach the ARRIVED status — they light up the Arrival + // stage from the train's ARRIVED state while staying IN_TRANSIT, so the + // headline is overridden here. Bookings with a per-booking journey carry the + // ARRIVED status themselves and use its own STATUS_MAP copy. const cfg = - stage === ARRIVAL_STAGE + stage === ARRIVAL_STAGE && STATUS_MAP[status]?.stage !== ARRIVAL_STAGE ? { title: "Train arrived at destination", description: diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts index 2a41a6fa3..4b32a059c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts @@ -54,12 +54,13 @@ export const PROGRESS_STAGES = [ statuses: ["EXPIRED", "IN_TRANSIT"], }, { - // No booking status maps here: the booking stays IN_TRANSIT until - // delivery, so this stage lights up from the assigned train's own status + // ARRIVED: cargo unloaded at the booking's own destination yard (segment + // corridor journeys). Legacy bookings stay IN_TRANSIT until delivery, so + // this stage also lights up from the assigned train's own status // (trainScheduleStatus === "ARRIVED") — see resolveStage. label: "Arrival", icon: MapPin, - statuses: [], + statuses: ["ARRIVED"], }, { label: "Complete", @@ -75,8 +76,10 @@ export const ARRIVAL_STAGE = PROGRESS_STAGES.findIndex( /** * Stage for a booking, factoring in the assigned train's operational status: - * a booking is stuck at IN_TRANSIT between dispatch and delivery, so once its - * train has ARRIVED the tracker advances to the Arrival stage. + * a booking with per-booking journey data reaches ARRIVED when it is unloaded + * at its own destination yard; a legacy booking is stuck at IN_TRANSIT between + * dispatch and delivery, so once its train has ARRIVED the tracker advances to + * the Arrival stage. */ export function resolveStage(booking: { status: string; @@ -177,6 +180,12 @@ export const STATUS_MAP: Record< description: "Your shipment is currently moving through the rail network.", stage: 6, }, + ARRIVED: { + title: "Arrived at destination", + description: + "Your cargo has been unloaded at its destination yard and is being prepared for release.", + stage: 7, + }, OPERATION_REQUEST_PENDING: { title: "Operation request under review", description: diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx index 4a7643377..bd68632f7 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx @@ -58,6 +58,7 @@ import { const TRACKABLE_STATUSES = new Set([ "PAID", "IN_TRANSIT", + "ARRIVED", "COMPLETED", "DELIVERED", ]); @@ -83,7 +84,7 @@ const STATUS_FILTERS = [ statuses: "SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED", }, - { key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" }, + { key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT,ARRIVED" }, { key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" }, { key: "closed", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/tracking/ShipmentTrackingModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/tracking/ShipmentTrackingModal.tsx index 64ee63d5e..4282c7e81 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/tracking/ShipmentTrackingModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/tracking/ShipmentTrackingModal.tsx @@ -6,6 +6,7 @@ import { Clock, Flag, MapPin, + PackageCheck, PackageX, RefreshCw, Train, @@ -15,11 +16,14 @@ import { api } from "@/services/api"; import { Freight } from "@edr/types"; import { + bookingJourneyState, + bookingLegRange, + bookingShipmentStatusLabel, checkpointKindLabel, corridorProgress, isArrived, isDispatched, - shipmentStatusLabel, + type BookingJourneyState, } from "./trackingStages"; const GREEN = "#0EA371"; @@ -70,6 +74,7 @@ export function ShipmentTrackingModal({ trainNumber={data?.trainNumber ?? null} status={data?.scheduleStatus ?? null} currentSequenceNo={data?.currentSequenceNo ?? -1} + journey={data ? bookingJourneyState(data) : null} onClose={onClose} onRefresh={() => refetch()} refreshing={isFetching} @@ -95,6 +100,7 @@ export function ShipmentTrackingModal({ ) : data ? ( + @@ -111,6 +117,7 @@ function Header({ trainNumber, status, currentSequenceNo, + journey, onClose, onRefresh, refreshing, @@ -119,6 +126,7 @@ function Header({ trainNumber: string | null; status: Freight.TrainScheduleStatus | null; currentSequenceNo: number; + journey: BookingJourneyState; onClose: () => void; onRefresh: () => void; refreshing: boolean; @@ -171,7 +179,11 @@ function Header({ - + @@ -223,12 +235,17 @@ function IconButton({ function HeaderStatusPill({ status, currentSequenceNo, + journey, }: { status: Freight.TrainScheduleStatus | null; currentSequenceNo: number; + journey: BookingJourneyState; }) { - const arrived = isArrived(status); - const moving = isDispatched(status); + // The booking's own journey wins: a sub-corridor booking can be unloaded + // (arrived) at its own yard while the train is still moving. + const arrived = journey === "arrived" || (!journey && isArrived(status)); + const moving = !arrived && (journey === "in-transit" || isDispatched(status)); + const label = bookingShipmentStatusLabel(journey, status, currentSequenceNo); const bg = arrived ? "rgba(14,163,113,0.22)" : moving @@ -254,7 +271,7 @@ function HeaderStatusPill({ }} /> - {shipmentStatusLabel(status, currentSequenceNo)} + {label} ); @@ -263,7 +280,10 @@ function HeaderStatusPill({ // ── Summary bar (ETA / departure / arrival) ──────────────────────────────────── function SummaryBar({ data }: { data: Freight.IBookingTracking }) { - const arrived = isArrived(data.scheduleStatus); + const journey = bookingJourneyState(data); + // Booking-level arrival (unloaded at its own destination yard) counts as + // arrived even while the train itself is still moving down the corridor. + const arrived = journey === "arrived" || isArrived(data.scheduleStatus); const items: Array<{ label: string; value: string; accent?: boolean }> = [ { label: "Departed", @@ -271,7 +291,11 @@ function SummaryBar({ data }: { data: Freight.IBookingTracking }) { }, { label: arrived ? "Arrived" : "Est. arrival", - value: fmtTime(data.actualArrivalAt ?? data.scheduledArrivalAt), + value: fmtTime( + (journey === "arrived" ? data.arrivedAt : null) ?? + data.actualArrivalAt ?? + data.scheduledArrivalAt, + ), accent: !arrived, }, { @@ -317,6 +341,66 @@ function SummaryBar({ data }: { data: Freight.IBookingTracking }) { ); } +// ── Per-booking journey line (loaded / unloaded at the booking's own yards) ──── + +function BookingJourneyLine({ data }: { data: Freight.IBookingTracking }) { + if (!data.loadedAt && !data.arrivedAt) return null; + + const stationLabel = (yardId?: string | null) => + data.stations.find((s) => s.yardId === yardId)?.label ?? null; + const origin = stationLabel(data.bookingOriginYardId) ?? "origin yard"; + const destination = + stationLabel(data.bookingDestinationYardId) ?? "destination yard"; + + return ( + + {data.loadedAt && ( + } + text={`Loaded at ${origin}`} + time={fmtTime(data.loadedAt)} + /> + )} + {data.arrivedAt && ( + } + text={`Arrived at ${destination}`} + time={fmtTime(data.arrivedAt)} + /> + )} + + ); +} + +function JourneyChip({ + icon, + text, + time, +}: { + icon: React.ReactNode; + text: string; + time: string; +}) { + return ( + + {icon} + + {text} + + + · {time} + + + ); +} + // ── Corridor: stations + train marker ────────────────────────────────────────── function Corridor({ data }: { data: Freight.IBookingTracking }) { @@ -326,6 +410,14 @@ function Corridor({ data }: { data: Freight.IBookingTracking }) { const current = data.currentSequenceNo; const progress = corridorProgress(stations.length, current, arrived); + // The booking's own leg on the corridor (sub-corridor bookings ride only a + // slice of the train's route). Stations outside the leg render dimmed. + const leg = bookingLegRange( + stations, + data.bookingOriginYardId, + data.bookingDestinationYardId, + ); + // Map sequenceNo → latest checkpoint at that station for captions. const checkpointBySeq = new Map(); for (const c of data.checkpoints) checkpointBySeq.set(c.sequenceNo, c); @@ -415,6 +507,7 @@ function Corridor({ data }: { data: Freight.IBookingTracking }) { const reached = arrived || (current >= 0 && i <= current); const isCurrent = !arrived && i === current; const isLast = i === stations.length - 1; + const onLeg = !leg || (i >= leg.start && i <= leg.end); const cp = checkpointBySeq.get(s.sequenceNo); return ( @@ -441,6 +535,7 @@ function StationNode({ isCurrent, isEndpoint, arrivedHere, + dimmed, time, align, }: { @@ -449,6 +544,8 @@ function StationNode({ isCurrent: boolean; isEndpoint: boolean; arrivedHere: boolean; + /** Station lies outside the booking's own leg — render muted. */ + dimmed: boolean; time: string | null; align: "left" | "center" | "right"; }) { @@ -462,6 +559,7 @@ function StationNode({ flex: isEndpoint ? "0 0 auto" : 1, minWidth: 0, maxWidth: 120, + opacity: dimmed ? 0.4 : 1, }} > s.yardId === originYardId); + const end = stations.findIndex((s) => s.yardId === destinationYardId); + if (start < 0 || end < 0) return null; + return start <= end ? { start, end } : { start: end, end: start }; +} + /** Caption for a checkpoint kind. */ export function checkpointKindLabel(kind: Freight.TrainCheckpointKind): string { switch (kind) { diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx index 0c63f1b95..f9b3c3420 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx @@ -13,12 +13,15 @@ import { import { ArrowRight, CalendarClock, + CheckCircle2, ChevronLeft, ChevronRight, + Clock, } from "lucide-react"; import { CountdownTimer } from "@edr/ui-common"; import type { MyBookingWindow } from "@/services/bookings.service"; +import { formatWindowOpensAt, soonestUpcomingWindow } from "./booking-window"; const INK = "#10202F"; const MUTED = "#6B7C8E"; @@ -209,6 +212,65 @@ function WindowCard({ w }: { w: MyBookingWindow }) { ); } +/** + * One-line status strip above the cards: green when a window is open right now + * (the customer can act), neutral with the next opening time otherwise. + */ +function WindowStatusBanner({ windows }: { windows: MyBookingWindow[] }) { + const open = windows.find((w) => w.isOpenNow); + if (open) { + const lane = + open.origin && open.destination + ? ` on ${open.origin} → ${open.destination}` + : ""; + return ( + + + + A booking window is open right now{lane} — you can create a shipment + booking before it closes. + + + ); + } + + const next = soonestUpcomingWindow(windows); + return ( + + + + {next?.windowOpensAt + ? `Booking is not open yet — the next window opens ${formatWindowOpensAt( + next.windowOpensAt, + )} EAT.` + : "Booking is not open right now. You'll see the opening time here once a window is announced."} + + + ); +} + interface ContractBookingWindowsSectionProps { /** Windows already scoped to this contract's routes/direction by the API. */ windows: MyBookingWindow[]; @@ -248,8 +310,6 @@ export function ContractBookingWindowsSection({ safePage * PER_PAGE + PER_PAGE, ); - if (!isLoading && sorted.length === 0) return null; - return ( @@ -313,12 +373,39 @@ export function ContractBookingWindowsSection({ ))} + ) : sorted.length === 0 ? ( + + + + No booking windows announced yet + + + When a train is scheduled on this contract's routes, its + booking window will appear here with the opening time. + + ) : ( - - {visible.map((w) => ( - - ))} - + <> + + + {visible.map((w) => ( + + ))} + + )} ); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index c3c7710e6..24991d94a 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -507,23 +507,17 @@ export default function NewContractPage({ const isContainer = data.cargoType === "container"; // Cargo scope rows — no quantities (doc §5.4). Container: one row per enabled - // size; bulk: a single commodity row. - // GENERAL contracts carry a quantity cap (draw-down); ONE_TIME does not. - const isGeneral = data.contractKind === "general_contract"; + // size; bulk: a single commodity row. Both GENERAL and ONE_TIME are uncapped + // (quantityCap omitted → NULL): the customer books repeatedly against a + // GENERAL contract until its validity expires. const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer ? data.enabledContainerSizes.map((size) => ({ containerSize: size, - quantityCap: - isGeneral && data.containerSizeCaps[size] - ? data.containerSizeCaps[size] - : undefined, })) : [ { cargoTypeId: data.cargoTypePath?.[1] || undefined, cargoFreeText: data.cargoFreeText || undefined, - quantityCap: - isGeneral && data.bulkQuantityCap ? data.bulkQuantityCap : undefined, }, ]; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx index 4d0dd3564..565ca2079 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx @@ -156,6 +156,7 @@ export const CONTRACT_STATUS_CONFIG: Record< PNR_GENERATED: { label: "Payment Reference Ready", ...TONE.warning }, PAID: { label: "Paid", ...TONE.success }, IN_TRANSIT: { label: "In Transit", ...TONE.info }, + ARRIVED: { label: "Arrived", ...TONE.success }, COMPLETED: { label: "Completed", ...TONE.success }, }; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/payment-currency-field.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/payment-currency-field.tsx index 06f67f7fc..d53b64552 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/payment-currency-field.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/payment-currency-field.tsx @@ -9,23 +9,27 @@ import { fieldStyles } from "./shared"; export function PaymentCurrencyField({ control, + etbOnly = false, }: { control: Control; + /** Intercity (domestic) contracts are priced in ETB only. */ + etbOnly?: boolean; }) { + const options = etbOnly + ? PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value === "ETB") + : PAYMENT_CURRENCY_OPTIONS; return ( { - const selected = PAYMENT_CURRENCY_OPTIONS.find( - (o) => o.value === field.value, - ); + const selected = options.find((o) => o.value === field.value); return (
setOriginFilter(v ?? "ALL")} + data={[ + { value: "ALL", label: "All origins" }, + ...originOptions.map((o) => ({ value: o, label: o })), + ]} + w={160} + styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} + /> + { + if (!v) return; + const [by, dir] = v.split(":") as [ + typeof sortBy, + typeof sortDir, + ]; + setSortBy(by); + setSortDir(dir); + }} + data={[ + { value: "createdAt:desc", label: "Newest created" }, + { value: "createdAt:asc", label: "Oldest created" }, + { value: "scheduleDate:desc", label: "Departure ↓" }, + { value: "scheduleDate:asc", label: "Departure ↑" }, + { value: "reference:asc", label: "Reference ↑" }, + { value: "reference:desc", label: "Reference ↓" }, + ]} + w={170} + styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} + /> } /> @@ -693,9 +805,16 @@ function ScheduleCard({ - - {schedule.routeName ?? "Train schedule"} - + + {schedule.reference ? ( + + {schedule.reference} + + ) : null} + + {schedule.routeName ?? "Train schedule"} + + {day} · {time} diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 29190846d..2f0422c6f 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -152,6 +152,8 @@ export interface LocomotiveRecord { export interface TrainScheduleListItem { id: string; + reference?: string | null; + createdAt?: string | null; scheduleDate: string; trainNumber?: string | null; routeName?: string | null; @@ -237,6 +239,7 @@ export interface BatchBoardBooking { */ export interface StaffBookingWindow { scheduleId: string; + reference: string | null; trainNumber: string | null; direction: "IMPORT" | "EXPORT" | null; windowPhase: BookingWindowPhase | string | null; @@ -432,6 +435,7 @@ export interface UpdateScheduleWindowRulePayload { export interface TrainScheduleDetail { id: string; + reference?: string | null; status: TrainScheduleStatus | string; deferredBookings?: DeferredBookingRow[]; freightType?: FreightType | null; From 3f6b9ac97470a2665b1c532857b883eb2fdcf043 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 8 Jul 2026 04:36:03 +0000 Subject: [PATCH 11/34] Container truck assignment on customer portal for import --- ...2020000000000-WarehouseCapacityKgToTons.ts | 35 ++++++++++++ .../bookings/customer-truck.service.ts | 56 +++++++++++++------ .../dto/create-warehouse-yard.dto.ts | 2 +- .../dto/create-warehouse-zone.dto.ts | 2 +- .../warehouses/dto/create-warehouse.dto.ts | 2 +- .../warehouses/dto/load-inventory.dto.ts | 2 +- .../warehouse-inspection.service.ts | 2 +- .../warehouses/warehouse-inventory.service.ts | 34 +++++------ .../warehouses/CreateWarehouseModal.tsx | 2 +- .../components/warehouses/CreateYardModal.tsx | 2 +- .../components/warehouses/CreateZoneModal.tsx | 2 +- .../warehouses/InspectionReportModal.tsx | 4 +- .../warehouses/InventoryDetailModal.tsx | 2 +- .../InventoryInquiryDetailModal.tsx | 2 +- .../warehouses/LoadInventoryModal.tsx | 2 +- .../warehouses/LoadToTrainPanel.tsx | 2 +- .../warehouses/ReceiveInventoryModal.tsx | 6 +- .../warehouses/ReleaseOrderModal.tsx | 10 ++-- .../pages/warehouses/LoadedInventoryPage.tsx | 2 +- .../src/pages/warehouses/LoadingQueuePage.tsx | 2 +- .../CustomerTruckAssignmentCard.tsx | 39 ++++++------- 21 files changed, 133 insertions(+), 79 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2020000000000-WarehouseCapacityKgToTons.ts diff --git a/apps/edr-freight-api/src/migrations/2020000000000-WarehouseCapacityKgToTons.ts b/apps/edr-freight-api/src/migrations/2020000000000-WarehouseCapacityKgToTons.ts new file mode 100644 index 000000000..333a0f541 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2020000000000-WarehouseCapacityKgToTons.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Weights are tonnes everywhere. Warehouse / yard / zone capacity was stored in + * kg (e.g. 25000, 5000, 2500) — convert existing rows to tonnes (÷1000). Cargo + * weight (warehouse_inventory.weight ← cargo_total_weight_vgm) is already tonnes + * and is NOT touched; truck gross weight has no data yet. Runs exactly once + * (tracked by TypeORM) — re-running would divide again. + */ +export class WarehouseCapacityKgToTons2020000000000 implements MigrationInterface { + name = 'WarehouseCapacityKgToTons2020000000000'; + + private readonly tables = ['warehouses', 'warehouse_yards', 'warehouse_zones']; + private readonly columns = ['capacity_weight', 'current_weight', 'max_weight']; + + public async up(queryRunner: QueryRunner): Promise { + for (const table of this.tables) { + for (const column of this.columns) { + await queryRunner.query( + `UPDATE freight.${table} SET ${column} = ${column} / 1000.0 WHERE ${column} IS NOT NULL`, + ); + } + } + } + + public async down(queryRunner: QueryRunner): Promise { + for (const table of this.tables) { + for (const column of this.columns) { + await queryRunner.query( + `UPDATE freight.${table} SET ${column} = ${column} * 1000.0 WHERE ${column} IS NOT NULL`, + ); + } + } + } +} 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 6f0ec6f55..43c14c7f6 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 @@ -42,16 +42,16 @@ export class CustomerTruckService { const booking = await this.loadBookingGuard(bookingId); this.assertSelfHaulPaid(booking); - 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) { + // Both import and export specify the containers each truck carries. Capacity + // is size-based: a 40ft container fills the truck (max 1); two 20ft containers + // fit (max 2), no size mixing. #trucks <= #containers follows naturally since + // each container is assigned to exactly one truck. + if (requested.length < 1) { + throw new BadRequestException('Select at least one container for this truck'); + } + if (requested.length > 2) { throw new BadRequestException('A truck carries at most 2 containers'); } @@ -68,6 +68,13 @@ export class CustomerTruckService { throw new ConflictException(`Container ${n} is already loaded onto another truck`); } } + // Size cap: a 40ft container fills the truck. + const sizes = await this.containerSizes(bookingId, requested); + if (sizes.some((s) => s.includes('40')) && requested.length > 1) { + throw new BadRequestException( + 'A 40ft container fills the truck — assign only 1 container to this truck', + ); + } } await this.dataSource.transaction(async (manager) => { @@ -244,7 +251,7 @@ export class CustomerTruckService { } } - const grossKg = await this.vgmKgForContainers(bookingId, requested); + const grossTons = await this.vgmTonsForContainers(bookingId, requested); await this.dataSource.transaction(async (manager) => { await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); await manager.getRepository(CustomerTruckContainer).save( @@ -256,18 +263,19 @@ export class CustomerTruckService { }), ), ); - // Provisional gross from the loaded containers' VGM — overridden by the - // weighed gross on departure. + // Provisional gross (tonnes) from the loaded containers' VGM — overridden + // by the weighed gross on departure. (Column is *_kg but holds tonnes.) await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { - grossWeightKg: grossKg, + grossWeightKg: grossTons, }); }); return this.listTrucks(bookingId); } - private async vgmKgForContainers(bookingId: string, numbers: string[]): Promise { - const [row]: Array<{ kg: string }> = await this.dataSource.query( - `SELECT COALESCE(SUM(bcu.vgm_tons), 0) * 1000 AS kg + /** Summed VGM (tonnes) of the given containers — provisional truck gross. */ + private async vgmTonsForContainers(bookingId: string, numbers: string[]): Promise { + const [row]: Array<{ tons: string }> = await this.dataSource.query( + `SELECT COALESCE(SUM(bcu.vgm_tons), 0) AS tons FROM freight.booking_container_units bcu JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL @@ -276,7 +284,7 @@ export class CustomerTruckService { AND bcu.deleted_at IS NULL`, [bookingId, numbers], ); - return Number(row?.kg ?? 0); + return Number(row?.tons ?? 0); } /** @@ -399,4 +407,20 @@ export class CustomerTruckService { ); return rows.map((r) => r.containerNumber.trim().toUpperCase()); } + + /** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */ + private async containerSizes(bookingId: string, numbers: string[]): Promise { + if (!numbers.length) return []; + const rows: Array<{ size: string | null }> = await this.dataSource.query( + `SELECT bc.container_size AS "size" + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND UPPER(bcu.container_number) = ANY($2) + AND bcu.deleted_at IS NULL`, + [bookingId, numbers], + ); + return rows.map((r) => (r.size ?? '').trim()); + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts index ccdda90d8..56b9d0810 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts @@ -35,7 +35,7 @@ export class CreateWarehouseYardDto { @Min(0) capacityContainers?: number; - @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' }) + @ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' }) @IsOptional() @IsNumber() @Min(0) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts index fbb057fd5..eb29f751f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts @@ -35,7 +35,7 @@ export class CreateWarehouseZoneDto { @Min(0) capacityContainers?: number; - @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' }) + @ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' }) @IsOptional() @IsNumber() @Min(0) 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 5a8025948..900a9d8ac 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 @@ -47,7 +47,7 @@ export class CreateWarehouseDto { @Min(0) capacityContainers?: number; - @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' }) + @ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' }) @IsOptional() @IsNumber() @Min(0) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts index 063bb7d1d..c81550cd0 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts @@ -6,7 +6,7 @@ export class LoadInventoryDto { @IsUUID() wagonId!: string; - @ApiPropertyOptional({ description: 'Weight loaded onto the wagon (kg)' }) + @ApiPropertyOptional({ description: 'Weight loaded onto the wagon (t)' }) @IsOptional() @IsNumber() @Min(0) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts index ff25258d3..0a0c56821 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts @@ -44,7 +44,7 @@ export class WarehouseInspectionService { expectedWeight: expected, actualWeight: actual, weightLoss, - weightLossUnit: weightLoss !== null ? 'kg' : null, + weightLossUnit: weightLoss !== null ? 't' : null, hasMissingItems: dto.hasMissingItems ?? false, missingItemsDescription: dto.missingItemsDescription ?? null, remarks: dto.remarks ?? null, 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 bf7139d72..48b767a09 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 @@ -2020,7 +2020,7 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_RECEIVED', inventoryId: saved.id, warehouseId: dto.warehouseId, - description: `GRN ${grnNumber}: received ${weight}kg via truck ${truckEntrance.truckPlateNumber}`, + description: `GRN ${grnNumber}: received ${weight}t via truck ${truckEntrance.truckPlateNumber}`, performedBy: dto.performedBy, }, manager, @@ -2677,7 +2677,7 @@ export class WarehouseInventoryService { ['Pickup Truck Plate', data.plateNumber], ['Driver', data.driverName], ['Truck Type', data.truckType], - ['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} kg`], + ['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} t`], ['Gate-Out Time', gateOut], ['Clearance Status', 'CLEARED FOR WAREHOUSE EXIT'], ]; @@ -3608,8 +3608,8 @@ export class WarehouseInventoryService { ['Booking Containers', data.bookingContainerSummary], ['Cargo / Goods Description', data.cargoDescription], ['Quantity', data.quantity], - ['Received Weight', `${data.weight.toLocaleString()} kg`], - ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null], + ['Received Weight', `${data.weight.toLocaleString()} t`], + ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null], ['Volume', data.volume == null ? null : data.volume.toLocaleString()], ['Warehouse', data.warehouse], ['Yard', data.yard], @@ -3731,7 +3731,7 @@ export class WarehouseInventoryService { `${(data.truckPlateNumber && data.truckWeightKg ? data.truckWeightKg : data.weight - ).toLocaleString()} kg`, + ).toLocaleString()} t`, ], ['Warehouse', data.warehouse], ['Yard', data.yard], @@ -3894,8 +3894,8 @@ export class WarehouseInventoryService { ['Booking Containers', data.bookingContainerSummary], ['Cargo / Goods Description', data.cargoDescription], ['Quantity', data.quantity], - ['Inventory Weight', `${data.weight.toLocaleString()} kg`], - ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null], + ['Inventory Weight', `${data.weight.toLocaleString()} t`], + ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null], ['Warehouse', data.warehouse], ['Yard', data.yard], ['Zone', data.zone], @@ -3968,8 +3968,8 @@ export class WarehouseInventoryService { 1. Goods${esc(data.cargoDescription || data.containerNumber || data.bookingReference)} Container${esc(data.containerNumber)} Booking Containers${esc(data.bookingContainerSummary)} - Inventory Weight${esc(`${data.weight.toLocaleString()} kg`)} - Booking Declared Weight${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null)} + Inventory Weight${esc(`${data.weight.toLocaleString()} t`)} + Booking Declared Weight${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null)}
Handover Clause
@@ -4303,9 +4303,9 @@ export class WarehouseInventoryService { dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null, dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null, dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null, - `Tare Weight: ${tareWeight} kg`, - grossWeight == null ? null : `Gross Weight: ${grossWeight} kg`, - computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} kg`, + `Tare Weight: ${tareWeight} t`, + grossWeight == null ? null : `Gross Weight: ${grossWeight} t`, + computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`, dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null, ]; @@ -4357,7 +4357,7 @@ export class WarehouseInventoryService { } private extractExitInspectionNumber(note: string | null | undefined, label: string): number | undefined { - const value = this.extractExitInspectionLine(note, label)?.replace(/\s*kg$/i, ''); + const value = this.extractExitInspectionLine(note, label)?.replace(/\s*(kg|t)$/i, ''); if (!value) return undefined; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : undefined; @@ -4420,9 +4420,9 @@ export class WarehouseInventoryService { truck?.driverName ? `Driver: ${truck.driverName}` : null, truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null, truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null, - truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg` : null, + truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} t` : null, truck?.weighingRequired !== undefined ? `Weighing Required: ${truck.weighingRequired ? 'Yes' : 'No'}` : null, - truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null, + truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} t` : null, truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null, truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null, truck?.hsCodes ? `HS Codes: ${truck.hsCodes}` : null, @@ -4430,8 +4430,8 @@ export class WarehouseInventoryService { truck?.itemDescription ? `Item Description: ${truck.itemDescription}` : null, truck?.packagingType ? `Packaging Type: ${truck.packagingType}` : null, truck?.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null, - truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null, - truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null, + truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} t` : null, + truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} t` : null, truck?.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null, truck?.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null, truck?.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null, 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 6494906b4..b1afff27b 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx @@ -169,7 +169,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh setExpectedWeight(v === '' ? '' : Number(v))} /> setActualWeight(v === '' ? '' : Number(v))} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx index c888253f1..e76baa6f0 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx @@ -78,7 +78,7 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM - + diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryInquiryDetailModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryInquiryDetailModal.tsx index 479249894..906ba4b47 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryInquiryDetailModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryInquiryDetailModal.tsx @@ -67,7 +67,7 @@ export function InventoryInquiryDetailModal({ opened, onClose, result }: Invento - + diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/LoadInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/LoadInventoryModal.tsx index 7fc43c0d5..f0166714c 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/LoadInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/LoadInventoryModal.tsx @@ -67,7 +67,7 @@ export function LoadInventoryModal({ opened, onClose, item }: LoadInventoryModal = { LOADED: 'green', }; -const weight = (w: number | null) => (w == null ? '—' : `${Number(w).toLocaleString()} kg`); +const weight = (w: number | null) => (w == null ? '—' : `${Number(w).toLocaleString()} t`); interface BookingGroup { bookingId: string | null; diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 3e3877236..822f350a2 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -520,14 +520,14 @@ function TruckEntranceFields({ {value.weighingRequired && ( onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })} /> onChange({ ...value, netWeightKg: v === '' ? '' : Number(v) })} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index 0471d51c6..1bf371e5d 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -78,7 +78,7 @@ const lineValue = (notes: string | null | undefined, label: string) => { }; const lineNumber = (notes: string | null | undefined, label: string): number | '' => { - const value = lineValue(notes, label).replace(/\s*kg$/i, ''); + const value = lineValue(notes, label).replace(/\s*(kg|t)$/i, ''); if (!value) return ''; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : ''; @@ -397,13 +397,13 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} /> - setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} /> - setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} /> - + setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} /> + setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} /> + - Computed net: {computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} kg`} + Computed net: {computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`} setGateOutTime(e.currentTarget.value)} disabled={!isExitStep} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx index b5deec80e..7dfc1996c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx @@ -38,7 +38,7 @@ const columns: ColumnDef[] = [ }, { id: 'weight', - header: 'Loaded Weight (kg)', + header: 'Loaded Weight (t)', cell: ({ row }) => formatNumber(row.original.loadedWeight), }, { diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx index 652a26863..e9bb3a162 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx @@ -203,7 +203,7 @@ function PendingPaymentTable({ items, onNavigate }: PendingPaymentTableProps) { }, { id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse?.code ?? '—' }, { id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.code ?? '—' }, - { id: 'weight', header: 'Weight (kg)', cell: ({ row }) => formatNumber(row.original.weight) }, + { id: 'weight', header: 'Weight (t)', cell: ({ row }) => formatNumber(row.original.weight) }, { id: 'payment', header: 'Payment', 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 65af584d3..67840c133 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,10 +73,7 @@ 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"; - + // Both import and export specify the containers each truck carries. const resetForm = () => { setPlateNumber(""); setDriverName(""); @@ -91,8 +88,7 @@ export function CustomerTruckAssignmentCard({ truckPlateNumber: plateNumber.trim().toUpperCase(), driverName: driverName.trim(), truckType: truckType.trim(), - // Import: containers are registered + weighed on departure, not here. - containerNumbers: isExport ? containers : [], + containerNumbers: containers, }), onSuccess: (list) => { queryClient.setQueryData(trucksKey, list); @@ -123,7 +119,7 @@ export function CustomerTruckAssignmentCard({ setError("Plate number, driver name and truck type are required."); return; } - if (isExport && (containers.length < 1 || containers.length > 2)) { + if (containers.length < 1 || containers.length > 2) { setError("Select 1 or 2 container numbers for this truck."); return; } @@ -207,8 +203,8 @@ export function CustomerTruckAssignmentCard({ )} - {/* Add-truck form. Export needs unassigned containers; import always allows another truck. */} - {(isExport ? availableContainers.length > 0 : true) ? ( + {/* Add-truck form — both directions assign the containers each truck carries. */} + {availableContainers.length > 0 ? ( <> @@ -231,19 +227,18 @@ export function CustomerTruckAssignmentCard({ value={truckType || null} onChange={(value) => setTruckType(value ?? "")} /> - {isExport && ( - - )} + - ) : ( - - - - )} -
-

{title}

- {subtitle && ( -

{subtitle}

- )} -
-
- - - ); -} - -function ConversationList({ - onClose, - onNew, - onOpen, -}: { - onClose: () => void; - onNew: () => void; - onOpen: (id: string) => void; -}) { - const { data, isLoading } = useConversations(); - const items = data?.items ?? []; - - return ( - <> -
-
- {isLoading ? ( -
Loading…
- ) : items.length === 0 ? ( -
- - - - No conversations yet. Start one and our team will help you out. -
- ) : ( - items.map((c) => ( - onOpen(c.id)} /> - )) - )} -
-
- -
- - ); -} - -function ConversationRow({ - c, - onClick, -}: { - c: ConversationDto; - onClick: () => void; -}) { - const unread = c.unreadCount > 0; - return ( - - ); -} - -function NewConversation({ - isGuest, - onClose, - onBack, - onCreated, -}: { - isGuest: boolean; - onClose: () => void; - onBack: () => void; - onCreated: (id: string) => void; -}) { - const [name, setName] = useState(''); - const [email, setEmail] = useState(''); - const [subject, setSubject] = useState(''); - const [message, setMessage] = useState(''); - const create = useCreateConversation(); - - const guestValid = - !isGuest || (name.trim().length > 0 && /.+@.+\..+/.test(email.trim())); - const valid = - subject.trim().length >= 3 && message.trim().length > 0 && guestValid; - - const submit = async () => { - if (!valid) return; - const conv = await create.mutateAsync({ - subject: subject.trim(), - initialMessage: message.trim(), - ...(isGuest ? { name: name.trim(), email: email.trim() } : {}), - }); - onCreated(conv.id); - }; - - const inputClass = - 'w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-emerald-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white'; - - return ( - <> -
-
- {isGuest && ( - <> -
- - setName(e.target.value)} - placeholder="Full name" - className={inputClass} - /> -
-
- - setEmail(e.target.value)} - placeholder="you@example.com" - className={inputClass} - /> -
- - )} -
- - setSubject(e.target.value)} - placeholder="e.g. Refund for booking EDR-1234" - className={inputClass} - /> -
-
- -