From 63f638d1bba63dca9999c2161f72843e69a7bcf5 Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Tue, 7 Jul 2026 08:52:35 +0000
Subject: [PATCH 01/37] 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/37] 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 && (
+
+ }
+ >
+ Download
+
+
+ )}
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" ? (
+
+ }
+ disabled={!canAct || !trainHere}
+ loading={pendingBookingId === row.id}
+ onClick={() => onLoad(row.id)}
+ >
+ Load
+
+
+ ) : (
+
+ }
+ disabled={!canAct || !trainHere}
+ loading={pendingBookingId === row.id}
+ onClick={() => onUnload(row.id)}
+ >
+ Unload
+
+
+ )}
+
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+/**
+ * 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 (
-
+
- Replace
+ Replace file
) : (
From 9e98600a5f23eb28d1de2ecb8bb66263430e4854 Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Wed, 8 Jul 2026 10:51:22 +0000
Subject: [PATCH 26/37] fix: add vat to the settings, and docs to the profile
---
.../pages/customers/CustomerDetailPage.tsx | 123 +++++++++++++-----
.../src/pages/settings/TabCompanyProfile.tsx | 21 +++
2 files changed, 112 insertions(+), 32 deletions(-)
diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
index fa7b03be7..c68a4ca25 100644
--- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
@@ -164,25 +164,85 @@ export default function CustomerDetailPage() {
{
id: "type",
header: "Role",
- cell: ({ row }) => ,
- },
- {
- id: "reference",
- header: "Reference",
cell: ({ row }) => (
-
- {row.original.reference}
-
+
+
+
+
+ {row.original.reference}
+
+
),
},
{
- id: "businessLicense",
- header: "Business license",
- cell: ({ row }) => (
-
- {row.original.businessLicense || "—"}
-
- ),
+ id: "licenseFiles",
+ header: "License documents",
+ cell: ({ row }) => {
+ const files = row.original.licenseFiles ?? [];
+ if (files.length === 0) {
+ return (
+
+ —
+
+ );
+ }
+ return (
+
+ {files.map((f) => (
+
+
+ view({
+ name: f.name,
+ url: fileViewUrl(f.id),
+ mimeType: f.mimeType,
+ })
+ }
+ >
+
+
+
+ view({
+ name: f.name,
+ url: fileViewUrl(f.id),
+ mimeType: f.mimeType,
+ })
+ }
+ style={{
+ maxWidth: 170,
+ textAlign: "left",
+ textDecoration:
+ f.status === "pending_remove"
+ ? "line-through"
+ : undefined,
+ }}
+ >
+ {f.name}
+
+ {f.status === "pending_add" && (
+
+ Pending
+
+ )}
+ {f.status === "pending_remove" && (
+
+ Removing
+
+ )}
+
+ ))}
+
+ );
+ },
},
{
id: "status",
@@ -210,7 +270,7 @@ export default function CustomerDetailPage() {
),
},
],
- [],
+ [view],
);
const bookingColumns: ColumnDef[] = useMemo(
@@ -504,9 +564,8 @@ export default function CustomerDetailPage() {
]}
backTo="/dashboard/customers"
title={company.name}
- subtitle={`TIN ${company.tin}${
- company.country ? ` · ${company.country}` : ""
- }`}
+ subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
+ }`}
meta={
@@ -620,7 +679,7 @@ export default function CustomerDetailPage() {
-
+
void bookingsQuery.refetch(),
- }
+ message: "Failed to load bookings.",
+ onRetry: () => void bookingsQuery.refetch(),
+ }
: undefined
}
/>
@@ -669,9 +728,9 @@ export default function CustomerDetailPage() {
error={
documentsQuery.isError
? {
- message: "Failed to load documents.",
- onRetry: () => void documentsQuery.refetch(),
- }
+ message: "Failed to load documents.",
+ onRetry: () => void documentsQuery.refetch(),
+ }
: undefined
}
/>
@@ -750,9 +809,9 @@ export default function CustomerDetailPage() {
error={
paymentsQuery.isError
? {
- message: "Failed to load payments.",
- onRetry: () => void paymentsQuery.refetch(),
- }
+ message: "Failed to load payments.",
+ onRetry: () => void paymentsQuery.refetch(),
+ }
: undefined
}
/>
@@ -773,9 +832,9 @@ export default function CustomerDetailPage() {
error={
invoicesQuery.isError
? {
- message: "Failed to load invoices.",
- onRetry: () => void invoicesQuery.refetch(),
- }
+ message: "Failed to load invoices.",
+ onRetry: () => void invoicesQuery.refetch(),
+ }
: undefined
}
pagination={{
diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx
index 995acc104..27011a9d7 100644
--- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx
+++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx
@@ -34,6 +34,12 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
companyAddress: z.string().min(1, "Address is required"),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
+ vatNumber: z
+ .string()
+ .trim()
+ .max(20, "VAT number is too long")
+ .optional()
+ .or(z.literal("")),
});
export type CompanyProfileFormData = z.infer;
@@ -63,6 +69,7 @@ export default function TabCompanyProfile({
companyAddress: profile.companyAddress ?? "",
tinNumber: profile.tinNumber,
fanNumber: profile.fanNumber ?? "",
+ vatNumber: profile.vatNumber ?? "",
};
}
return {
@@ -73,6 +80,7 @@ export default function TabCompanyProfile({
companyAddress: "",
tinNumber: "",
fanNumber: "",
+ vatNumber: "",
};
}, [profile]);
@@ -97,6 +105,7 @@ export default function TabCompanyProfile({
companyAddress: data.companyAddress,
tin: data.tinNumber,
fanNumber: data.fanNumber,
+ vatNumber: data.vatNumber ?? "",
};
if (isCreate) {
@@ -223,6 +232,18 @@ export default function TabCompanyProfile({
/>
+
+
+
+
+
+
Date: Wed, 8 Jul 2026 11:05:46 +0000
Subject: [PATCH 27/37] fix
---
.../portal/src/pages/accounts/SignupPage.tsx | 435 +++++++++---------
1 file changed, 212 insertions(+), 223 deletions(-)
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx
index 1b13f7cfc..5746eefd5 100644
--- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx
@@ -242,270 +242,259 @@ export default function SignupPage() {
return (
-
- { stage === "form" ? (
-
- < PasswordInput
-label = "Confirm password"
-placeholder = "Re-enter your password"
-required
-disabled = { sending }
-error = { errors.confirmPassword?.message }
-{...register("confirmPassword") }
+
-{
- error ? (
- }
+ {error ? (
+ }
>
- { error }
-
+ {error}
+
) : null}
- : undefined}
+ color="edr-green"
+ fullWidth
+ loading={sending}
+ rightSection={!sending ? : undefined}
>
- Continue
-
+ Continue
+
- < p className = "text-center text-sm text-gray-500" >
- Already have an account ? { " "}
- < button
- type = "button"
-onClick = {() => navigate("/login")}
-className = "font-semibold text-primary hover:underline"
- >
- Sign In
-
-
-
-
- ) : (
-
-
-
-
-
-
- < div className = "space-y-1.5 text-center" >
-
- Verify your { otpChannel === "email" ? "email" : "phone" }
-
- < p className = "text-sm leading-relaxed text-gray-500" >
- We sent a 6 - digit code to{ " " }
-
- { otpChannel === "email"
- ? maskEmail(pendingData?.email ?? "")
- : maskPhone(pendingData?.phone ?? "")}
-
- .Enter it to finish creating your account.
+
+ Already have an account ?{" "}
+ navigate("/login")}
+ className="font-semibold text-primary hover:underline"
+ >
+ Sign In
+
-
+
+
+ ) : (
+
+
+
+
+
+
+
+
+ Verify your {otpChannel === "email" ? "email" : "phone"}
+
+
+ We sent a 6 - digit code to{" "}
+
+ {otpChannel === "email"
+ ? maskEmail(pendingData?.email ?? "")
+ : maskPhone(pendingData?.phone ?? "")}
+
+ .Enter it to finish creating your account.
+
+
-{
- otpError ? (
- }
+ {otpError ? (
+ }
>
- { otpError }
-
+ {otpError}
+
) : null}
-
-
- Verification code
-
- < PinInput
-length = { 6}
-type = "number"
-oneTimeCode
-value = { otpCode }
-placeholder = "0"
-disabled = { verifying }
-styles = {{ input: { textAlign: "center" } }}
-onChange = { setOtpCode }
- />
-
+
+
+ Verification code
+
+
+
- < Button
-color = "edr-green"
-fullWidth
-loading = { verifying }
-disabled = { verifying || otpCode.trim().length !== 6}
-onClick = { confirmOtp }
- >
- Verify & amp; create account
-
+
+ Verify & create account
+
- < div className = "flex items-center justify-between" >
-
+ }
-disabled = { sending || verifying}
-onClick = {() => {
- setStage("form");
- setOtpError(null);
-}}
+ color="gray"
+ leftSection={}
+ disabled={sending || verifying}
+ onClick={() => {
+ setStage("form");
+ setOtpError(null);
+ }}
>
- Back
-
- < Button
-variant = "subtle"
-color = "edr-green"
-leftSection = {< RotateCw size = { 14} />}
-disabled = { resendIn > 0 || sending || verifying}
-onClick = { resendOtp }
- >
- { resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
-
-
-
+ Back
+
+ }
+ disabled={resendIn > 0 || sending || verifying}
+ onClick={resendOtp}
+ >
+ {resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
+
+
+
)}
-
-
+
+
);
}
From 73baf97045dadc2e36cf6f9299dc573999355fbb Mon Sep 17 00:00:00 2001
From: Hagernesh
Date: Wed, 8 Jul 2026 12:58:24 +0000
Subject: [PATCH 28/37] delivery approve notification
---
.../src/modules/bookings/bookings.service.ts | 12 ++++++++++++
.../BookingDetailPage/ReadonlyBookingView.tsx | 1 +
packages/types/src/freight/index.ts | 2 ++
3 files changed, 15 insertions(+)
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
index a5f25ad21..578e73278 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
@@ -1424,6 +1424,18 @@ export class BookingsService {
schedule?.status ?? null;
}
+ // A generated-but-unsigned handover means the customer must approve delivery.
+ // Surfaced so the portal shows "Approve delivery" as soon as the handover
+ // exists, independent of the truck-arrival flag.
+ const [pendingHandover] = await this.dataSource.query(
+ `SELECT 1 FROM freight.booking_handovers
+ WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
+ LIMIT 1`,
+ [id],
+ );
+ (booking as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature =
+ Boolean(pendingHandover);
+
return booking;
}
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 40994d589..659f767c7 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
@@ -108,6 +108,7 @@ export function ReadonlyBookingView({
: status === "SELECTED_FOR_BATCH");
const canApproveDelivery =
status === "COMPLETED" ||
+ Boolean(booking.handoverAwaitingSignature) ||
(status === "TRUCK_ASSIGNED" && Boolean(booking.customerTruckArrivedAt));
const usesCustomerTruck =
booking.tradeDirection === "IMPORT"
diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts
index 7817e0a31..c5a1a364a 100644
--- a/packages/types/src/freight/index.ts
+++ b/packages/types/src/freight/index.ts
@@ -521,6 +521,8 @@ export interface IBooking extends BaseEntity {
customerTruckContainerNumber?: string | null;
customerTruckAssignedAt?: string | null;
customerTruckArrivedAt?: string | null;
+ /** A handover has been generated for this booking and is awaiting the customer's signature. */
+ handoverAwaitingSignature?: boolean;
customsClearingEnabled?: boolean;
// (multi-truck self-haul lives in ICustomerTruck[], fetched via the
From 8897e49972d6841368e455e0cd8749c26b1eca36 Mon Sep 17 00:00:00 2001
From: Roba Boru
Date: Wed, 8 Jul 2026 21:11:26 +0300
Subject: [PATCH 29/37] Fix auto assign seat
---
.../portal/src/app/booking/results/page.tsx | 116 +++++++-----
.../portal/src/app/booking/seats/page.tsx | 173 ++++++++++++------
2 files changed, 185 insertions(+), 104 deletions(-)
diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx
index 66f91dd60..574652cde 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx
@@ -379,36 +379,50 @@ export default function ResultsPage() {
const coachCurrency = "ETB";
const CoachIcon = getCoachIcon(coachType.coachTypeName);
+ const selectThisCoach = () =>
+ handleSelectCoachType(
+ scheduleId,
+ coachType.coachTypeId,
+ coachType.coachTypeCode,
+ coachType.coachTypeName,
+ coachType.classes?.[0]?.name ||
+ coachType.coachTypeName,
+ );
+
return (
-
- handleSelectCoachType(
- scheduleId,
- coachType.coachTypeId,
- coachType.coachTypeCode,
- coachType.coachTypeName,
- coachType.classes?.[0]?.name ||
- coachType.coachTypeName,
- )
- }
- className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 ${
+ role="button"
+ tabIndex={0}
+ onClick={selectThisCoach}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ selectThisCoach();
+ }
+ }}
+ className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 cursor-pointer ${
isSelected
? "border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02]"
- : "border-gray-200 dark:border-gray-700 hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50"
+ : "border-gray-200 dark:border-gray-700 shadow-sm hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50"
}`}
style={{
animation: `fade-in-up 0.3s ease-out ${index * 0.1}s both`,
}}
>
- {isSelected && (
-
-
-
- )}
+ {/* Radio indicator — top-right, persistent (not hover-only) so the
+ card's selection state is clear on touch too. */}
+
+ {isSelected && (
+
+ )}
+
@@ -474,7 +488,7 @@ export default function ResultsPage() {
(cls: any, idx: number) => (
@@ -496,8 +510,37 @@ export default function ResultsPage() {
)}
+
+ {/* Note — only shown while unselected; once picked, the Continue
+ button below takes its place. */}
+ {!isSelected && (
+
+ Click to select this coach
+
+ )}
+
+ {/* Continue only appears on the card the user has actually picked —
+ a real nested button (the outer card is a div, not a button, so
+ this doesn't create invalid/ambiguous nested-button behavior). */}
+ {isSelected && (
+
{
+ e.stopPropagation();
+ handleSelect(classModal, isOutbound);
+ }}
+ className="mt-3 w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all shadow-md shadow-primary/30 hover:shadow-lg active:scale-[0.98]"
+ >
+
+ {isRoundTrip && isOutbound
+ ? "Continue to Return Journey"
+ : "Continue to Passenger Details"}
+
+
+
+ )}
-
+
);
})}
@@ -512,33 +555,6 @@ export default function ResultsPage() {
)}
-
-
-
-
{
- if (selectedCoachType) {
- handleSelect(classModal, isOutbound);
- }
- }}
- disabled={!selectedCoachType}
- className="w-full flex items-center justify-center gap-2.5 px-6 py-3.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-primary/30 disabled:shadow-none hover:shadow-xl hover:scale-[1.02] active:scale-[0.98]"
- >
-
- {isRoundTrip && isOutbound
- ? "Continue to Return Journey"
- : "Continue to Passenger Details"}
-
-
-
- {!selectedCoachType && (
-
-
- Select a coach type to continue
-
- )}
-
-