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 && (
+
+ }
+ >
+ 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/34] 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/34] 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/34] 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 3760bcdb66ab25f7df4f6cc4acca0b459034b4b1 Mon Sep 17 00:00:00 2001
From: Stephanos A
Date: Wed, 8 Jul 2026 18:16:57 +0300
Subject: [PATCH 29/34] Fare engine updates, seat class amount updates
---
.../src/modules/bookings/bookings.dto.ts | 5 ++
.../src/modules/bookings/bookings.service.ts | 80 +++++++++++------
.../src/modules/bookings/guest-booking.dto.ts | 11 ++-
.../modules/bookings/guest-booking.service.ts | 90 ++++++++++++++++---
.../src/modules/currency/currency.service.ts | 46 ++++++++--
.../fare-engine/fare-engine.service.ts | 8 +-
.../modules/payments/payments.service.spec.ts | 2 +-
.../src/modules/payments/payments.service.ts | 1 -
.../src/modules/search/search.service.ts | 3 +-
.../modules/seat-classes/seat-classes.dto.ts | 20 ++++-
.../backoffice/src/app/classes/page.tsx | 5 --
.../backoffice/src/app/tariff-rates/page.tsx | 29 +++---
.../portal/src/app/booking/payment/page.tsx | 2 +-
.../portal/src/app/booking/review/page.tsx | 13 ++-
.../portal/src/components/SearchWidget.tsx | 27 +-----
15 files changed, 243 insertions(+), 99 deletions(-)
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
index 345cfc357..d50f48592 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
@@ -21,6 +21,8 @@ export class PassengerInputDto {
@ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string;
@ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country for non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: string;
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' }) @IsOptional() @IsString() nationality?: string;
+ @ApiPropertyOptional({ example: 35000, description: 'Actual fare for this passenger in minor units (ETB). When provided, overrides the fare engine calculation — use for berth-specific pricing (Upper/Middle/Lower).' }) @IsOptional() @IsInt() seatFareMinor?: number;
+ @ApiPropertyOptional({ example: 35000, description: 'Return leg fare for this passenger in minor units (ETB). Used for ROUND_TRIP berth-specific pricing.' }) @IsOptional() @IsInt() returnSeatFareMinor?: number;
}
export class RoundTripPassengerDto {
@@ -143,6 +145,9 @@ export class CreateBookingDto {
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
@IsOptional() @IsString() priceTierId?: string;
+ @ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, this overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' })
+ @IsOptional() @IsInt() reviewedTotalMinor?: number;
+
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
@IsOptional() @IsString() promoCode?: string;
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
index 50d953010..b784ea111 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
@@ -1,4 +1,4 @@
-import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
+import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
@@ -60,6 +60,8 @@ interface BookingFilters {
@Injectable()
export class BookingsService {
+ private readonly logger = new Logger(BookingsService.name);
+
constructor(
private readonly prisma: PrismaService,
@InjectDataSource() private readonly dataSource: DataSource,
@@ -546,30 +548,42 @@ export class BookingsService {
: await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
const displayCurrency = dto.displayCurrency || Currency.ETB;
- let displayTotalMinor = fareCalculation.totalMinor;
- if (displayCurrency !== Currency.ETB) {
- displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency);
- }
- // Track per-seat fare. For package bookings children pay 10% of adult fare;
- // for regular bookings the first child is free.
+ // Track per-seat fare. Use the client-supplied seatFareMinor when present (berth-specific
+ // pricing for Upper/Middle/Lower beds). Fall back to the fare engine's baseFareMinor.
let freeChildUsed = false;
+ let pkgChildIdx = 0;
const passengersWithFares = passengersData.map(p => {
let fareMinor: number;
if (p.category === PassengerCategory.ADULT) {
- fareMinor = fareCalculation.baseFareMinor;
+ fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
} else if (dto.packageId) {
- // Free children (first per adult) get fareMinor=0; paid children pay full adult fare.
- // passengersWithFares is built in adult-first order so we track paid children by count.
- const childIdx = passengersWithFares.filter(x => x.category !== PassengerCategory.ADULT).length;
- fareMinor = childIdx < adultCount ? 0 : fareCalculation.baseFareMinor;
+ fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? fareCalculation.baseFareMinor);
+ pkgChildIdx++;
} else {
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
- else fareMinor = fareCalculation.baseFareMinor;
+ else fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
}
return { ...p, fareMinor };
});
+ // Use the sum of per-seat fares as the authoritative total when the client supplied
+ // seatFareMinor for every seat-holding passenger — this captures berth-specific pricing
+ // (Upper/Middle/Lower) that the fare engine cannot resolve from seatClassId alone.
+ // Free children have no seatId and no seatFareMinor — exclude them from the check.
+ const seatedPassengers = passengersData.filter(p => p.seatId);
+ const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
+ const resolvedTotalMinor = dto.reviewedTotalMinor ??
+ (allFaresProvided
+ ? passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0)
+ : fareCalculation.totalMinor);
+ this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} fareEngine=${fareCalculation.totalMinor})`);
+
+ let displayTotalMinor = resolvedTotalMinor;
+ if (displayCurrency !== Currency.ETB) {
+ displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency);
+ }
+
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
@@ -577,7 +591,7 @@ export class BookingsService {
scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT',
bookingType: 'ONE_WAY',
- totalMinor: fareCalculation.totalMinor,
+ totalMinor: resolvedTotalMinor / 100,
adultCount,
childCount,
displayCurrency,
@@ -697,8 +711,8 @@ export class BookingsService {
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
}
- // Track per-seat fare. For package bookings children pay 10% of adult fare;
- // for regular bookings the first child is free per leg.
+ // Track per-seat fare. Use client-supplied seatFareMinor/returnSeatFareMinor when
+ // present (berth-specific pricing). Fall back to fare engine values.
let outboundFreeChildUsed = false;
let returnFreeChildUsed = false;
const passengersWithFares = passengersData.map(p => {
@@ -706,24 +720,40 @@ export class BookingsService {
let returnFareMinor: number;
if (p.category === PassengerCategory.ADULT) {
- outboundFareMinor = outboundFare.baseFareMinor;
- returnFareMinor = returnFare.baseFareMinor;
+ outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
+ returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
} else if (dto.packageId) {
- // Free children (first per adult) get fareMinor=0; paid children pay full adult fare.
- const childIdx = passengersWithFares.filter(x => x.category !== PassengerCategory.ADULT).length;
- const isFreeChild = childIdx < adultCount;
- outboundFareMinor = isFreeChild ? 0 : outboundFare.baseFareMinor;
- returnFareMinor = isFreeChild ? 0 : returnFare.baseFareMinor;
+ outboundFareMinor = 0;
+ returnFareMinor = 0;
} else {
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
- else outboundFareMinor = outboundFare.baseFareMinor;
+ else outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
- else returnFareMinor = returnFare.baseFareMinor;
+ else returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
}
return { ...p, outboundFareMinor, returnFareMinor };
});
+ // Override totalMinor with the sum of actual per-seat fares when all seated passengers
+ // supplied their fares — free children (no seatId) are excluded from the check.
+ const rtSeatedPassengers = passengersData.filter(p => p.outboundSeatId);
+ const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
+ rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
+ if (dto.reviewedTotalMinor) {
+ totalMinor = dto.reviewedTotalMinor;
+ displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
+ : totalMinor;
+ } else if (allRTFaresProvided && !dto.packageId) {
+ totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
+ if (displayCurrency !== Currency.ETB) {
+ displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
+ } else {
+ displayTotalMinor = totalMinor;
+ }
+ }
+
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts
index c2acaeece..f5a5559bb 100644
--- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts
@@ -1,4 +1,4 @@
-import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean } from 'class-validator';
+import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
@@ -42,6 +42,12 @@ export class GuestPassengerDto {
@ApiPropertyOptional({ example: 'abebe@email.com', description: 'Contact email' })
@IsOptional() @IsString() email?: string;
+
+ @ApiPropertyOptional({ example: 35000, description: 'Actual fare for this passenger in minor units (ETB). Overrides fare engine — use for berth-specific pricing (Upper/Middle/Lower).' })
+ @IsOptional() @IsInt() seatFareMinor?: number;
+
+ @ApiPropertyOptional({ example: 35000, description: 'Return leg fare for this passenger in minor units (ETB). Used for ROUND_TRIP berth-specific pricing.' })
+ @IsOptional() @IsInt() returnSeatFareMinor?: number;
}
export class CreateGuestBookingDto {
@@ -150,6 +156,9 @@ export class CreateGuestBookingDto {
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
@IsOptional() @IsString() priceTierId?: string;
+
+ @ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' })
+ @IsOptional() @IsInt() reviewedTotalMinor?: number;
}
export class SavedPassengerProfileDto {
diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
index 5d773860c..fb218e052 100644
--- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
@@ -185,12 +185,38 @@ export class GuestBookingService {
}
const taxesMinor = 0;
- const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor);
+
+ // Per-seat fare: use client-supplied seatFareMinor when present (berth-specific pricing).
+ // Free children (first child, non-package) get fareMinor=0.
+ let freeChildUsed = false;
+ let pkgChildIdx = 0;
+ const passengersWithFares = passengersData.map(p => {
+ let fareMinor: number;
+ if (p.category === PassengerCategory.ADULT) {
+ fareMinor = p.seatFareMinor ?? baseFareMinor;
+ } else if (isPackageOneway) {
+ fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? childUnitFare);
+ pkgChildIdx++;
+ } else {
+ if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
+ else fareMinor = p.seatFareMinor ?? childUnitFare;
+ }
+ return { ...p, fareMinor };
+ });
+
+ // Use reviewedTotalMinor from frontend as authoritative total when provided.
+ // Fall back to per-seat sum when all seated passengers supplied seatFareMinor.
+ const seatedPassengers = passengersData.filter(p => p.seatId);
+ const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
+ const resolvedTotalMinor = dto.reviewedTotalMinor ??
+ (allFaresProvided
+ ? passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0)
+ : Math.max(0, totalBaseFareMinor - discountMinor));
const displayCurrency = dto.displayCurrency || Currency.ETB;
- let displayTotalMinor = totalMinor;
+ let displayTotalMinor = resolvedTotalMinor;
if (displayCurrency !== Currency.ETB) {
- displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
+ displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency);
}
// Resolve or create the guest Passenger record
@@ -224,7 +250,7 @@ export class GuestBookingService {
passengerId: guestPassengerId,
scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT',
- totalMinor,
+ totalMinor: resolvedTotalMinor,
adultCount,
childCount,
displayCurrency,
@@ -235,7 +261,7 @@ export class GuestBookingService {
contactEmail: firstPassenger.email || null,
contactPhone: firstPassenger.phone || null,
seats: {
- create: passengersData.map((p) => ({
+ create: passengersWithFares.map((p) => ({
seat: { connect: { id: p.seatId } },
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
@@ -245,7 +271,7 @@ export class GuestBookingService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
- fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : childUnitFare,
+ fareMinor: p.fareMinor,
displayCurrency,
})),
},
@@ -278,7 +304,7 @@ export class GuestBookingService {
totalBaseFareMinor,
discountMinor,
taxesFeesMinor: taxesMinor,
- totalMinor,
+ totalMinor: resolvedTotalMinor,
currency: 'ETB',
displayCurrency,
displayTotalMinor,
@@ -423,13 +449,51 @@ export class GuestBookingService {
}
const taxesMinor = 0;
- const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
+ let totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
- const displayTotalMinor = displayCurrency !== Currency.ETB
+ let displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
+ // Per-seat fares: use client-supplied seatFareMinor/returnSeatFareMinor when present.
+ let outboundFreeChildUsed = false;
+ let returnFreeChildUsed = false;
+ const passengersWithFares = passengersData.map(p => {
+ let outboundFareMinor: number;
+ let returnFareMinor: number;
+ if (p.category === PassengerCategory.ADULT) {
+ outboundFareMinor = p.seatFareMinor ?? outboundBaseFare;
+ returnFareMinor = p.returnSeatFareMinor ?? returnBaseFare;
+ } else if (isPackageRoundTrip) {
+ outboundFareMinor = 0;
+ returnFareMinor = 0;
+ } else {
+ if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
+ else outboundFareMinor = p.seatFareMinor ?? outboundChildUnitFare;
+ if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
+ else returnFareMinor = p.returnSeatFareMinor ?? returnChildUnitFare;
+ }
+ return { ...p, outboundFareMinor, returnFareMinor };
+ });
+
+ // Override totalMinor with reviewedTotalMinor when provided, or sum of per-seat fares
+ // when all seated passengers supplied their fares.
+ const rtSeatedPassengers = passengersData.filter(p => p.seatId);
+ const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
+ rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
+ if (dto.reviewedTotalMinor) {
+ totalMinor = dto.reviewedTotalMinor;
+ displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
+ : totalMinor;
+ } else if (allRTFaresProvided && !isPackageRoundTrip) {
+ totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
+ displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
+ : totalMinor;
+ }
+
// Create or resolve guest passenger (same as one-way)
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
@@ -461,7 +525,7 @@ export class GuestBookingService {
contactPhone: passengersData[0]?.phone || null,
seats: {
create: [
- ...passengersData.map((p) => ({
+ ...passengersWithFares.map((p) => ({
seat: { connect: { id: p.seatId } },
leg: 1,
scheduleId: dto.scheduleId,
@@ -473,10 +537,10 @@ export class GuestBookingService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
- fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : outboundChildUnitFare,
+ fareMinor: p.outboundFareMinor,
displayCurrency,
})),
- ...passengersData.map((p) => ({
+ ...passengersWithFares.map((p) => ({
seat: { connect: { id: p.returnSeatId } },
leg: 2,
scheduleId: dto.returnScheduleId,
@@ -488,7 +552,7 @@ export class GuestBookingService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
- fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : returnChildUnitFare,
+ fareMinor: p.returnFareMinor,
displayCurrency,
})),
],
diff --git a/apps/edr-passenger-api/src/modules/currency/currency.service.ts b/apps/edr-passenger-api/src/modules/currency/currency.service.ts
index 4666d4aaa..304add11c 100644
--- a/apps/edr-passenger-api/src/modules/currency/currency.service.ts
+++ b/apps/edr-passenger-api/src/modules/currency/currency.service.ts
@@ -30,23 +30,59 @@ export class CurrencyService {
private readonly configService: ConfigService,
) {}
+ /**
+ * Converts a stored display-currency minor amount to the charge major amount
+ * sent to the payment provider, without hitting the DB for an exchange rate.
+ * Use this when the payment method's settlement currency matches the booking's
+ * displayCurrency — the rate is already baked into displayTotalMinor.
+ */
+ displayMinorToChargeMajor(displayMinor: number, currency: string): number {
+ const decimals = CHARGE_CURRENCY_DECIMALS[currency.toUpperCase()];
+ if (decimals === undefined) {
+ throw new BadRequestException(`Unsupported charge currency: ${currency}`);
+ }
+ return this.roundTo(displayMinor / 100, decimals);
+ }
+
+ async convertEtbMinorToChargeMinor(
+ amountMinorEtb: number,
+ targetCurrency: string,
+ ): Promise {
+ const target = targetCurrency.toUpperCase();
+ if (CHARGE_CURRENCY_DECIMALS[target] === undefined) {
+ throw new BadRequestException(`Unsupported charge currency: ${targetCurrency}`);
+ }
+
+ if (target === Currency.ETB) {
+ return amountMinorEtb;
+ }
+
+ // Convert ETB minor → target minor: apply exchange rate, keep as minor units.
+ const rate = await this.getRateOrThrow(Currency.ETB, target as Currency);
+ return Math.round(amountMinorEtb * rate);
+ }
+
+ /**
+ * Converts an ETB minor-unit amount to the charge major-unit amount sent to the
+ * payment provider. Applies the exchange rate for foreign currencies then divides
+ * by 100 to yield major units (e.g. 300000 ETB minor → 3000.00 ETB major).
+ */
async convertEtbMinorToChargeMajor(
amountMinorEtb: number,
targetCurrency: string,
): Promise {
const target = targetCurrency.toUpperCase();
- const decimals = CHARGE_CURRENCY_DECIMALS[target];
- if (decimals === undefined) {
+ if (CHARGE_CURRENCY_DECIMALS[target] === undefined) {
throw new BadRequestException(`Unsupported charge currency: ${targetCurrency}`);
}
+ const decimals = CHARGE_CURRENCY_DECIMALS[target];
- const sourceMajor = amountMinorEtb / 100;
if (target === Currency.ETB) {
- return this.roundTo(sourceMajor, decimals);
+ return this.roundTo(amountMinorEtb / 100, decimals);
}
const rate = await this.getRateOrThrow(Currency.ETB, target as Currency);
- return this.roundTo(sourceMajor * rate, decimals);
+ return this.roundTo((amountMinorEtb * rate) / 100, decimals);
}
async getRateOrThrow(
diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts
index 515a00655..dd4e15b9c 100644
--- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts
+++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts
@@ -105,10 +105,16 @@ export class FareEngineService {
if (segmentOverride) {
baseFarePerPassengerMinor = segmentOverride.baseFareMinor;
+ if (nationalityType === 'INTERNATIONAL' && !segmentOverride.nationality) {
+ baseFarePerPassengerMinor *= 2;
+ }
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
fareSource = 'SEGMENT_FARE_RULE';
} else if (fareRule?.tripId) {
baseFarePerPassengerMinor = fareRule.baseFareMinor;
+ if (nationalityType === 'INTERNATIONAL' && !fareRule.nationality) {
+ baseFarePerPassengerMinor *= 2;
+ }
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
fareSource = 'SCHEDULE_FARE_RULE';
} else {
@@ -165,7 +171,7 @@ export class FareEngineService {
const calculation = [
`Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`,
- `Nationality: ${dto.nationality ?? 'unspecified'} → ${nationalityType} → ${nationalitySeatClass.name}`,
+ `Nationality: ${dto.nationality ?? 'unspecified'} → ${nationalityType}${nationalityType === 'INTERNATIONAL' ? ' (2× surcharge applied)' : ''} → ${nationalitySeatClass.name}`,
`Rate per km: ${nationalitySeatClass.baseFareMinor} minor → ${nationalitySeatClass.baseFareMinor / 100} ETB/km`,
`Insurance: ${nationalitySeatClass.insuranceFeeMinor} minor → factor ${insuranceFactor}${insuranceAlreadyInBase ? ' (baked into base fare)' : ''}`,
`USD→ETB rate: ${usdToEtbRate}`,
diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts
index 112cc5096..bc5faee95 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts
@@ -76,7 +76,7 @@ describe("PaymentsService", () => {
// Mirrors the real ETB→major conversion: minor units → major price (TELEBIRR settles in ETB).
const mockCurrencyService = {
convertEtbMinorToChargeMajor: jest.fn((minor: number) =>
- Promise.resolve(minor / 100),
+ Promise.resolve(minor),
),
getRateOrThrow: jest.fn(),
};
diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts
index 9e43139c4..2f4e8deda 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts
@@ -550,7 +550,6 @@ export class PaymentsService {
getSupportedPaymentMethods(region?: PaymentRegionEnum) {
return this.prisma.paymentMethod.findMany({
where: {
- enabled: true,
...(region
? {
region: {
diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts
index a02c7b7e3..4de0f0d21 100644
--- a/apps/edr-passenger-api/src/modules/search/search.service.ts
+++ b/apps/edr-passenger-api/src/modules/search/search.service.ts
@@ -209,7 +209,8 @@ export class SearchService {
const cutoffHours = await this.getCutoffHours();
const cutoffThreshold = new Date(now.getTime() + cutoffHours * 60 * 60 * 1000);
- const earliest = new Date(Math.max((date < now ? now : date).getTime(), cutoffThreshold.getTime()));
+ const isToday = now.getFullYear() === y && now.getMonth() === m - 1 && now.getDate() === d;
+ const earliest = isToday ? cutoffThreshold : date;
const schedules = await this.prisma.trainSchedule.findMany({
where: {
diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts
index d12fe0fb6..1f061bf53 100644
--- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts
+++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts
@@ -1,17 +1,31 @@
-import { IsString, IsInt, IsBoolean, IsOptional } from 'class-validator';
+import { IsString, IsInt, IsBoolean, IsOptional, IsIn } from 'class-validator';
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
export class CreateSeatClassDto {
+ @ApiProperty()
+ @IsString()
+ coachTypeId: string;
+
@ApiProperty({ example: 'Economy Seat' })
@IsString()
name: string;
- @ApiPropertyOptional({ example: 'Standard economy seating' })
+ @ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
- @ApiProperty({ example: 45000, description: 'Base price in minor currency units' })
+ @ApiPropertyOptional({ enum: ['LOCAL', 'INTERNATIONAL'] })
+ @IsOptional()
+ @IsIn(['LOCAL', 'INTERNATIONAL'])
+ nationalityType?: string;
+
+ @ApiPropertyOptional({ enum: ['UPPER', 'MIDDLE', 'LOWER'] })
+ @IsOptional()
+ @IsString()
+ bedPosition?: string;
+
+ @ApiProperty({ example: 3000, description: 'Per-km rate in minor units (tariff decimal × 100000)' })
@IsInt()
basePrice: number;
diff --git a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx
index c6d9af92b..7cc9875dc 100644
--- a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx
@@ -326,11 +326,6 @@ export default function ClassesPage() {
Flat fee per passenger (e.g., travel insurance)
-
-
Total Fare Calculation:
-
Total = (Base Fare × Distance) + Insurance
-
• Insurance applies per passenger
-
diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
index 7b5ff3102..653ffddc3 100644
--- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
@@ -10,7 +10,6 @@ import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient } from '@/lib/api-client';
-const NATIONALITY_TYPES = ['LOCAL', 'INTERNATIONAL'] as const;
const BED_POSITIONS = ['UPPER', 'MIDDLE', 'LOWER'] as const;
const COACH_TYPE_LABELS: Record
= {
HSC: 'Regular Seat (Hard Seat)',
@@ -18,7 +17,6 @@ const COACH_TYPE_LABELS: Record = {
SBC: 'VIP Bed (Soft Berth)',
};
-// Tariff reference rates per the official policy document
const TARIFF_REFERENCE: Record> = {
LOCAL: {
'HSC-null': 0.03,
@@ -109,7 +107,7 @@ export default function TariffRatesPage() {
name: fd.get('name') as string,
nationalityType: selectedNationalityType,
bedPosition: selectedBedPosition || null,
- baseFareMinor: parseInt(fd.get('baseFareMinor') as string),
+ basePrice: Math.round(Number(fd.get('baseFareMinor') as string) * 100) || 0,
isActive: fd.get('isActive') === 'true',
};
if (editingClass) {
@@ -127,7 +125,6 @@ export default function TariffRatesPage() {
? classesData
: (classesData as any)?.items || (classesData as any)?.data || [];
- // Only show classes that have nationalityType set (tariff-managed rows)
const tariffClasses = allClasses.filter((c: any) => c.nationalityType);
const displayed = tariffClasses.filter((c: any) => {
@@ -141,7 +138,6 @@ export default function TariffRatesPage() {
);
});
- // Auto-suggest name from selections
const suggestName = () => {
const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
if (!ct) return '';
@@ -151,13 +147,12 @@ export default function TariffRatesPage() {
return `${label}${pos} (${nat})`;
};
- // Auto-suggest baseFareMinor from tariff reference
+ // Returns the human-readable rate (e.g. 0.03); stored value = this × 100
const suggestRate = () => {
const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
if (!ct) return '';
const ref = getTariffRef(selectedNationalityType, ct.code, selectedBedPosition || null);
- // baseFareMinor = tariff_decimal × 100000
- return ref ? Math.round(ref * 100000).toString() : '';
+ return ref ? String(ref) : '';
};
const columns = [
@@ -184,18 +179,18 @@ export default function TariffRatesPage() {
render: (c: any) => {c.name},
},
{
- key: 'baseFareMinor', label: 'Rate per km (minor)',
+ key: 'baseFareMinor', label: 'Rate per km',
render: (c: any) => {
const ct = coachTypesArray.find((t: any) => t.id === c.coachTypeId);
const ref = ct ? getTariffRef(c.nationalityType, ct.code, c.bedPosition) : undefined;
- const tariffMinor = ref ? Math.round(ref * 100000) : undefined;
+ const tariffMinor = ref ? Math.round(ref * 100) : undefined;
const matches = tariffMinor === c.baseFareMinor;
return (
- {c.baseFareMinor}
+ {c.baseFareMinor / 100}
{tariffMinor !== undefined && (
- {matches ? '✓ tariff' : `tariff: ${tariffMinor}`}
+ {matches ? '✓ tariff' : `tariff: ${tariffMinor / 100}`}
)}
@@ -237,7 +232,6 @@ export default function TariffRatesPage() {
-
@@ -367,15 +361,16 @@ export default function TariffRatesPage() {
-
+
{suggestRate() && (
@@ -391,7 +386,7 @@ export default function TariffRatesPage() {
>
{suggestRate()}
- {' '}(= {(parseInt(suggestRate()) / 100000).toFixed(3)} ETB/km)
+ {' '}(stored as {Math.round(Number(suggestRate()) * 100)})
)}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx
index a95179762..ceb54acd9 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx
@@ -436,7 +436,7 @@ export default function PaymentPage() {
) : (
- {paymentMethods.map((method) => {
+ {paymentMethods.filter(m => m.enabled).map((method) => {
const Icon = getIconForMethod(method.type);
const isSelected = selectedMethod === method.type;
return (
diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
index c5d897a13..cf14cc87c 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
@@ -343,6 +343,9 @@ export default function ReviewPage() {
passportNumber: !isEthiopian ? (p.passportNumber || '') : '',
passportCountry: !isEthiopian ? (p.passportCountry || '') : '',
nationality: p.nationality,
+ ...(isRoundTrip
+ ? { seatFareMinor: (p as any).outboundSeatFareMinor ?? undefined, returnSeatFareMinor: (p as any).inboundSeatFareMinor ?? undefined }
+ : { seatFareMinor: p.seatFareMinor ?? undefined }),
};
}),
};
@@ -397,8 +400,9 @@ export default function ReviewPage() {
passportNumber: !isEthiopian ? (p.passportNumber || '') : '',
passportCountry: !isEthiopian ? (p.passportCountry || '') : '',
nationality: p.nationality,
- phone: p.phone || '',
- email: p.email || '',
+ ...(isRoundTrip
+ ? { seatFareMinor: (p as any).outboundSeatFareMinor ?? undefined, returnSeatFareMinor: (p as any).inboundSeatFareMinor ?? undefined }
+ : { seatFareMinor: p.seatFareMinor ?? undefined }),
};
}),
createAccount: createAccount || false,
@@ -444,6 +448,11 @@ export default function ReviewPage() {
return { fareMinor, isFree: isFreeChild };
});
setReviewedTotal(computedTotal, passengerFares);
+
+ // Pass the exact total shown on this page to the backend so it stores the
+ // correct berth-specific amount regardless of what the fare engine calculates.
+ bookingData.reviewedTotalMinor = computedTotal;
+
await createBookingMutation.mutateAsync(bookingData);
} catch (error) {
alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.');
diff --git a/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx b/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx
index c51dc6158..a47d4bcc7 100644
--- a/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx
+++ b/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx
@@ -10,20 +10,6 @@ import { useBookingStore } from '@/lib/booking-store';
import { Station } from '@/types';
import { MapPin, Users, Search, Plus, Minus, ChevronDown, Globe } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
-
-function useDarkMode() {
- const [dark, setDark] = useState(() =>
- typeof window !== 'undefined' && document.documentElement.classList.contains('dark')
- );
- useEffect(() => {
- const obs = new MutationObserver(() =>
- setDark(document.documentElement.classList.contains('dark'))
- );
- obs.observe(document.documentElement, { attributeFilter: ['class'] });
- return () => obs.disconnect();
- }, []);
- return dark;
-}
import ModernDatePicker from '@/components/ModernDatePicker';
const searchSchema = z.object({
@@ -86,8 +72,6 @@ function CustomSelect({
return () => document.removeEventListener('mousedown', handler);
}, []);
- const dark = useDarkMode();
-
return (
!disabled && setOpen((o) => !o)}
className={`w-full ${icon ? 'pl-11' : 'pl-4'} pr-10 py-3.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base text-left flex items-center gap-2
- bg-white dark:bg-gray-800
+ bg-white dark:bg-gray-800 text-gray-900 dark:text-white
disabled:opacity-60 disabled:cursor-not-allowed
${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
transition-colors`}
- style={{ color: dark ? '#ffffff' : '#111827' }}
>
{icon && {icon}}
-
+
{selected ? selected.label : placeholder}
@@ -139,7 +122,6 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
const router = useRouter();
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
const [isPassengerOpen, setIsPassengerOpen] = useState(false);
- const dark = useDarkMode();
const { data: stations, isLoading } = useQuery({
queryKey: ['stations'],
@@ -255,10 +237,9 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
setIsPassengerOpen(!isPassengerOpen)}
- className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-base bg-white dark:bg-gray-800 flex items-center justify-between hover:border-primary transition-colors"
- style={{ color: dark ? '#ffffff' : '#111827' }}
+ className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-base bg-white dark:bg-gray-800 text-gray-900 dark:text-white flex items-center justify-between hover:border-primary transition-colors"
>
-
+
{(adultCount || 1) + (childCount || 0)} Passenger{((adultCount || 1) + (childCount || 0)) !== 1 ? 's' : ''}
From 9825d37e66526984703ce4615cd5ebbac9889230 Mon Sep 17 00:00:00 2001
From: Stephanos A
Date: Wed, 8 Jul 2026 19:04:21 +0300
Subject: [PATCH 30/34] Search widget updates
---
.../portal/src/app/booking/search/page.tsx | 14 +++------
.../portal/src/components/SearchWidget.tsx | 31 ++++++++++---------
2 files changed, 21 insertions(+), 24 deletions(-)
diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx
index 786196731..574b08487 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx
@@ -429,7 +429,7 @@ function StationDropdown({
(s.name.toLowerCase().includes(query.toLowerCase()) ||
s.code?.toLowerCase().includes(query.toLowerCase())),
)
- : stations.filter((s) => s.id !== excludeId).slice(0, 8);
+ : stations.filter((s) => s.id !== excludeId).slice(0, 20);
const displayValue = open ? query : (selectedStation?.name ?? "");
@@ -475,7 +475,7 @@ function StationDropdown({
{open && (
-
+
{!query && recentIds.length > 0 && (
@@ -1161,8 +1161,6 @@ export default function SearchPage() {
)}
- {/* Divider */}
-
{/* Date */}
- {/* Divider */}
-
{/* Pax + Nationality */}