diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/ContractReferenceLink.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/ContractReferenceLink.tsx
new file mode 100644
index 000000000..94061eee2
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/ContractReferenceLink.tsx
@@ -0,0 +1,43 @@
+import { Link } from "react-router-dom";
+
+/**
+ * The parent contract's reference, linking to that contract's detail page.
+ *
+ * Backoffice-local on purpose: the contract detail route differs per app
+ * (`/dashboard/contract-requests/:id` here vs `/contracts/:id` in the portal),
+ * so the portal keeps its own copy in `pages/bookings/booking-display.tsx`
+ * rather than the two sharing a component that would have to take the route as
+ * a prop at every call site.
+ *
+ * Renders nothing when either field is missing: `contractId` is nullable on the
+ * booking, and only the bookings list/detail endpoints join `contractReference`
+ * — other endpoints (warehouse, fleet, payments) return booking rows without it,
+ * and a link with no id would be a dead one.
+ *
+ * `stopPropagation` matters: booking rows are click-to-navigate, so without it a
+ * click here would race the row handler and land on the booking instead.
+ */
+export function ContractReferenceLink({
+ contractId,
+ contractReference,
+ className,
+}: {
+ contractId?: string | null;
+ contractReference?: string | null;
+ className?: string;
+}) {
+ if (!contractId || !contractReference) return null;
+
+ return (
+ e.stopPropagation()}
+ className={
+ className ??
+ "block truncate font-mono text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
+ }
+ >
+ {contractReference}
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx
index aa14fe8cd..3ee5d72a0 100644
--- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx
@@ -24,6 +24,7 @@ import type { LucideIcon } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
+import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
@@ -94,9 +95,15 @@ export function BookingRequestHero({
Booking reference
-
- {booking.reference}
-
+
+
+ {booking.reference}
+
+
+
{booking.schedulingStatus ? (
diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts
index bc10fd064..a0594094c 100644
--- a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts
+++ b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts
@@ -19,6 +19,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
id: booking.id,
reference: booking.reference,
contractReference: booking.contractReference ?? null,
+ contractId: booking.contractId ?? null,
approvalSteps: booking.approvalSteps,
customerLabel: booking.isGovernment
? (booking.governmentInstitution ?? "Government")
diff --git a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts
index 5b2bcb255..0a270372e 100644
--- a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts
+++ b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts
@@ -27,7 +27,8 @@ export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
- staleTime: 30_000,
+ // staleTime: 30_000,
+ staleTime:0,
// Data freshness is driven by mutation invalidation (MutationCache above),
// socket pushes, and explicit polling — not by tab focus. Focus refetch
// just re-fires every mounted query each time the window is refocused.
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx
index 72c16215b..3eb775e95 100644
--- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx
@@ -48,6 +48,7 @@ import {
useBookingList,
useBookingListSummary,
} from "@/hooks/bookings/useBookings";
+import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service";
import type { BookingListRow } from "@/types/booking";
@@ -308,7 +309,17 @@ export default function BookingRequestsPage() {
return (
{ref ? (
- {ref}
+ // Fall back to plain text when the id is missing — the reference is
+ // still worth showing, it just has nowhere to link to.
+ (row.original.contractId ? (
+
+ ) : (
+ {ref}
+ ))
) : (
—
)}
diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts
index ec31e11fa..4b5b5257b 100644
--- a/apps/edr-freight-web/backoffice/src/types/booking.ts
+++ b/apps/edr-freight-web/backoffice/src/types/booking.ts
@@ -231,6 +231,8 @@ export interface BookingListRow {
id: string;
reference: string;
contractReference?: string | null;
+ /** Needed to link the reference to the contract's detail page. */
+ contractId?: string | null;
customerLabel: string;
approvalSteps?: BookingApprovalStep[];
status: BookingStatus;
diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx
index 007fc7fe9..56226594e 100644
--- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx
+++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx
@@ -10,6 +10,7 @@ import {
bookingIsSignable,
} from "@/pages/bookings/contract/ContractSignButton";
import { ApproveDeliveryButton } from "@/pages/bookings/delivery/ApproveDeliveryButton";
+import { ContractReferenceLink } from "@/pages/bookings/booking-display";
interface BookingRowProps {
booking: any;
@@ -73,6 +74,7 @@ export const BookingRow = memo(function BookingRow({
{booking.reference}
+
{commodity} · {origin} → {dest}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx
index dcc455ada..a8cc43e14 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx
@@ -13,7 +13,6 @@ import type { Freight } from "@edr/types";
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
-import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner";
import { CardTitle, SectionCard } from "./layout";
@@ -65,8 +64,9 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
return (
-
-
+ {/* The "Clearance progress" stepper moved into the unified journey
+ wizard at the top of the page — this card keeps only the actions. */}
+ Clearance documents
{action && (
)}
- {/* ── customs progress (Path B phased clearance) ──────────────────── */}
- {(clearance?.milestones?.length ?? 0) > 0 && (
-
- Customs clearance progress
-
- Every customs step for this shipment — completed steps are ticked,
- the highlighted one is where it currently stands.
-
-
-
- )}
-
+ {/* Customs progress lives in the unified journey wizard at the top of
+ the page (StatusHero → JourneyWizard) — no duplicate timeline here. */}
{(clearance?.workflowFiles?.length ?? 0) > 0 && (
Customs documents
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/JourneyWizard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/JourneyWizard.tsx
new file mode 100644
index 000000000..6eabb83ad
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/JourneyWizard.tsx
@@ -0,0 +1,211 @@
+import { Badge, Box, Text } from "@mantine/core";
+import { Check } from "lucide-react";
+
+import type { Freight } from "@edr/types";
+
+import {
+ CONTRACT_PROGRESS_STAGES,
+ PROGRESS_STAGES,
+ resolveContractStage,
+ resolveStage,
+} from "../constants";
+import { isNegative } from "../utils";
+
+type StepState = "done" | "active" | "idle" | "skipped";
+
+interface JourneyStep {
+ key: string;
+ label: string;
+ state: StepState;
+ /** Milestone owner region (customs flow only) — rendered as a tiny badge. */
+ owner?: string;
+}
+
+const OWNER_LABELS: Record = {
+ CUST: "You",
+ ET: "GL Ethiopia",
+ DJ: "GL Djibouti",
+ OPS: "Operations",
+};
+
+/**
+ * The single source for the booking-journey wizard steps.
+ *
+ * Customs (Path B) bookings: the backend GL milestone catalog IS the
+ * end-to-end process — documents → review → customs declaration → duty/tax →
+ * wagon & freight payment → loading → transit → arrival → release. Those rows
+ * are rendered directly (post-booking milestones appear once GL seeds them),
+ * framed by the booking-level steps the catalog does not carry: the prepaid
+ * clearance service fee gate at the start and final delivery at the end.
+ *
+ * Non-customs bookings: the existing lifecycle stage sets (direct vs
+ * contract-drawdown) — no customs steps ever show up.
+ */
+export function buildJourneySteps(
+ booking: Freight.IBooking,
+ milestones: Freight.IClearanceMilestone[],
+): JourneyStep[] {
+ const status = booking.status as string;
+ const isCustoms = Boolean(booking.customsClearingEnabled);
+
+ if (isCustoms && milestones.length > 0) {
+ const sorted = [...milestones].sort((a, b) => a.sortOrder - b.sortOrder);
+ const firstPendingId = sorted.find((m) => m.status === "PENDING")?.id;
+ const feeActive = status === "AWAITING_CLEARANCE_PAYMENT";
+ const delivered = ["COMPLETED", "DELIVERED"].includes(status);
+
+ const steps: JourneyStep[] = [
+ { key: "booked", label: "Booking initiated", state: "done" },
+ {
+ key: "fee",
+ label: "Clearance fee paid",
+ state: feeActive ? "active" : "done",
+ owner: "CUST",
+ },
+ ...sorted.map((m) => ({
+ key: m.id,
+ label: m.milestoneLabel,
+ owner: m.ownerRegion ?? undefined,
+ state:
+ m.status === "COMPLETED"
+ ? "done"
+ : m.status === "SKIPPED"
+ ? "skipped"
+ : !feeActive && m.id === firstPendingId
+ ? "active"
+ : "idle",
+ })),
+ { key: "delivered", label: "Delivered", state: delivered ? "done" : "idle" },
+ ];
+ // Every known milestone is done but the booking hasn't closed yet — the
+ // delivery step is what's in progress.
+ if (!feeActive && !firstPendingId && !delivered) {
+ steps[steps.length - 1].state = "active";
+ }
+ return steps;
+ }
+
+ const contractFlow = Boolean(booking.contractId) && !isNegative(status);
+ const stages = contractFlow ? CONTRACT_PROGRESS_STAGES : PROGRESS_STAGES;
+ const current = contractFlow
+ ? resolveContractStage(booking)
+ : resolveStage(booking);
+ return stages.map((s, idx) => ({
+ key: s.label,
+ label: s.label,
+ state: idx < current ? "done" : idx === current ? "active" : "idle",
+ }));
+}
+
+/**
+ * One large wizard for the whole booking journey. Steps flow left→right and
+ * wrap onto the next row when the process is long (customs imports run ~20
+ * steps → 2–3 rows) — no sideways scrolling, everything visible at once.
+ */
+export function JourneyWizard({
+ booking,
+ milestones = [],
+}: {
+ booking: Freight.IBooking;
+ milestones?: Freight.IClearanceMilestone[];
+}) {
+ const steps = buildJourneySteps(booking, milestones);
+ const last = steps.length - 1;
+
+ return (
+
+ {steps.map((s, idx) => {
+ const done = s.state === "done";
+ const active = s.state === "active";
+ const skipped = s.state === "skipped";
+ // A connector segment turns green once the step to its left completed
+ // (skipped steps pass progress through).
+ const prev = idx > 0 ? steps[idx - 1] : undefined;
+ const leftOn =
+ !!prev && (prev.state === "done" || prev.state === "skipped");
+ const rightOn = done || skipped;
+ const ownerLabel = s.owner ? OWNER_LABELS[s.owner] : undefined;
+
+ return (
+
+ {/* rail: left connector · circle · right connector */}
+
+
+
+ {done ? : idx + 1}
+
+
+
+ {/* label (+ owner badge) centered under the circle */}
+
+
+ {s.label}
+
+ {ownerLabel && (
+
+ {ownerLabel}
+
+ )}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx
index b5494ab9d..455b71a69 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx
@@ -13,7 +13,10 @@ import type { ReactNode } from "react";
import type { Freight } from "@edr/types";
-import { bookingStatusLabel } from "@/pages/bookings/booking-display";
+import {
+ bookingStatusLabel,
+ ContractReferenceLink,
+} from "@/pages/bookings/booking-display";
import { bookingSubtitle, isDraftLike, isNegative } from "../utils";
@@ -50,9 +53,12 @@ export function PageHeader({
-
- {booking.reference}
-
+
+
+ {booking.reference}
+
+
+ = {
- GREEN: "green",
- YELLOW: "yellow",
- RED: "red",
-};
-
/**
- * Read-only shipment tracking for the customer (Path B). Shows the GL milestone
- * progression and, when GL has advised duty/tax but the slip is not yet paid,
- * surfaces a payment-slip upload — the only customer action in this phase.
+ * Customs (Path B) duty/tax panel: when GL has advised duty/tax but the slip is
+ * not yet paid, surfaces the payment-slip upload — the customer's only action
+ * in this phase. The milestone progression itself is rendered by the unified
+ * journey wizard at the top of the page.
*/
export function ShipmentTrackingCard({ bookingId }: { bookingId: string }) {
const qc = useQueryClient();
@@ -44,12 +29,6 @@ export function ShipmentTrackingCard({ bookingId }: { bookingId: string }) {
},
});
- const sorted = useMemo(
- () => [...milestones].sort((a, b) => a.sortOrder - b.sortOrder),
- [milestones],
- );
- const nextPending = sorted.find((m) => m.status === "PENDING");
-
const dutyAdvised = milestones.find(
(m) => m.milestoneCode === "DUTY_TAXES_ADVISED",
);
@@ -57,11 +36,14 @@ export function ShipmentTrackingCard({ bookingId }: { bookingId: string }) {
const needsDutySlip =
dutyAdvised?.status === "COMPLETED" && dutyPaid?.status !== "COMPLETED";
- if (sorted.length === 0) return null;
+ // The milestone progression itself lives in the unified journey wizard at
+ // the top of the page — this card only surfaces the customer's one action
+ // in the customs phase: uploading the duty/tax payment slip.
+ if (!needsDutySlip) return null;
return (
- Shipment tracking
+ Duty & tax payment
{needsDutySlip ? (
) : null}
-
-
- {sorted.map((m, index) => {
- const isLast = index === sorted.length - 1;
- const isNext = nextPending?.id === m.id;
- const Icon =
- m.status === "COMPLETED" ? Check : isNext ? Clock : Circle;
- const risk =
- m.milestoneCode === "RISK_ASSIGNED"
- ? m.metadata?.riskLevel
- : undefined;
-
- return (
-
-
-
-
-
- {!isLast && (
-
- )}
-
-
-
-
- {m.milestoneLabel}
-
- {risk ? (
-
- {risk}
-
- ) : null}
-
-
-
- );
- })}
-
);
}
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 b9d7ee8e8..c702329d1 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
@@ -1,18 +1,20 @@
import { Box, Group, Text } from "@mantine/core";
-import { Check, MoveRight } from "lucide-react";
+import { useQuery } from "@tanstack/react-query";
+import { MoveRight } from "lucide-react";
import type { Freight } from "@edr/types";
+import { contractsService } from "@/services/contracts.service";
+
import {
ARRIVAL_STAGE,
CONTRACT_ARRIVAL_STAGE,
- CONTRACT_PROGRESS_STAGES,
- PROGRESS_STAGES,
STATUS_MAP,
resolveContractStage,
resolveStage,
} from "../constants";
import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils";
+import { JourneyWizard } from "./JourneyWizard";
import { SectionCard } from "./layout";
/** Origin → destination strip rendered above the progress tracker. */
@@ -73,13 +75,17 @@ export function StatusHero({
children?: React.ReactNode;
}) {
const status = booking.status;
+ // Customs (Path B) bookings render the GL milestone rows inside the unified
+ // wizard. Same query key the duty-slip panel uses, so the cache is shared.
+ const { data: milestones = [] } = useQuery({
+ queryKey: ["booking-milestones", booking.id],
+ queryFn: () => contractsService.getBookingMilestones(booking.id),
+ enabled: Boolean(booking.id) && Boolean(booking.customsClearingEnabled),
+ });
// Contract-drawdown bookings (initiated under a contract) follow a dedicated
// wizard — initiated → submitted → accepted → payment → … — instead of the
// direct booking's Request/Approval/Contract stages.
const isContractDrawdown = Boolean(booking.contractId) && !isNegative(status);
- const stages = isContractDrawdown
- ? CONTRACT_PROGRESS_STAGES
- : PROGRESS_STAGES;
const arrivalStage = isContractDrawdown
? CONTRACT_ARRIVAL_STAGE
: ARRIVAL_STAGE;
@@ -157,128 +163,8 @@ export function StatusHero({
{!negative && }
{children ?? (
-
+
)}
);
}
-
-function ProgressTracker({
- current,
- stages = PROGRESS_STAGES,
- tone = "green",
-}: {
- current: number;
- /** Which stage set to render — direct or contract-drawdown. */
- stages?: typeof PROGRESS_STAGES;
- tone?: "green" | "ink";
- negative?: boolean;
-}) {
- const last = stages.length - 1;
- const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371";
- const activeRing = tone === "ink" ? "#D9E0E7" : "#BFE8D4";
-
- return (
- /* Scrollable on mobile so the stages never overflow */
-
- {/* ~84px per stage keeps 2-word labels readable; the box scrolls on mobile. */}
-
- {stages.map((stage, idx) => {
- const state =
- idx < current ? "done" : idx === current ? "active" : "idle";
- const Icon = stage.icon;
- const reachedLeft = current >= idx && current >= 0;
- const reachedRight = current > idx && current >= 0;
-
- const circleBg =
- state === "idle"
- ? "#EEF2F6"
- : state === "active"
- ? activeFill
- : "#0EA371";
- const circleBorder =
- state === "idle" ? "1px solid #E1E7EE" : undefined;
- const circleShadow =
- state === "active" ? `0 0 0 4px ${activeRing}` : undefined;
-
- return (
-
-
- {/* left connector */}
-
- {/* stage circle */}
-
-
- );
-}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx b/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx
index 780099d1f..24d9d421e 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx
@@ -1,4 +1,5 @@
import { Badge, Box, Group, Text } from "@mantine/core";
+import { Link } from "react-router-dom";
import type { Freight } from "@edr/types";
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
@@ -9,6 +10,40 @@ import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
* and detail page render type/freight/mode/payment consistently.
*/
+/**
+ * The parent contract's reference, rendered small under a booking reference and
+ * linking to that contract's detail page.
+ *
+ * Renders nothing when either field is missing: `contractId` is nullable on the
+ * booking, and only the bookings list/detail endpoints join `contractReference`
+ * — other endpoints (warehouse, fleet, payments) return booking rows without it,
+ * and a link with no id would be a dead one.
+ *
+ * `stopPropagation` matters: booking rows are click-to-navigate, so without it a
+ * click here would race the row handler and land on the booking instead.
+ */
+export function ContractReferenceLink({
+ booking,
+}: {
+ booking: Pick;
+}) {
+ if (!booking.contractId || !booking.contractReference) return null;
+
+ return (
+ e.stopPropagation()}
+ fz={11}
+ c="edr-muted"
+ truncate
+ style={{ display: "block", textDecoration: "underline" }}
+ >
+ {booking.contractReference}
+
+ );
+}
+
/** Title-case an unmapped enum as a readable fallback ("FOO_BAR" → "Foo Bar"). */
export function titleCaseStatus(status: string): string {
return status
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx
index 21594c5ca..f03f99b01 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx
@@ -141,9 +141,13 @@ const BUSINESS_LICENSE_DOC_CODES = new Set([
// documents" rather than the clearance set.
const PROFILE_DOC_CODES = new Set([
"tin_certificate",
+ "tin",
"national_id",
"national_id_passport",
"passport",
+ // The seeded company-onboarding setting stores the national ID field with the
+ // bare code "id" — without this the doc lands in the clearance catch-all.
+ "id",
]);
interface DocGroup {
@@ -164,23 +168,44 @@ interface DocGroup {
*/
function groupContractDocuments(
files: ContractFile[],
- { includeClearance = true }: { includeClearance?: boolean } = {},
+ {
+ includeClearance = true,
+ clearanceKeys,
+ }: { includeClearance?: boolean; clearanceKeys?: Set } = {},
): DocGroup[] {
const businessLicense: ContractFile[] = [];
const profile: ContractFile[] = [];
const clearance: ContractFile[] = [];
+ const other: ContractFile[] = [];
+
+ // A file belongs to the clearance set when its code matches a clearance
+ // upload field (multi-file uploads append `_`), an ad-hoc `custom_*` doc,
+ // or a known GL workflow artifact. Without a key list (clearance view not
+ // loaded for this status) everything unclassified stays under clearance —
+ // the pre-existing catch-all behaviour.
+ const isClearanceCode = (code: string): boolean => {
+ if (code.startsWith("custom_")) return true;
+ if (clearanceWorkflowFileLabel(code)) return true;
+ if (!clearanceKeys || clearanceKeys.size === 0) return true;
+ return (
+ clearanceKeys.has(code) || clearanceKeys.has(code.replace(/_\d+$/, ""))
+ );
+ };
+
for (const f of files) {
// The generated contract PDF lives in the contract list / home rows, not
// here. Signature images are baked into that PDF — skip both.
if (f.code === "contract" || f.code.startsWith("signature_")) continue;
else if (BUSINESS_LICENSE_DOC_CODES.has(f.code)) businessLicense.push(f);
else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f);
- else if (includeClearance) clearance.push(f);
+ else if (includeClearance && isClearanceCode(f.code)) clearance.push(f);
+ else other.push(f);
}
return [
- { key: "clearance", title: "Clearance documents", files: clearance },
+ { key: "profile", title: "Company profile", files: profile },
{ key: "businessLicense", title: "Business license", files: businessLicense },
- { key: "profile", title: "Profile documents", files: profile },
+ { key: "clearance", title: "Clearance documents", files: clearance },
+ { key: "other", title: "Other documents", files: other },
].filter((g) => g.files.length > 0);
}
@@ -346,8 +371,13 @@ export default function ContractDetailPage() {
const files = contract.files ?? [];
// GENERAL contracts clear per booking — clearance documents live on each
// booking's detail page, so this tab keeps only profile/licence documents.
+ // Clearance upload field keys (when the clearance view is loaded) let the
+ // grouping tell real clearance docs apart from other attachments.
const docGroups = groupContractDocuments(files, {
includeClearance: contract.contractKind !== "GENERAL",
+ clearanceKeys: new Set(
+ (clearanceView?.documents ?? []).map((d) => d.fileKey),
+ ),
});
const docCount = docGroups.reduce((sum, g) => sum + g.files.length, 0);
// The generated contract PDF — surfaced via a dedicated "View contract" button
@@ -1190,21 +1220,13 @@ export default function ContractDetailPage() {
/>
) : null}
-
-
- Contract documents
- {contract.contractGeneratedAt && (
-
- Generated
-
- )}
-
- {docGroups.length === 0 ? (
+ {docGroups.length === 0 ? (
+
- ) : (
-
- {docGroups.map((group) => {
- const accent = DOC_GROUP_ACCENT[group.key] ?? GREEN;
- const Icon = DOC_GROUP_ICON[group.key] ?? FileText;
- return (
-
-
-
-
-
-
- {group.title}
-
-
- {group.files.length}
-
-
-
- {group.files.map((file) => (
-
- ))}
-
-
- );
- })}
-
- )}
-
+
+ ) : (
+ // One card per section — profile, business licence, clearance,
+ // other — instead of a single flat list.
+ docGroups.map((group) => {
+ const accent = DOC_GROUP_ACCENT[group.key] ?? GREEN;
+ const Icon = DOC_GROUP_ICON[group.key] ?? FileText;
+ return (
+
+
+
+
+
+
+ {group.title}
+
+
+ {group.files.length}
+
+
+
+ {DOC_GROUP_DESC[group.key] ?? ""}
+
+
+ {group.files.map((file) => (
+
+ ))}
+
+
+ );
+ })
+ )}
@@ -1579,16 +1602,26 @@ const KEY_FACT_ACCENT: Record = {
orange: "#C77F09",
};
-// Per-section accent + icon for the Documents tab groups.
+// Per-section accent + icon + description for the Documents tab groups.
const DOC_GROUP_ACCENT: Record = {
clearance: "#C77F09",
businessLicense: "#0A6F4D",
profile: "#2B6CB0",
+ other: "#64748B",
};
const DOC_GROUP_ICON: Record = {
clearance: Upload,
businessLicense: FileBadge,
profile: FileText,
+ other: FileText,
+};
+const DOC_GROUP_DESC: Record = {
+ profile:
+ "Identity and onboarding documents attached from your company profile.",
+ businessLicense: "Business and trade licences on file for this company.",
+ clearance:
+ "Documents uploaded for the customs clearance review of this contract.",
+ other: "Additional files attached to this contract.",
};
/**
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx
index cdef9136e..5daa66628 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx
@@ -50,6 +50,7 @@ import {
StatCard,
} from "./contract-ui";
import { ContractStepBanner } from "./ContractStepBanner";
+import "./contracts-table.css";
function primaryRoute(contract: Freight.IContract) {
const route = contract.routes?.[0];
@@ -367,11 +368,15 @@ export default function ContractsList() {
>
)}
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/contracts-table.css b/apps/edr-freight-web/portal/src/pages/contracts/contracts-table.css
new file mode 100644
index 000000000..c348b8b59
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/contracts/contracts-table.css
@@ -0,0 +1,78 @@
+/*
+ * Scoped to .edr-contracts-table — every rule below is prefixed, so no other
+ * Mantine Table in the portal is affected.
+ *
+ * Column sizing: table-layout stays `auto`, so a column with short content
+ * (a currency code, a badge) keeps its natural narrow width. The cap only
+ * kicks in for columns whose content would otherwise push past it — those
+ * wrap onto extra lines instead of widening the table.
+ */
+.edr-contracts-table {
+ /* Single knob for the cap — raise this if the columns read too cramped. */
+ --edr-col-max: 60px;
+}
+
+.edr-contracts-table th,
+.edr-contracts-table td {
+ max-width: var(--edr-col-max);
+ white-space: normal;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+/*
+ * The two fixed-purpose columns are exempt from the cap: the expander is a
+ * 28px icon button that must not wrap, and the action column holds two
+ * buttons side by side.
+ */
+.edr-contracts-table th:first-child,
+.edr-contracts-table td:first-child:not([colspan]),
+.edr-contracts-table th:last-child,
+.edr-contracts-table td:last-child:not([colspan]) {
+ max-width: none;
+ white-space: nowrap;
+}
+
+/* Sticky header row (moved off the Mantine `styles` prop — see ContractsList). */
+.edr-contracts-table thead th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+}
+
+/*
+ * Sticky action column. `:not([colspan])` keeps the full-width rows — loading,
+ * error, empty state, and the expanded ContractStepBanner row — out of it;
+ * those span every column and have no separate action cell to pin.
+ */
+.edr-contracts-table th:last-child,
+.edr-contracts-table td:last-child:not([colspan]) {
+ position: sticky;
+ right: 0;
+ box-shadow: -8px 0 8px -8px rgba(16, 32, 47, 0.18);
+}
+
+/*
+ * Sticky cells sit above the scrolling ones, so they need their own opaque
+ * background or the columns underneath show through. Each state below mirrors
+ * the background the row already has.
+ */
+.edr-contracts-table td:last-child:not([colspan]) {
+ background: #ffffff;
+ z-index: 2;
+}
+
+.edr-contracts-table tbody tr:hover td:last-child:not([colspan]) {
+ background: #f4fbf8;
+}
+
+/* Expanded row: the parent
carries an inline #F4FBF8 background. */
+.edr-contracts-table tbody tr[data-expanded="true"] td:last-child:not([colspan]) {
+ background: #f4fbf8;
+}
+
+/* Header cell is sticky on both axes — it must outrank the body's sticky column. */
+.edr-contracts-table th:last-child {
+ background: #f8fafc;
+ z-index: 3;
+}