mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
Merge pull request #805 from Tria-plc/freight_feature/usermanagement
streamline booking detail components and enhance journey visualization
This commit is contained in:
@@ -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 (
|
||||||
|
<Link
|
||||||
|
to={`/dashboard/contract-requests/${contractId}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className={
|
||||||
|
className ??
|
||||||
|
"block truncate font-mono text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{contractReference}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import type { LucideIcon } from "lucide-react";
|
|||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||||
|
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
|
||||||
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||||
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
|
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
|
||||||
|
|
||||||
@@ -94,9 +95,15 @@ export function BookingRequestHero({
|
|||||||
Booking reference
|
Booking reference
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap="sm" align="center" wrap="wrap">
|
<Group gap="sm" align="center" wrap="wrap">
|
||||||
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
|
<Stack gap={2} miw={0}>
|
||||||
{booking.reference}
|
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
|
||||||
</Title>
|
{booking.reference}
|
||||||
|
</Title>
|
||||||
|
<ContractReferenceLink
|
||||||
|
contractId={booking.contractId}
|
||||||
|
contractReference={booking.contractReference}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
<BookingStatusBadge status={booking.status} />
|
<BookingStatusBadge status={booking.status} />
|
||||||
<BookingPriorityBadge score={booking.priorityScore} />
|
<BookingPriorityBadge score={booking.priorityScore} />
|
||||||
{booking.schedulingStatus ? (
|
{booking.schedulingStatus ? (
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
|
|||||||
id: booking.id,
|
id: booking.id,
|
||||||
reference: booking.reference,
|
reference: booking.reference,
|
||||||
contractReference: booking.contractReference ?? null,
|
contractReference: booking.contractReference ?? null,
|
||||||
|
contractId: booking.contractId ?? null,
|
||||||
approvalSteps: booking.approvalSteps,
|
approvalSteps: booking.approvalSteps,
|
||||||
customerLabel: booking.isGovernment
|
customerLabel: booking.isGovernment
|
||||||
? (booking.governmentInstitution ?? "Government")
|
? (booking.governmentInstitution ?? "Government")
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ export const queryClient = new QueryClient({
|
|||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
retry: 1,
|
retry: 1,
|
||||||
staleTime: 30_000,
|
// staleTime: 30_000,
|
||||||
|
staleTime:0,
|
||||||
// Data freshness is driven by mutation invalidation (MutationCache above),
|
// Data freshness is driven by mutation invalidation (MutationCache above),
|
||||||
// socket pushes, and explicit polling — not by tab focus. Focus refetch
|
// socket pushes, and explicit polling — not by tab focus. Focus refetch
|
||||||
// just re-fires every mounted query each time the window is refocused.
|
// just re-fires every mounted query each time the window is refocused.
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
useBookingList,
|
useBookingList,
|
||||||
useBookingListSummary,
|
useBookingListSummary,
|
||||||
} from "@/hooks/bookings/useBookings";
|
} from "@/hooks/bookings/useBookings";
|
||||||
|
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { BookingListFilter } from "@/services/bookings.service";
|
import type { BookingListFilter } from "@/services/bookings.service";
|
||||||
import type { BookingListRow } from "@/types/booking";
|
import type { BookingListRow } from "@/types/booking";
|
||||||
@@ -308,7 +309,17 @@ export default function BookingRequestsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{ref ? (
|
{ref ? (
|
||||||
<span className="truncate font-mono text-xs text-foreground">{ref}</span>
|
// 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 ? (
|
||||||
|
<ContractReferenceLink
|
||||||
|
contractId={row.original.contractId}
|
||||||
|
contractReference={ref}
|
||||||
|
className="truncate font-mono text-xs text-foreground underline underline-offset-2 hover:text-primary"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="truncate font-mono text-xs text-foreground">{ref}</span>
|
||||||
|
))
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -231,6 +231,8 @@ export interface BookingListRow {
|
|||||||
id: string;
|
id: string;
|
||||||
reference: string;
|
reference: string;
|
||||||
contractReference?: string | null;
|
contractReference?: string | null;
|
||||||
|
/** Needed to link the reference to the contract's detail page. */
|
||||||
|
contractId?: string | null;
|
||||||
customerLabel: string;
|
customerLabel: string;
|
||||||
approvalSteps?: BookingApprovalStep[];
|
approvalSteps?: BookingApprovalStep[];
|
||||||
status: BookingStatus;
|
status: BookingStatus;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
bookingIsSignable,
|
bookingIsSignable,
|
||||||
} from "@/pages/bookings/contract/ContractSignButton";
|
} from "@/pages/bookings/contract/ContractSignButton";
|
||||||
import { ApproveDeliveryButton } from "@/pages/bookings/delivery/ApproveDeliveryButton";
|
import { ApproveDeliveryButton } from "@/pages/bookings/delivery/ApproveDeliveryButton";
|
||||||
|
import { ContractReferenceLink } from "@/pages/bookings/booking-display";
|
||||||
|
|
||||||
interface BookingRowProps {
|
interface BookingRowProps {
|
||||||
booking: any;
|
booking: any;
|
||||||
@@ -73,6 +74,7 @@ export const BookingRow = memo(function BookingRow({
|
|||||||
<Text fz={15} fw={700} c="edr-text" truncate>
|
<Text fz={15} fw={700} c="edr-text" truncate>
|
||||||
{booking.reference}
|
{booking.reference}
|
||||||
</Text>
|
</Text>
|
||||||
|
<ContractReferenceLink booking={booking} />
|
||||||
<Text fz={12} c="edr-muted" truncate>
|
<Text fz={12} c="edr-muted" truncate>
|
||||||
{commodity} · {origin} → {dest}
|
{commodity} · {origin} → {dest}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import type { Freight } from "@edr/types";
|
|||||||
|
|
||||||
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
|
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
|
||||||
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
|
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
|
||||||
import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner";
|
|
||||||
|
|
||||||
import { CardTitle, SectionCard } from "./layout";
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
|
|
||||||
@@ -65,8 +64,9 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<BookingClearanceWorkflowBanner booking={booking} />
|
{/* The "Clearance progress" stepper moved into the unified journey
|
||||||
<Group justify="space-between" align="center" mb="md" mt="md">
|
wizard at the top of the page — this card keeps only the actions. */}
|
||||||
|
<Group justify="space-between" align="center" mb="md">
|
||||||
<CardTitle>Clearance documents</CardTitle>
|
<CardTitle>Clearance documents</CardTitle>
|
||||||
{action && (
|
{action && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Alert, Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
|
import { Alert, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
Check,
|
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -25,7 +24,6 @@ import toast from "react-hot-toast";
|
|||||||
|
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
import { saveBlob } from "@/utils/download";
|
import { saveBlob } from "@/utils/download";
|
||||||
import { fmtDate } from "../utils";
|
|
||||||
import { IconSquare } from "./Documents";
|
import { IconSquare } from "./Documents";
|
||||||
import { CardTitle, SectionCard } from "./layout";
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
|
|
||||||
@@ -150,113 +148,7 @@ function FileRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── customs clearance progress timeline ──────────────────────────────────────
|
|
||||||
|
|
||||||
const REGION_LABELS: Record<string, string> = {
|
|
||||||
CUST: "You",
|
|
||||||
ET: "GL Ethiopia",
|
|
||||||
DJ: "GL Djibouti",
|
|
||||||
OPS: "Operations",
|
|
||||||
};
|
|
||||||
|
|
||||||
function MilestoneTimeline({
|
|
||||||
milestones,
|
|
||||||
}: {
|
|
||||||
milestones: Freight.IClearanceMilestone[];
|
|
||||||
}) {
|
|
||||||
const ordered = [...milestones].sort((a, b) => a.sortOrder - b.sortOrder);
|
|
||||||
const currentIdx = ordered.findIndex((m) => m.status === "PENDING");
|
|
||||||
return (
|
|
||||||
<Stack gap={0}>
|
|
||||||
{ordered.map((m, idx) => {
|
|
||||||
const done = m.status === "COMPLETED";
|
|
||||||
const skipped = m.status === "SKIPPED";
|
|
||||||
const active = idx === currentIdx;
|
|
||||||
const last = idx === ordered.length - 1;
|
|
||||||
return (
|
|
||||||
<Group key={m.id} gap={12} align="stretch" wrap="nowrap">
|
|
||||||
{/* rail: circle + connector */}
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
width: 26,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
width: 22,
|
|
||||||
height: 22,
|
|
||||||
borderRadius: 999,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
backgroundColor: done
|
|
||||||
? "#0EA371"
|
|
||||||
: active
|
|
||||||
? "#0C1A2B"
|
|
||||||
: "#EEF2F6",
|
|
||||||
color: done || active ? "#fff" : "#9AA8B5",
|
|
||||||
boxShadow: active ? "0 0 0 4px #D9E0E7" : undefined,
|
|
||||||
fontSize: 10.5,
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{done ? <Check size={13} strokeWidth={3} /> : idx + 1}
|
|
||||||
</Box>
|
|
||||||
{!last && (
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
width: 2,
|
|
||||||
flex: 1,
|
|
||||||
minHeight: 14,
|
|
||||||
backgroundColor: done ? "#0EA371" : "#E1E7EE",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Box pb={last ? 0 : 14} miw={0} flex={1}>
|
|
||||||
<Group gap={8} wrap="nowrap" align="center">
|
|
||||||
<Text
|
|
||||||
fz="13.5px"
|
|
||||||
fw={active ? 800 : 700}
|
|
||||||
c={done || active ? "#10202F" : "#9AA8B5"}
|
|
||||||
td={skipped ? "line-through" : undefined}
|
|
||||||
truncate
|
|
||||||
>
|
|
||||||
{m.milestoneLabel}
|
|
||||||
</Text>
|
|
||||||
{m.ownerRegion && REGION_LABELS[m.ownerRegion] && (
|
|
||||||
<Badge
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
color={m.ownerRegion === "CUST" ? "orange" : "gray"}
|
|
||||||
radius="sm"
|
|
||||||
tt="none"
|
|
||||||
>
|
|
||||||
{REGION_LABELS[m.ownerRegion]}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
<Text fz="11.5px" c="#9AA8B5" mt={1}>
|
|
||||||
{skipped
|
|
||||||
? "Skipped"
|
|
||||||
: done
|
|
||||||
? `Completed${m.triggeredAt ? ` · ${fmtDate(m.triggeredAt)}` : ""}`
|
|
||||||
: active
|
|
||||||
? "Current step"
|
|
||||||
: "Upcoming"}
|
|
||||||
{m.note ? ` · ${m.note}` : ""}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── contract & profile document grouping ─────────────────────────────────────
|
// ── contract & profile document grouping ─────────────────────────────────────
|
||||||
|
|
||||||
@@ -458,18 +350,8 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── customs progress (Path B phased clearance) ──────────────────── */}
|
{/* Customs progress lives in the unified journey wizard at the top of
|
||||||
{(clearance?.milestones?.length ?? 0) > 0 && (
|
the page (StatusHero → JourneyWizard) — no duplicate timeline here. */}
|
||||||
<SectionCard>
|
|
||||||
<CardTitle>Customs clearance progress</CardTitle>
|
|
||||||
<Text fz="12.5px" c="dimmed" mt={4} mb="md">
|
|
||||||
Every customs step for this shipment — completed steps are ticked,
|
|
||||||
the highlighted one is where it currently stands.
|
|
||||||
</Text>
|
|
||||||
<MilestoneTimeline milestones={clearance!.milestones!} />
|
|
||||||
</SectionCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(clearance?.workflowFiles?.length ?? 0) > 0 && (
|
{(clearance?.workflowFiles?.length ?? 0) > 0 && (
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<CardTitle>Customs documents</CardTitle>
|
<CardTitle>Customs documents</CardTitle>
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
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<JourneyStep>((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 (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(104px, 1fr))",
|
||||||
|
rowGap: 22,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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 (
|
||||||
|
<Box key={s.key} miw={0}>
|
||||||
|
{/* rail: left connector · circle · right connector */}
|
||||||
|
<Box style={{ display: "flex", alignItems: "center" }}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
height: 3,
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor:
|
||||||
|
idx === 0 ? "transparent" : leftOn ? "#0EA371" : "#E1E7EE",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
borderRadius: 999,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
backgroundColor: done
|
||||||
|
? "#0EA371"
|
||||||
|
: active
|
||||||
|
? "#0C1A2B"
|
||||||
|
: "#EEF2F6",
|
||||||
|
color: done || active ? "#fff" : "#9AA8B5",
|
||||||
|
border: done || active ? undefined : "1px solid #E1E7EE",
|
||||||
|
boxShadow: active ? "0 0 0 4px #D9E0E7" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{done ? <Check size={14} strokeWidth={3} /> : idx + 1}
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
height: 3,
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor:
|
||||||
|
idx === last ? "transparent" : rightOn ? "#0EA371" : "#E1E7EE",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* label (+ owner badge) centered under the circle */}
|
||||||
|
<Box mt={7} px={4} style={{ textAlign: "center" }}>
|
||||||
|
<Text
|
||||||
|
fz="11.5px"
|
||||||
|
fw={active ? 800 : 700}
|
||||||
|
lh={1.25}
|
||||||
|
c={done || active ? "#10202F" : "#9AA8B5"}
|
||||||
|
td={skipped ? "line-through" : undefined}
|
||||||
|
lineClamp={2}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</Text>
|
||||||
|
{ownerLabel && (
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color={s.owner === "CUST" ? "orange" : "gray"}
|
||||||
|
radius="sm"
|
||||||
|
tt="none"
|
||||||
|
mt={3}
|
||||||
|
>
|
||||||
|
{ownerLabel}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,10 @@ import type { ReactNode } from "react";
|
|||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
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";
|
import { bookingSubtitle, isDraftLike, isNegative } from "../utils";
|
||||||
|
|
||||||
@@ -50,9 +53,12 @@ export function PageHeader({
|
|||||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||||
<Stack gap={8} miw={0}>
|
<Stack gap={8} miw={0}>
|
||||||
<Group gap={12} align="center" wrap="wrap">
|
<Group gap={12} align="center" wrap="wrap">
|
||||||
<Text fz="26px" fw={800} c="#10202F">
|
<Stack gap={2} miw={0}>
|
||||||
{booking.reference}
|
<Text fz="26px" fw={800} c="#10202F">
|
||||||
</Text>
|
{booking.reference}
|
||||||
|
</Text>
|
||||||
|
<ContractReferenceLink booking={booking} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-[7px] rounded-full px-3 py-1.5 text-xs font-bold"
|
className="inline-flex items-center gap-[7px] rounded-full px-3 py-1.5 text-xs font-bold"
|
||||||
|
|||||||
@@ -1,31 +1,16 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import { Box, Button, FileButton, Group, Text } from "@mantine/core";
|
||||||
Badge,
|
import { Receipt, Upload } from "lucide-react";
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
FileButton,
|
|
||||||
Group,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
ThemeIcon,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { Check, Circle, Clock, Receipt, Upload } from "lucide-react";
|
|
||||||
import type { Freight } from "@edr/types";
|
|
||||||
|
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { SectionCard, CardTitle } from "./layout";
|
import { SectionCard, CardTitle } from "./layout";
|
||||||
|
|
||||||
const RISK_COLOR: Record<Freight.CustomsRiskLevel, string> = {
|
|
||||||
GREEN: "green",
|
|
||||||
YELLOW: "yellow",
|
|
||||||
RED: "red",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-only shipment tracking for the customer (Path B). Shows the GL milestone
|
* Customs (Path B) duty/tax panel: when GL has advised duty/tax but the slip is
|
||||||
* progression and, when GL has advised duty/tax but the slip is not yet paid,
|
* not yet paid, surfaces the payment-slip upload — the customer's only action
|
||||||
* surfaces a payment-slip upload — the only customer action in this phase.
|
* 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 }) {
|
export function ShipmentTrackingCard({ bookingId }: { bookingId: string }) {
|
||||||
const qc = useQueryClient();
|
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(
|
const dutyAdvised = milestones.find(
|
||||||
(m) => m.milestoneCode === "DUTY_TAXES_ADVISED",
|
(m) => m.milestoneCode === "DUTY_TAXES_ADVISED",
|
||||||
);
|
);
|
||||||
@@ -57,11 +36,14 @@ export function ShipmentTrackingCard({ bookingId }: { bookingId: string }) {
|
|||||||
const needsDutySlip =
|
const needsDutySlip =
|
||||||
dutyAdvised?.status === "COMPLETED" && dutyPaid?.status !== "COMPLETED";
|
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 (
|
return (
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<CardTitle>Shipment tracking</CardTitle>
|
<CardTitle>Duty & tax payment</CardTitle>
|
||||||
|
|
||||||
{needsDutySlip ? (
|
{needsDutySlip ? (
|
||||||
<Box
|
<Box
|
||||||
@@ -115,63 +97,6 @@ export function ShipmentTrackingCard({ bookingId }: { bookingId: string }) {
|
|||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Stack gap={0} mt="md">
|
|
||||||
{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 (
|
|
||||||
<Group key={m.id} gap="sm" wrap="nowrap" align="flex-start">
|
|
||||||
<Stack gap={0} align="center" style={{ flexShrink: 0 }}>
|
|
||||||
<ThemeIcon
|
|
||||||
variant={m.status === "COMPLETED" ? "filled" : "light"}
|
|
||||||
color={isNext ? "edr-green" : "gray"}
|
|
||||||
radius="xl"
|
|
||||||
size={26}
|
|
||||||
>
|
|
||||||
<Icon size={13} strokeWidth={2.2} />
|
|
||||||
</ThemeIcon>
|
|
||||||
{!isLast && (
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
width: 2,
|
|
||||||
flex: 1,
|
|
||||||
minHeight: 22,
|
|
||||||
background:
|
|
||||||
m.status === "COMPLETED"
|
|
||||||
? "var(--mantine-color-edr-green-4)"
|
|
||||||
: "var(--mantine-color-gray-2)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
<Box pb={isLast ? 0 : "sm"} style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Group gap={8} wrap="nowrap">
|
|
||||||
<Text
|
|
||||||
fz="sm"
|
|
||||||
fw={m.status === "COMPLETED" ? 600 : 500}
|
|
||||||
c={m.status === "COMPLETED" ? undefined : "dimmed"}
|
|
||||||
>
|
|
||||||
{m.milestoneLabel}
|
|
||||||
</Text>
|
|
||||||
{risk ? (
|
|
||||||
<Badge size="xs" color={RISK_COLOR[risk]} variant="filled">
|
|
||||||
{risk}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</Group>
|
|
||||||
</Box>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { Box, Group, Text } from "@mantine/core";
|
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 type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ARRIVAL_STAGE,
|
ARRIVAL_STAGE,
|
||||||
CONTRACT_ARRIVAL_STAGE,
|
CONTRACT_ARRIVAL_STAGE,
|
||||||
CONTRACT_PROGRESS_STAGES,
|
|
||||||
PROGRESS_STAGES,
|
|
||||||
STATUS_MAP,
|
STATUS_MAP,
|
||||||
resolveContractStage,
|
resolveContractStage,
|
||||||
resolveStage,
|
resolveStage,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils";
|
import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils";
|
||||||
|
import { JourneyWizard } from "./JourneyWizard";
|
||||||
import { SectionCard } from "./layout";
|
import { SectionCard } from "./layout";
|
||||||
|
|
||||||
/** Origin → destination strip rendered above the progress tracker. */
|
/** Origin → destination strip rendered above the progress tracker. */
|
||||||
@@ -73,13 +75,17 @@ export function StatusHero({
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const status = booking.status;
|
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
|
// Contract-drawdown bookings (initiated under a contract) follow a dedicated
|
||||||
// wizard — initiated → submitted → accepted → payment → … — instead of the
|
// wizard — initiated → submitted → accepted → payment → … — instead of the
|
||||||
// direct booking's Request/Approval/Contract stages.
|
// direct booking's Request/Approval/Contract stages.
|
||||||
const isContractDrawdown = Boolean(booking.contractId) && !isNegative(status);
|
const isContractDrawdown = Boolean(booking.contractId) && !isNegative(status);
|
||||||
const stages = isContractDrawdown
|
|
||||||
? CONTRACT_PROGRESS_STAGES
|
|
||||||
: PROGRESS_STAGES;
|
|
||||||
const arrivalStage = isContractDrawdown
|
const arrivalStage = isContractDrawdown
|
||||||
? CONTRACT_ARRIVAL_STAGE
|
? CONTRACT_ARRIVAL_STAGE
|
||||||
: ARRIVAL_STAGE;
|
: ARRIVAL_STAGE;
|
||||||
@@ -157,128 +163,8 @@ export function StatusHero({
|
|||||||
{!negative && <RouteStrip booking={booking} />}
|
{!negative && <RouteStrip booking={booking} />}
|
||||||
|
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<ProgressTracker
|
<JourneyWizard booking={booking} milestones={milestones} />
|
||||||
current={stage}
|
|
||||||
stages={stages}
|
|
||||||
tone={draft ? "ink" : "green"}
|
|
||||||
negative={negative}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 */
|
|
||||||
<Box
|
|
||||||
className="overflow-x-auto pt-2"
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
scrollbarWidth: "none",
|
|
||||||
WebkitOverflowScrolling: "touch",
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* ~84px per stage keeps 2-word labels readable; the box scrolls on mobile. */}
|
|
||||||
<div
|
|
||||||
className="flex items-start"
|
|
||||||
style={{ minWidth: Math.max(640, stages.length * 84) }}
|
|
||||||
>
|
|
||||||
{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 (
|
|
||||||
<div
|
|
||||||
key={stage.label}
|
|
||||||
className="flex flex-1 flex-col items-center"
|
|
||||||
>
|
|
||||||
<div className="flex w-full items-center">
|
|
||||||
{/* left connector */}
|
|
||||||
<div
|
|
||||||
className="flex-1 rounded-full"
|
|
||||||
style={{
|
|
||||||
height: 3,
|
|
||||||
background:
|
|
||||||
idx === 0
|
|
||||||
? "transparent"
|
|
||||||
: reachedLeft
|
|
||||||
? "#0EA371"
|
|
||||||
: "#E1E7EE",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* stage circle */}
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-center mb-2 rounded-full shrink-0"
|
|
||||||
style={{
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
backgroundColor: circleBg,
|
|
||||||
border: circleBorder,
|
|
||||||
boxShadow: circleShadow,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{state === "done" ? (
|
|
||||||
<Check size={16} color="#fff" />
|
|
||||||
) : state === "active" ? (
|
|
||||||
<Icon size={16} color="#fff" />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{/* right connector */}
|
|
||||||
<div
|
|
||||||
className="flex-1 rounded-full"
|
|
||||||
style={{
|
|
||||||
height: 3,
|
|
||||||
background:
|
|
||||||
idx === last
|
|
||||||
? "transparent"
|
|
||||||
: reachedRight
|
|
||||||
? "#0EA371"
|
|
||||||
: "#E1E7EE",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Text
|
|
||||||
fz="14px"
|
|
||||||
fw={700}
|
|
||||||
ta="center"
|
|
||||||
c={state === "idle" ? "#9AA8B5" : "#10202F"}
|
|
||||||
>
|
|
||||||
{stage.label}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Badge, Box, Group, Text } from "@mantine/core";
|
import { Badge, Box, Group, Text } from "@mantine/core";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
|
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.
|
* 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<Freight.IBooking, "contractId" | "contractReference">;
|
||||||
|
}) {
|
||||||
|
if (!booking.contractId || !booking.contractReference) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
component={Link}
|
||||||
|
to={`/contracts/${booking.contractId}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
fz={11}
|
||||||
|
c="edr-muted"
|
||||||
|
truncate
|
||||||
|
style={{ display: "block", textDecoration: "underline" }}
|
||||||
|
>
|
||||||
|
{booking.contractReference}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Title-case an unmapped enum as a readable fallback ("FOO_BAR" → "Foo Bar"). */
|
/** Title-case an unmapped enum as a readable fallback ("FOO_BAR" → "Foo Bar"). */
|
||||||
export function titleCaseStatus(status: string): string {
|
export function titleCaseStatus(status: string): string {
|
||||||
return status
|
return status
|
||||||
|
|||||||
@@ -141,9 +141,13 @@ const BUSINESS_LICENSE_DOC_CODES = new Set([
|
|||||||
// documents" rather than the clearance set.
|
// documents" rather than the clearance set.
|
||||||
const PROFILE_DOC_CODES = new Set([
|
const PROFILE_DOC_CODES = new Set([
|
||||||
"tin_certificate",
|
"tin_certificate",
|
||||||
|
"tin",
|
||||||
"national_id",
|
"national_id",
|
||||||
"national_id_passport",
|
"national_id_passport",
|
||||||
"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 {
|
interface DocGroup {
|
||||||
@@ -164,23 +168,44 @@ interface DocGroup {
|
|||||||
*/
|
*/
|
||||||
function groupContractDocuments(
|
function groupContractDocuments(
|
||||||
files: ContractFile[],
|
files: ContractFile[],
|
||||||
{ includeClearance = true }: { includeClearance?: boolean } = {},
|
{
|
||||||
|
includeClearance = true,
|
||||||
|
clearanceKeys,
|
||||||
|
}: { includeClearance?: boolean; clearanceKeys?: Set<string> } = {},
|
||||||
): DocGroup[] {
|
): DocGroup[] {
|
||||||
const businessLicense: ContractFile[] = [];
|
const businessLicense: ContractFile[] = [];
|
||||||
const profile: ContractFile[] = [];
|
const profile: ContractFile[] = [];
|
||||||
const clearance: 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 `_<n>`), 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) {
|
for (const f of files) {
|
||||||
// The generated contract PDF lives in the contract list / home rows, not
|
// The generated contract PDF lives in the contract list / home rows, not
|
||||||
// here. Signature images are baked into that PDF — skip both.
|
// here. Signature images are baked into that PDF — skip both.
|
||||||
if (f.code === "contract" || f.code.startsWith("signature_")) continue;
|
if (f.code === "contract" || f.code.startsWith("signature_")) continue;
|
||||||
else if (BUSINESS_LICENSE_DOC_CODES.has(f.code)) businessLicense.push(f);
|
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 (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 [
|
return [
|
||||||
{ key: "clearance", title: "Clearance documents", files: clearance },
|
{ key: "profile", title: "Company profile", files: profile },
|
||||||
{ key: "businessLicense", title: "Business license", files: businessLicense },
|
{ 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);
|
].filter((g) => g.files.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,8 +371,13 @@ export default function ContractDetailPage() {
|
|||||||
const files = contract.files ?? [];
|
const files = contract.files ?? [];
|
||||||
// GENERAL contracts clear per booking — clearance documents live on each
|
// GENERAL contracts clear per booking — clearance documents live on each
|
||||||
// booking's detail page, so this tab keeps only profile/licence documents.
|
// 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, {
|
const docGroups = groupContractDocuments(files, {
|
||||||
includeClearance: contract.contractKind !== "GENERAL",
|
includeClearance: contract.contractKind !== "GENERAL",
|
||||||
|
clearanceKeys: new Set(
|
||||||
|
(clearanceView?.documents ?? []).map((d) => d.fileKey),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
const docCount = docGroups.reduce((sum, g) => sum + g.files.length, 0);
|
const docCount = docGroups.reduce((sum, g) => sum + g.files.length, 0);
|
||||||
// The generated contract PDF — surfaced via a dedicated "View contract" button
|
// The generated contract PDF — surfaced via a dedicated "View contract" button
|
||||||
@@ -1190,21 +1220,13 @@ export default function ContractDetailPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Card
|
{docGroups.length === 0 ? (
|
||||||
withBorder
|
<Card
|
||||||
radius="lg"
|
withBorder
|
||||||
p="lg"
|
radius="lg"
|
||||||
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
|
p="lg"
|
||||||
>
|
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
|
||||||
<Group justify="space-between" align="center" mb="md">
|
>
|
||||||
<SectionLabel>Contract documents</SectionLabel>
|
|
||||||
{contract.contractGeneratedAt && (
|
|
||||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
|
||||||
Generated
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
{docGroups.length === 0 ? (
|
|
||||||
<Stack align="center" gap={10} py={48}>
|
<Stack align="center" gap={10} py={48}>
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
@@ -1228,55 +1250,56 @@ export default function ContractDetailPage() {
|
|||||||
: "The signed contract and any uploaded clearance documents will appear here."}
|
: "The signed contract and any uploaded clearance documents will appear here."}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
</Card>
|
||||||
<Stack gap="xl">
|
) : (
|
||||||
{docGroups.map((group) => {
|
// One card per section — profile, business licence, clearance,
|
||||||
const accent = DOC_GROUP_ACCENT[group.key] ?? GREEN;
|
// other — instead of a single flat list.
|
||||||
const Icon = DOC_GROUP_ICON[group.key] ?? FileText;
|
docGroups.map((group) => {
|
||||||
return (
|
const accent = DOC_GROUP_ACCENT[group.key] ?? GREEN;
|
||||||
<Stack key={group.key} gap={10}>
|
const Icon = DOC_GROUP_ICON[group.key] ?? FileText;
|
||||||
<Group gap={10} align="center">
|
return (
|
||||||
<Box
|
<Card
|
||||||
style={{
|
key={group.key}
|
||||||
width: 28,
|
withBorder
|
||||||
height: 28,
|
radius="lg"
|
||||||
borderRadius: 8,
|
p="lg"
|
||||||
display: "flex",
|
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
|
||||||
alignItems: "center",
|
>
|
||||||
justifyContent: "center",
|
<Group gap={12} align="center" mb={4}>
|
||||||
background: `${accent}14`,
|
<Box
|
||||||
color: accent,
|
style={{
|
||||||
}}
|
width: 34,
|
||||||
>
|
height: 34,
|
||||||
<Icon size={15} />
|
borderRadius: 10,
|
||||||
</Box>
|
display: "flex",
|
||||||
<Text fz={13} fw={700} style={{ color: INK }}>
|
alignItems: "center",
|
||||||
{group.title}
|
justifyContent: "center",
|
||||||
</Text>
|
background: `${accent}14`,
|
||||||
<Badge
|
color: accent,
|
||||||
size="sm"
|
flexShrink: 0,
|
||||||
variant="light"
|
}}
|
||||||
color="gray"
|
>
|
||||||
radius="sm"
|
<Icon size={17} />
|
||||||
>
|
</Box>
|
||||||
{group.files.length}
|
<Text fz={14.5} fw={700} style={{ color: INK }}>
|
||||||
</Badge>
|
{group.title}
|
||||||
</Group>
|
</Text>
|
||||||
<Stack gap={10}>
|
<Badge size="sm" variant="light" color="gray" radius="sm">
|
||||||
{group.files.map((file) => (
|
{group.files.length}
|
||||||
<DocFileRow
|
</Badge>
|
||||||
key={file.id}
|
</Group>
|
||||||
file={file}
|
<Text fz={12.5} c="dimmed" mb="md" ml={46}>
|
||||||
onView={view}
|
{DOC_GROUP_DESC[group.key] ?? ""}
|
||||||
/>
|
</Text>
|
||||||
))}
|
<Stack gap={10}>
|
||||||
</Stack>
|
{group.files.map((file) => (
|
||||||
</Stack>
|
<DocFileRow key={file.id} file={file} onView={view} />
|
||||||
);
|
))}
|
||||||
})}
|
</Stack>
|
||||||
</Stack>
|
</Card>
|
||||||
)}
|
);
|
||||||
</Card>
|
})
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
@@ -1579,16 +1602,26 @@ const KEY_FACT_ACCENT: Record<string, string> = {
|
|||||||
orange: "#C77F09",
|
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<string, string> = {
|
const DOC_GROUP_ACCENT: Record<string, string> = {
|
||||||
clearance: "#C77F09",
|
clearance: "#C77F09",
|
||||||
businessLicense: "#0A6F4D",
|
businessLicense: "#0A6F4D",
|
||||||
profile: "#2B6CB0",
|
profile: "#2B6CB0",
|
||||||
|
other: "#64748B",
|
||||||
};
|
};
|
||||||
const DOC_GROUP_ICON: Record<string, LucideIcon> = {
|
const DOC_GROUP_ICON: Record<string, LucideIcon> = {
|
||||||
clearance: Upload,
|
clearance: Upload,
|
||||||
businessLicense: FileBadge,
|
businessLicense: FileBadge,
|
||||||
profile: FileText,
|
profile: FileText,
|
||||||
|
other: FileText,
|
||||||
|
};
|
||||||
|
const DOC_GROUP_DESC: Record<string, string> = {
|
||||||
|
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.",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
StatCard,
|
StatCard,
|
||||||
} from "./contract-ui";
|
} from "./contract-ui";
|
||||||
import { ContractStepBanner } from "./ContractStepBanner";
|
import { ContractStepBanner } from "./ContractStepBanner";
|
||||||
|
import "./contracts-table.css";
|
||||||
|
|
||||||
function primaryRoute(contract: Freight.IContract) {
|
function primaryRoute(contract: Freight.IContract) {
|
||||||
const route = contract.routes?.[0];
|
const route = contract.routes?.[0];
|
||||||
@@ -367,11 +368,15 @@ export default function ContractsList() {
|
|||||||
>
|
>
|
||||||
<Box style={{ overflowX: "auto" }}>
|
<Box style={{ overflowX: "auto" }}>
|
||||||
<Table
|
<Table
|
||||||
|
className="edr-contracts-table"
|
||||||
verticalSpacing={14}
|
verticalSpacing={14}
|
||||||
horizontalSpacing={20}
|
horizontalSpacing={10}
|
||||||
highlightOnHover
|
highlightOnHover
|
||||||
highlightOnHoverColor="#F4FBF8"
|
highlightOnHoverColor="#F4FBF8"
|
||||||
styles={{
|
styles={{
|
||||||
|
// Sticky positioning, z-index and wrapping live in
|
||||||
|
// contracts-table.css — Mantine's `styles` prop emits inline
|
||||||
|
// styles, which would outrank the sticky action column there.
|
||||||
th: {
|
th: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
@@ -380,10 +385,6 @@ export default function ContractsList() {
|
|||||||
color: MUTED,
|
color: MUTED,
|
||||||
background: "#F8FAFC",
|
background: "#F8FAFC",
|
||||||
borderBottom: `1px solid ${BORDER}`,
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
whiteSpace: "nowrap",
|
|
||||||
position: "sticky",
|
|
||||||
top: 0,
|
|
||||||
zIndex: 1,
|
|
||||||
},
|
},
|
||||||
tr: {
|
tr: {
|
||||||
transition: "background-color 120ms ease",
|
transition: "background-color 120ms ease",
|
||||||
@@ -463,6 +464,9 @@ export default function ContractsList() {
|
|||||||
return (
|
return (
|
||||||
<Fragment key={c.id}>
|
<Fragment key={c.id}>
|
||||||
<Table.Tr
|
<Table.Tr
|
||||||
|
// Read by contracts-table.css to keep the sticky
|
||||||
|
// action cell's opaque background in step with the row.
|
||||||
|
data-expanded={isOpen ? "true" : undefined}
|
||||||
style={{
|
style={{
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
background: isOpen ? "#F4FBF8" : undefined,
|
background: isOpen ? "#F4FBF8" : undefined,
|
||||||
|
|||||||
@@ -1656,7 +1656,6 @@ function ContainerLineEditor({
|
|||||||
field.onChange(e.currentTarget.value);
|
field.onChange(e.currentTarget.value);
|
||||||
const qty = Number(e.currentTarget.value || 0);
|
const qty = Number(e.currentTarget.value || 0);
|
||||||
syncUnits(qty);
|
syncUnits(qty);
|
||||||
clampHandlingCounts(qty);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 <tr> 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user