fix conflict

This commit is contained in:
hagiye
2026-06-25 11:31:50 +03:00
168 changed files with 8445 additions and 2583 deletions

View File

@@ -8,6 +8,7 @@ import { useAuth } from "@/auth/useAuth";
import {
getNextPendingApprovalStep,
isAllocateAction,
isClearanceNavAction,
isContractNavAction,
listRowHasActions,
type BookingActionContext,
@@ -38,6 +39,7 @@ export function BookingActionsMenu({
reference: row.reference,
approvalSteps: row.approvalSteps,
schedulingStatus: row.schedulingStatus,
customsClearingEnabled: row.customsClearingEnabled,
};
const flow = useBookingActionDialog(row.id, context);
@@ -46,10 +48,15 @@ export function BookingActionsMenu({
const goToContract = () =>
navigate(`/dashboard/booking-requests/${row.id}/contract`);
const goToClearanceTab = () =>
navigate(`/dashboard/booking-requests/${row.id}?tab=clearance`);
const handleAction = (action: (typeof actions)[number]) => {
onSuppressRowClick?.();
if (isContractNavAction(action.id)) {
goToContract();
} else if (isClearanceNavAction(action.id)) {
goToClearanceTab();
} else if (isAllocateAction(action.id)) {
onAllocateBooking?.();
} else {

View File

@@ -2,9 +2,11 @@ import { Badge, ScrollArea, Tabs } from "@mantine/core";
import {
CheckCircle,
ClipboardCheck,
ClipboardList,
FileSignature,
Inbox,
LayoutGrid,
ShieldCheck,
Train,
Wallet,
XCircle,
@@ -21,7 +23,9 @@ const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
intake: <Inbox size={17} strokeWidth={1.85} />,
in_approval: <ClipboardCheck size={17} strokeWidth={1.85} />,
approved_contract: <FileSignature size={17} strokeWidth={1.85} />,
clearance: <ShieldCheck size={17} strokeWidth={1.85} />,
payment: <Wallet size={17} strokeWidth={1.85} />,
ops_review: <ClipboardList size={17} strokeWidth={1.85} />,
operations: <Train size={17} strokeWidth={1.85} />,
completed: <CheckCircle size={17} strokeWidth={1.85} />,
closed: <XCircle size={17} strokeWidth={1.85} />,

View File

@@ -1,4 +1,4 @@
import { Train, MapPin, ArrowRight } from "lucide-react";
import { Train, MapPin, ArrowRight, FileText } from "lucide-react";
import { Group, Stack, Text, Badge, Box, SimpleGrid } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
@@ -44,6 +44,8 @@ export function BookingRouteServiceCard({
const serviceLabel =
booking.serviceType?.label ?? booking.serviceType?.code ?? "Rail service";
const includesCustoms = booking.serviceType?.includesCustoms;
const metrics = [
{ label: "Trade direction", value: booking.tradeDirection },
{ label: "Freight type", value: booking.freightType },
@@ -96,6 +98,47 @@ export function BookingRouteServiceCard({
<MetricTile key={m.label} label={m.label} value={m.value} />
))}
</SimpleGrid>
{includesCustoms ? (
<Box
mt="md"
px={14}
py={10}
style={{
borderRadius: 10,
border: "1.5px solid #CDEBDD",
background: "#F6FBF8",
}}
>
<Group gap={10} align="center">
<FileText size={15} color="#0A6F4D" />
<Text fz={13} fw={600} c="#0A6F4D">
Customs clearing included automatically
</Text>
</Group>
</Box>
) : booking.customsClearingAgent ? (
<Box
mt="md"
px={14}
py={10}
style={{
borderRadius: 10,
border: "1.5px solid #E6ECF2",
background: "#F8FAFC",
}}
>
<Group gap={10} align="center">
<FileText size={15} color="#64748B" />
<Text fz={13} fw={500} c="#374151">
Customs clearing agent:{" "}
<Text component="span" fw={700} c="#10202F">
{booking.customsClearingAgent}
</Text>
</Text>
</Group>
</Box>
) : null}
</SectionCard>
);
}

View File

@@ -5,15 +5,13 @@ import {
Badge,
Box,
Button,
Card,
FileButton,
Group,
Loader,
Paper,
Progress,
ScrollArea,
Stack,
Text,
TextInput,
Textarea,
ThemeIcon,
Tooltip,
@@ -21,257 +19,63 @@ import {
import {
AlertCircle,
CheckCircle2,
Clock,
Download,
ExternalLink,
FileCheck2,
FileText,
Inbox,
MessageSquareWarning,
Search,
ShieldCheck,
Upload,
X,
} from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "./SectionCard";
import { bookingsService } from "@/services/bookings.service";
const REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW";
export default function GlClearancePage() {
const qc = useQueryClient();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState("");
// Bookings currently awaiting GL document review.
const { data: list, isLoading } = useQuery({
queryKey: ["gl-clearance", "list"],
queryFn: () =>
bookingsService.list({ status: REVIEW_STATUS, pageSize: 100 }),
});
const bookings = list?.items ?? [];
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return bookings;
return bookings.filter(
(b) =>
b.reference?.toLowerCase().includes(q) ||
b.tradeDirection?.toLowerCase().includes(q) ||
b.freightType?.toLowerCase().includes(q),
);
}, [bookings, search]);
const activeId =
selectedId && filtered.some((b) => b.id === selectedId)
? selectedId
: (filtered[0]?.id ?? null);
return (
<PageContainer>
<PageHeader
title="Document Clearance"
subtitle="Review customer documents, approve or raise a query, and finalize clearance."
meta={
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={13} />}
>
{bookings.length} awaiting review
</Badge>
}
/>
<div className="flex flex-col gap-5 lg:flex-row lg:items-start">
{/* ── Review queue ─────────────────────────────────────────────── */}
<Card
withBorder
shadow="sm"
radius="lg"
p="sm"
className="w-full shrink-0 lg:w-[320px]"
>
<Group justify="space-between" align="center" mb="xs" px={4}>
<Text fz="13px" fw={700} c="edr-text">
Review queue
</Text>
<Badge size="sm" variant="default" radius="sm">
{filtered.length}
</Badge>
</Group>
<TextInput
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
placeholder="Search reference…"
size="xs"
radius="md"
mb="xs"
leftSection={<Search size={14} />}
rightSection={
search ? (
<X
size={14}
style={{ cursor: "pointer" }}
onClick={() => setSearch("")}
/>
) : null
}
/>
{isLoading ? (
<Group justify="center" py="lg" gap={8}>
<Loader size="xs" color="edr-green" />
<Text fz="13px" c="dimmed">
Loading
</Text>
</Group>
) : filtered.length === 0 ? (
<Stack align="center" gap={6} py="xl">
<ThemeIcon variant="light" color="gray" radius="xl" size={40}>
<Inbox size={20} />
</ThemeIcon>
<Text fz="13px" c="dimmed" ta="center">
{search
? "No bookings match your search."
: "Nothing awaiting document review."}
</Text>
</Stack>
) : (
<ScrollArea.Autosize mah={620} type="hover" offsetScrollbars>
<Stack gap={6}>
{filtered.map((b) => (
<QueueItem
key={b.id}
booking={b}
active={b.id === activeId}
onSelect={() => setSelectedId(b.id)}
/>
))}
</Stack>
</ScrollArea.Autosize>
)}
</Card>
{/* ── Review panel ─────────────────────────────────────────────── */}
<Box style={{ flex: 1, minWidth: 0 }}>
{activeId ? (
<ClearanceReviewPanel
key={activeId}
bookingId={activeId}
onChanged={() =>
qc.invalidateQueries({ queryKey: ["gl-clearance", "list"] })
}
/>
) : (
<EmptyPanel />
)}
</Box>
</div>
</PageContainer>
);
export interface ClearanceReviewSectionProps {
bookingId: string;
/** Called after any review/finalize mutation so the parent can refetch. */
onChanged?: () => void;
/** Hide the inline progress summary (e.g. when the parent renders its own). */
hideSummary?: boolean;
}
/** A single booking row in the left-hand review queue. */
function QueueItem({
booking,
active,
onSelect,
}: {
booking: Freight.IBooking;
active: boolean;
onSelect: () => void;
}) {
return (
<Box
component="button"
type="button"
onClick={onSelect}
ta="left"
p="xs"
style={{
cursor: "pointer",
borderRadius: 12,
border: "1px solid",
borderColor: active
? "var(--mantine-color-edr-green-5)"
: "var(--mantine-color-edr-border-6)",
background: active
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-edr-card-6)",
transition: "all 120ms ease",
}}
>
<Group justify="space-between" wrap="nowrap" gap={8}>
<Box style={{ minWidth: 0 }}>
<Text fz="13.5px" fw={700} c="edr-text" truncate>
{booking.reference}
</Text>
<Group gap={6} mt={3} wrap="nowrap">
<Badge
size="xs"
variant="light"
radius="sm"
color={
booking.tradeDirection === "IMPORT" ? "edr-blue" : "edr-accent"
}
>
{booking.tradeDirection}
</Badge>
<Text fz="11px" c="edr-muted" truncate>
{booking.freightType}
</Text>
</Group>
</Box>
</Group>
</Box>
);
}
const STATUS_META: Record<
Freight.DocumentReviewStatus,
{ label: string; color: string }
> = {
APPROVED: { label: "Approved", color: "edr-green" },
QUERIED: { label: "Queried", color: "red" },
PENDING: { label: "Pending", color: "gray" },
};
function EmptyPanel() {
return (
<Card withBorder shadow="sm" radius="lg" p={48}>
<Stack align="center" gap={10}>
<ThemeIcon variant="light" color="edr-green" radius="xl" size={56}>
<ShieldCheck size={28} />
</ThemeIcon>
<Text fw={700} c="edr-text">
No booking selected
</Text>
<Text fz="13px" c="dimmed" ta="center" maw={320}>
Pick a booking from the review queue to inspect its customer documents
and start clearance.
</Text>
</Stack>
</Card>
);
}
function ClearanceReviewPanel({
/**
* Staff-facing clearance document review: approve / query each customer
* document, upload customs output documents (customs bookings only) and
* finalize once every required document is approved. Shared by the Global
* Logistics clearance detail page (customs) and the Marketing booking detail
* (non-customs) the only difference is the output-docs block, which renders
* only when the booking has a customs output set.
*/
export function ClearanceReviewSection({
bookingId,
onChanged,
}: {
bookingId: string;
onChanged: () => void;
}) {
hideSummary,
}: ClearanceReviewSectionProps) {
const qc = useQueryClient();
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
const [outputFiles, setOutputFiles] = useState<Record<string, File>>({});
const { data: clearance, isLoading } = useQuery({
queryKey: ["gl-clearance", bookingId],
queryKey: ["clearance", bookingId],
queryFn: () => bookingsService.getClearance(bookingId),
});
const refresh = () => {
qc.invalidateQueries({ queryKey: ["gl-clearance", bookingId] });
onChanged();
qc.invalidateQueries({ queryKey: ["clearance", bookingId] });
qc.invalidateQueries({ queryKey: ["clearance", "list"] });
onChanged?.();
};
const reviewMutation = useMutation({
@@ -292,8 +96,7 @@ function ClearanceReviewPanel({
});
const outputMutation = useMutation({
mutationFn: () =>
bookingsService.uploadClearanceOutput(bookingId, outputFiles),
mutationFn: () => bookingsService.uploadClearanceOutput(bookingId, outputFiles),
onSuccess: () => {
toast.success("Output documents uploaded");
setOutputFiles({});
@@ -323,7 +126,6 @@ function ClearanceReviewPanel({
[clearance],
);
// Review progress across the customer documents — drives the summary bar.
const stats = useMemo(() => {
const total = customerDocs.length;
const approved = customerDocs.filter(
@@ -333,120 +135,97 @@ function ClearanceReviewPanel({
(d) => d.reviewStatus === "QUERIED",
).length;
const pending = total - approved - queried;
return { total, approved, queried, pending };
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
return { total, approved, queried, pending, pct };
}, [customerDocs]);
if (isLoading || !clearance) {
return (
<Card withBorder shadow="sm" radius="lg" p={48}>
<Group justify="center" gap={10}>
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading clearance</Text>
</Group>
</Card>
<Group justify="center" py="xl" gap={10}>
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading clearance</Text>
</Group>
);
}
const progressPct =
stats.total === 0 ? 0 : Math.round((stats.approved / stats.total) * 100);
return (
<Stack gap="md">
{/* ── Progress summary ───────────────────────────────────────────── */}
<Card withBorder shadow="sm" radius="lg" p="lg">
<Group justify="space-between" align="flex-start" mb="md">
<Box>
<Text fw={700} fz="15px" c="edr-text">
Customer documents
</Text>
<Text fz="12.5px" c="dimmed" mt={2}>
Approve each document, or open a query to tell the customer what to
fix.
</Text>
</Box>
{clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
size="lg"
leftSection={<CheckCircle2 size={14} />}
>
All approved
</Badge>
) : (
<Badge
variant="light"
color="edr-blue"
radius="sm"
size="lg"
leftSection={<Clock size={14} />}
>
Review pending
</Badge>
)}
</Group>
<Progress
value={progressPct}
color="edr-green"
radius="xl"
size="sm"
mb="sm"
/>
<Group gap="lg">
<StatPill color="edr-green" label="Approved" value={stats.approved} />
<StatPill color="red" label="Queried" value={stats.queried} />
<StatPill color="edr-slate" label="Pending" value={stats.pending} />
<Text fz="12.5px" c="dimmed" ml="auto">
<Stack gap="lg">
<SectionCard
icon={FileText}
title="Customer documents"
subtitle="Approve each document, or open a query to tell the customer what to fix."
extra={
<Text size="xs" c="dimmed" fw={600}>
{stats.approved}/{stats.total} approved
</Text>
</Group>
</Card>
{/* ── Document review list ───────────────────────────────────────── */}
<Stack gap={12}>
{customerDocs.map((doc) => (
<DocReviewCard
key={`${doc.settingCode}:${doc.fileKey}`}
doc={doc}
note={queryNotes[doc.fileKey] ?? ""}
queryOpen={openQuery[doc.fileKey] ?? false}
onToggleQuery={(open) =>
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
}
onNote={(v) => setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))}
onApprove={() =>
reviewMutation.mutate({ fileKey: doc.fileKey, status: "APPROVED" })
}
onQuery={() =>
reviewMutation.mutate({
fileKey: doc.fileKey,
status: "QUERIED",
note: queryNotes[doc.fileKey],
})
}
busy={reviewMutation.isPending}
/>
))}
</Stack>
{/* ── Customs output documents (GL-supplied) ─────────────────────── */}
{clearance.outputCode && (
<Card withBorder shadow="sm" radius="lg" p="lg">
<Group gap={8} mb="md">
<ThemeIcon variant="light" color="edr-blue" radius="md" size={28}>
<Upload size={15} />
</ThemeIcon>
<Text fw={700} c="edr-text">
Customs output documents
}
>
<Stack gap={12}>
{!hideSummary && stats.total > 0 && (
<Box>
<Progress
value={stats.pct}
color="edr-green"
radius="xl"
size="sm"
mb={6}
/>
<Group gap="lg">
<StatPill color="edr-green" label="Approved" value={stats.approved} />
<StatPill color="red" label="Queried" value={stats.queried} />
<StatPill color="gray" label="Pending" value={stats.pending} />
</Group>
</Box>
)}
{customerDocs.length === 0 ? (
<Text size="sm" c="dimmed">
No customer documents are required for this booking.
</Text>
</Group>
) : (
customerDocs.map((doc) => (
<DocReviewCard
key={`${doc.settingCode}:${doc.fileKey}`}
doc={doc}
note={queryNotes[doc.fileKey] ?? ""}
queryOpen={openQuery[doc.fileKey] ?? false}
onToggleQuery={(open) =>
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
}
onNote={(v) =>
setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))
}
onApprove={() =>
reviewMutation.mutate({
fileKey: doc.fileKey,
status: "APPROVED",
})
}
onQuery={() =>
reviewMutation.mutate({
fileKey: doc.fileKey,
status: "QUERIED",
note: queryNotes[doc.fileKey],
})
}
busy={reviewMutation.isPending}
/>
))
)}
</Stack>
</SectionCard>
{clearance.outputCode && (
<SectionCard
icon={Upload}
title="Customs output documents"
subtitle="Upload the cleared/customs paperwork to hand back to the customer."
accent="edr-green"
>
<Stack gap={10}>
{glDocs.map((doc) => (
<Group key={doc.fileKey} justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<FileText size={16} color="var(--mantine-color-edr-blue-6)" />
<FileText size={16} color="var(--mantine-color-edr-green-6)" />
<Text fz="13px" c="edr-text" truncate>
{doc.label}
{doc.required ? " *" : ""}
@@ -460,7 +239,7 @@ function ClearanceReviewPanel({
href={doc.file.url}
target="_blank"
rel="noreferrer"
c="edr-blue"
c="edr-green"
style={{ display: "flex" }}
>
<Download size={15} />
@@ -506,7 +285,7 @@ function ClearanceReviewPanel({
Upload output documents
</Button>
</Group>
</Card>
</SectionCard>
)}
{finalizeMutation.isError && (
@@ -517,14 +296,23 @@ function ClearanceReviewPanel({
</Alert>
)}
{/* ── Finalize bar ───────────────────────────────────────────────── */}
<Card withBorder shadow="sm" radius="lg" p="md">
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
<Text fz="12.5px" c="dimmed">
{clearance.allApproved
? "All required documents are approved. You can finalize clearance."
: "Approve every required document to unlock finalization."}
</Text>
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={clearance.allApproved ? "edr-green" : "gray"}
radius="md"
size={28}
>
<FileCheck2 size={15} />
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
{clearance.allApproved
? "All required documents are approved — you can finalize."
: "Approve every required document to unlock finalization."}
</Text>
</Group>
<Button
color="edr-green"
radius="md"
@@ -536,7 +324,7 @@ function ClearanceReviewPanel({
Finalize clearance
</Button>
</Group>
</Card>
</Paper>
</Stack>
);
}
@@ -570,16 +358,6 @@ function StatPill({
);
}
/** Visual treatment for each document review state. */
const STATUS_META: Record<
Freight.DocumentReviewStatus,
{ label: string; color: string }
> = {
APPROVED: { label: "Approved", color: "edr-green" },
QUERIED: { label: "Queried", color: "red" },
PENDING: { label: "Pending", color: "edr-slate" },
};
function DocReviewCard({
doc,
note,
@@ -604,10 +382,9 @@ function DocReviewCard({
const hasFile = !!doc.file;
return (
<Card
<Paper
withBorder
shadow="sm"
radius="lg"
radius="md"
p="md"
style={{
borderColor:
@@ -622,7 +399,7 @@ function DocReviewCard({
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={hasFile ? "edr-blue" : "gray"}
color={hasFile ? "edr-green" : "gray"}
radius="md"
size={40}
>
@@ -662,7 +439,6 @@ function DocReviewCard({
</Group>
</Group>
{/* Previously raised query — visible so staff see what was asked. */}
{status === "QUERIED" && doc.note && (
<Alert
mt="sm"
@@ -678,7 +454,6 @@ function DocReviewCard({
</Alert>
)}
{/* Action row — only when the customer actually uploaded a file. */}
{hasFile && (
<Box mt="sm">
{!queryOpen ? (
@@ -760,6 +535,6 @@ function DocReviewCard({
)}
</Box>
)}
</Card>
</Paper>
);
}

View File

@@ -0,0 +1,260 @@
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import {
Badge,
Box,
Card,
Center,
Group,
Loader,
Progress,
RingProgress,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { ChevronRight, Inbox, PackageCheck } from "lucide-react";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import {
useContractOrders,
useContractPool,
} from "@/hooks/bookings/useContractOrders";
import type { Freight } from "@edr/types";
export interface ContractOrdersPanelProps {
/** The general-contract booking whose drawdown orders are listed. */
contractBookingId: string;
/** Whether the contract is container-based (affects quantity labels). */
isContainer: boolean;
}
/** Format a contracted/remaining quantity with its unit. */
function formatQuantity(
qty: number,
unit: Freight.ContractQuantityLine["unitOfMeasure"],
isContainerLine: boolean,
): string {
const rounded = Number.isInteger(qty) ? qty : Number(qty.toFixed(2));
if (isContainerLine) return `${rounded} containers`;
if (unit === "PER_ITEM") return `${rounded} items`;
return `${rounded} tons`;
}
/** Summarise an order's lines, e.g. "2 20FT, 1 40FT" or "15". */
function summariseLines(lines: Freight.IBookingOrderLine[]): string {
return lines
.map((l) => {
const qty = Number(l.quantity);
const label = Number.isInteger(qty) ? `${qty}` : qty.toFixed(2);
return `${label}${l.containerTypeName ? ` ${l.containerTypeName}` : ""}`;
})
.join(", ");
}
/**
* Backoffice "Orders" tab for a general contract: shows the drawdown pool and
* lists each order placed against the contract. Each order links to its child
* booking's detail page, where staff approve it and review clearance/customer
* documents independently (same screen as a one-time booking).
*/
export function ContractOrdersPanel({
contractBookingId,
isContainer,
}: ContractOrdersPanelProps) {
const navigate = useNavigate();
const { data: orders, isLoading: ordersLoading } =
useContractOrders(contractBookingId);
const { data: pool, isLoading: poolLoading } =
useContractPool(contractBookingId);
const poolLines = pool ?? [];
const totals = useMemo(() => {
const contracted = poolLines.reduce(
(s, l) => s + (l.contractedQuantity || 0),
0,
);
const ordered = poolLines.reduce((s, l) => s + (l.orderedQuantity || 0), 0);
const pct = contracted > 0 ? Math.round((ordered / contracted) * 100) : 0;
return { contracted, ordered, pct };
}, [poolLines]);
if (ordersLoading || poolLoading) {
return (
<Center mih={240}>
<Loader color="gray" />
</Center>
);
}
return (
<Stack gap="lg">
{/* Drawdown pool */}
<Card withBorder radius="md" p="lg">
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="lg">
<Box>
<Text fw={700} fz={16}>
Contracted quantity
</Text>
<Text fz={13} c="dimmed" mt={2}>
How much of this contract has been ordered versus what remains.
</Text>
</Box>
{totals.contracted > 0 && (
<RingProgress
size={72}
thickness={7}
roundCaps
sections={[{ value: totals.pct, color: "edr-green" }]}
label={
<Text ta="center" fz={13} fw={800}>
{totals.pct}%
</Text>
}
/>
)}
</Group>
<Stack gap="lg">
{poolLines.length === 0 && (
<Text fz={13} c="dimmed">
No quantity pool available.
</Text>
)}
{poolLines.map((line, i) => {
const pct =
line.contractedQuantity > 0
? Math.min(
100,
(line.orderedQuantity / line.contractedQuantity) * 100,
)
: 0;
const label = isContainer
? (line.containerTypeName ?? "Containers")
: line.unitOfMeasure === "PER_ITEM"
? "Items"
: "Tons";
const depleted = line.remainingQuantity <= 0;
return (
<div key={line.containerTypeId ?? `bulk-${i}`}>
<Group justify="space-between" mb={6}>
<Group gap={8} align="center">
<Text fz={14} fw={600}>
{label}
</Text>
{depleted && (
<Badge size="xs" variant="light" color="gray" radius="sm">
Fully ordered
</Badge>
)}
</Group>
<Text fz={13} c="dimmed">
<Text span fw={700} c={depleted ? "dimmed" : "edr-green"}>
{formatQuantity(
line.remainingQuantity,
line.unitOfMeasure,
isContainer,
)}
</Text>{" "}
remaining of{" "}
{formatQuantity(
line.contractedQuantity,
line.unitOfMeasure,
isContainer,
)}
</Text>
</Group>
<Progress
value={pct}
color={depleted ? "gray" : "edr-green"}
size="md"
radius="xl"
/>
</div>
);
})}
</Stack>
</Card>
{/* Orders */}
<Card withBorder radius="md" p="lg">
<Group justify="space-between" align="center" mb="md">
<Text fw={700} fz={16}>
Orders
</Text>
<Badge variant="light" color="violet" radius="sm">
{orders?.length ?? 0}
</Badge>
</Group>
{!orders || orders.length === 0 ? (
<Stack align="center" gap={8} py="xl">
<ThemeIcon size={48} radius="xl" variant="light" color="gray">
<Inbox size={22} />
</ThemeIcon>
<Text fz={13} c="dimmed" ta="center" maw={360}>
No orders have been placed against this contract yet.
</Text>
</Stack>
) : (
<Stack gap={10}>
{orders.map((order) => {
const childId = order.bookingId;
const clickable = Boolean(childId);
return (
<Group
key={order.id}
justify="space-between"
wrap="nowrap"
p="sm"
style={{
borderRadius: 12,
border: "1px solid var(--mantine-color-gray-3)",
cursor: clickable ? "pointer" : "default",
}}
onClick={
clickable
? () =>
navigate(`/dashboard/booking-requests/${childId}`)
: undefined
}
>
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
size={38}
radius="md"
variant="light"
color="violet"
>
<PackageCheck size={18} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} truncate>
{order.reference}
</Text>
<Text fz={12} c="dimmed" truncate>
Ship{" "}
{new Date(order.scheduledDate).toLocaleDateString()}
{order.lines.length > 0
? ` · ${summariseLines(order.lines)}`
: ""}
</Text>
</Box>
</Group>
<Group gap={8} wrap="nowrap">
<BookingStatusBadge status={order.status} />
{clickable && (
<ChevronRight
size={16}
color="var(--mantine-color-gray-5)"
/>
)}
</Group>
</Group>
);
})}
</Stack>
)}
</Card>
</Stack>
);
}

View File

@@ -1,5 +1,7 @@
export * from "./booking-detail.styles";
export * from "./SectionCard";
export * from "./ClearanceReviewSection";
export * from "./ContractOrdersPanel";
export * from "./MetricTile";
export * from "./BookingDetailToolbar";
export * from "./BookingDetailHeader";

View File

@@ -8,6 +8,7 @@ import {
Checkbox,
Group,
Modal,
MultiSelect,
Paper,
Radio,
RingProgress,
@@ -107,7 +108,7 @@ export function AllocateBookingWizard({
const [selectedScheduleId, setSelectedScheduleId] = useState<string | null>(null);
const [routeId, setRouteId] = useState("");
const scheduleDate = booking.scheduledDate;
const [locomotiveId, setLocomotiveId] = useState("");
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
const [extraBookingIds, setExtraBookingIds] = useState<string[]>([]);
const [forceAssign, setForceAssign] = useState(false);
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
@@ -152,7 +153,7 @@ export function AllocateBookingWizard({
useEffect(() => {
if (scheduleMode === "new") {
setLocomotiveId("");
setLocomotiveIds([]);
}
}, [routeId, scheduleMode]);
@@ -264,11 +265,11 @@ export function AllocateBookingWizard({
const ensureSchedule = async (): Promise<string> => {
if (scheduleMode === "existing" && selectedScheduleId) return selectedScheduleId;
if (!routeId || !scheduleDate || !locomotiveId) {
throw new Error("Select route, date, and locomotive");
if (!routeId || !scheduleDate || locomotiveIds.length < 2) {
throw new Error("Select route, date, and at least two locomotives");
}
const created = await create.mutateAsync({
payload: { routeId, scheduleDate, locomotiveId },
payload: { routeId, scheduleDate, locomotiveIds },
});
setSelectedScheduleId(created.id);
return created.id;
@@ -526,17 +527,25 @@ export function AllocateBookingWizard({
onChange={(v) => setRouteId(v ?? "")}
searchable
/>
<Select
label="Locomotive"
placeholder={routeId ? "Select locomotive" : "Select a route first"}
<MultiSelect
label="Locomotives"
description="At least two (front and back)"
placeholder={
routeId ? "Select at least two locomotives" : "Select a route first"
}
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code}${l.name ? ` · ${l.name}` : ""}`,
}))}
value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")}
value={locomotiveIds}
onChange={setLocomotiveIds}
searchable
disabled={!routeId}
error={
locomotiveIds.length > 0 && locomotiveIds.length < 2
? "Select at least two locomotives"
: undefined
}
nothingFoundMessage={
routeId ? "No available locomotives for this corridor" : "Select a route first"
}

View File

@@ -45,6 +45,14 @@ export const QUERY_KEYS = {
byId: (id: string) => ["bookings", "detail", id] as const,
},
BOOKING_ORDERS: {
ROOT: ["booking-orders"] as const,
byContract: (contractBookingId: string) =>
["booking-orders", "by-contract", contractBookingId] as const,
pool: (contractBookingId: string) =>
["booking-orders", "pool", contractBookingId] as const,
},
TRAIN_SCHEDULING: {
ROOT: ["train-scheduling"] as const,
eligible: (freightType?: string, filters?: TrainScheduleFilters) =>

View File

@@ -32,6 +32,7 @@ export type BookingActionId =
| "rejectApproval"
| "viewContract"
| "signContractStaff"
| "reviewClearance"
| "allocateBooking"
| "startTransit"
| "complete"
@@ -69,6 +70,7 @@ export type BookingActionContext = Pick<
| "approvalSteps"
| "reference"
| "schedulingStatus"
| "customsClearingEnabled"
>;
const ALLOCATABLE_SCHEDULING_STATUSES = new Set([
@@ -250,6 +252,20 @@ const SIGN_CONTRACT_STAFF_ACTION: BookingActionDef = {
primary: true,
};
// Opens the booking detail straight on the Clearance tab so Marketing can
// review the customer's clearance documents (non-customs bookings only).
const REVIEW_CLEARANCE_ACTION: BookingActionDef = {
id: "reviewClearance",
label: "Review clearance",
shortLabel: "Clearance",
description: "Approve or query the customer's clearance documents",
confirmTitle: "",
confirmDescription: "",
variant: "default",
icon: ShieldCheck,
primary: true,
};
function withCancel(actions: BookingActionDef[]): BookingActionDef[] {
return [...actions, CANCEL_ACTION];
}
@@ -261,6 +277,7 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
rejectApproval: FREIGHT_PERMS.bookings.rejectApproval,
viewContract: FREIGHT_PERMS.bookings.view,
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
reviewClearance: FREIGHT_PERMS.bookings.reviewDocuments,
startTransit: FREIGHT_PERMS.bookings.operations,
complete: FREIGHT_PERMS.bookings.operations,
operationAccept: FREIGHT_PERMS.bookings.operations,
@@ -359,6 +376,14 @@ export function getBookingActions(
},
];
break;
case "AWAITING_DOCUMENTS":
case "DOCUMENTS_UNDER_REVIEW":
// Marketing reviews non-customs clearance here; customs bookings are
// handled in the Global Logistics clearance queue, not the booking list.
actions = ctx.customsClearingEnabled
? [CANCEL_ACTION]
: withCancel([REVIEW_CLEARANCE_ACTION]);
break;
case "OPERATION_REQUEST_PENDING":
actions = withCancel(OPERATION_REVIEW_ACTIONS);
break;
@@ -446,11 +471,17 @@ export function isAllocateAction(id: BookingActionId): boolean {
return id === "allocateBooking";
}
/** Opens the booking detail on the Clearance tab without a confirm dialog. */
export function isClearanceNavAction(id: BookingActionId): boolean {
return id === "reviewClearance";
}
export function listRowHasActions(
row: {
status: BookingStatus;
paymentCurrency: string;
approvalSteps?: BookingApprovalStep[] | null;
customsClearingEnabled?: boolean;
},
user?: AuthUser | null,
): boolean {
@@ -461,6 +492,7 @@ export function listRowHasActions(
reference: "",
approvalSteps: row.approvalSteps ?? undefined,
schedulingStatus: row.status,
customsClearingEnabled: row.customsClearingEnabled,
},
user,
);

View File

@@ -262,6 +262,11 @@ export const BOOKING_LIST_TABS = [
"FULLY_EXECUTED",
],
},
{
key: "clearance",
label: "Clearance",
statuses: ["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY"],
},
{
key: "payment",
label: "Payment",

View File

@@ -44,6 +44,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
governmentInstitution: booking.governmentInstitution ?? null,
consolidationPartnerId: booking.consolidationPartnerId ?? null,
consolidationPartnerReference: booking.consolidationPartner?.reference ?? null,
customsClearingEnabled: booking.customsClearingEnabled ?? false,
createdAt: booking.createdAt,
};
}

View File

@@ -0,0 +1,25 @@
import type { LucideIcon } from "lucide-react";
import { Layers, ShipWheel, Truck } from "lucide-react";
/**
* The Global Logistics clearance queue holds only customs bookings
* (`DOCUMENTS_UNDER_REVIEW` + customsClearingEnabled); non-customs clearance is
* reviewed by Marketing on the booking detail. Since every row here is a customs
* booking, the tabs slice by trade direction rather than customs scope.
*/
export type ClearanceTabKey = "all" | "import" | "export";
export interface ClearanceTab {
key: ClearanceTabKey;
label: string;
icon: LucideIcon;
}
export const CLEARANCE_TABS: ClearanceTab[] = [
{ key: "all", label: "All", icon: Layers },
{ key: "import", label: "Import", icon: Truck },
{ key: "export", label: "Export", icon: ShipWheel },
];
/** The backend booking status that places a booking in the clearance queue. */
export const CLEARANCE_REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW";

View File

@@ -0,0 +1,11 @@
export const HealthCheck = () => {
const url1 = import.meta.env.VITE_API_URL?? "undefined";
const url2 = import.meta.env.VITE_BASE_API_URL?? "undefined";
const url3 = import.meta.env.VITE_USER_MANAGEMENT_BASE?? "undefined";
return <div>
<h2>-----------{url1}</h2>
<h2>-----------{url2}</h2>
<h2>-----------{url3}</h2>
</div>
}

View File

@@ -0,0 +1,28 @@
import { useQuery } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { bookingOrdersService } from "@/services/booking-orders.service";
/** Orders placed against a general contract (id = the contract booking id). */
export function useContractOrders(
contractBookingId: string | undefined,
enabled = true,
) {
return useQuery({
queryKey: QUERY_KEYS.BOOKING_ORDERS.byContract(contractBookingId ?? ""),
queryFn: () => bookingOrdersService.listByContract(contractBookingId!),
enabled: Boolean(contractBookingId) && enabled,
});
}
/** Contracted / ordered / remaining drawdown pool for a general contract. */
export function useContractPool(
contractBookingId: string | undefined,
enabled = true,
) {
return useQuery({
queryKey: QUERY_KEYS.BOOKING_ORDERS.pool(contractBookingId ?? ""),
queryFn: () => bookingOrdersService.pool(contractBookingId!),
enabled: Boolean(contractBookingId) && enabled,
});
}

View File

@@ -4,6 +4,7 @@ import type { RuleEngineResourceSlug } from "@/types/rule-engine";
export const FREIGHT_PERMS = {
bookings: {
view: "edr_freight_app:bookings:view",
clearanceView: "edr_freight_app:bookings:clearance_view",
staffAccept: "edr_freight_app:bookings:staff_accept",
requestChanges: "edr_freight_app:bookings:request_changes",
reject: "edr_freight_app:bookings:reject",
@@ -82,6 +83,11 @@ export function canAccessBookings(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.bookings.view);
}
/** Can see/manage the customs document-clearance queue (Global Logistics). */
export function canViewClearance(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.bookings.reviewDocuments);
}
export function canViewScheduling(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.trainScheduling.view);
}

View File

@@ -1,11 +1,19 @@
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, FileSignature, Package } from "lucide-react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
ArrowLeft,
FileSignature,
Layers,
LayoutGrid,
Package,
ShieldCheck,
} from "lucide-react";
import {
Container,
Stack,
Grid,
Center,
Loader,
Tabs,
Text,
Paper,
Button,
@@ -28,11 +36,14 @@ import {
BookingCompanyCard,
BookingContractSummaryCard,
BookingDocumentsCard,
ClearanceReviewSection,
ContractOrdersPanel,
type BookingFileView,
} from "@/components/bookings/detail";
import { WarehouseInfoCard } from "@/components/warehouses";
import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import type { BookingDetail } from "@/types/booking";
import { downloadBookingFile } from "@/services/files.service";
import {
useBookingDetail,
@@ -52,6 +63,7 @@ const SIGNATURE_FILE_CODES = new Set([
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const {
data: booking,
isLoading,
@@ -142,6 +154,31 @@ export default function BookingRequestDetailPage() {
booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE";
// Non-customs clearance is reviewed here by Marketing in its own tab; customs
// bookings are handled in the Global Logistics clearance queue instead.
const showClearanceTab =
!booking.customsClearingEnabled &&
["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY"].includes(
booking.status,
);
// A general contract drives an "Orders" tab: each drawdown order spawns a
// child booking that staff manage (clearance/approval) independently.
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
const showTabs = showClearanceTab || isGeneralContract;
const requestedTab = searchParams.get("tab");
const activeTab =
requestedTab === "clearance" && showClearanceTab
? "clearance"
: requestedTab === "orders" && isGeneralContract
? "orders"
: "overview";
const setActiveTab = (tab: string | null) => {
const next = new URLSearchParams(searchParams);
if (tab && tab !== "overview") next.set("tab", tab);
else next.delete("tab");
setSearchParams(next, { replace: true });
};
return (
<PageContainer>
<Breadcrumbs
@@ -172,26 +209,69 @@ export default function BookingRequestDetailPage() {
)}
<Grid gap="lg">
{/* LEFT — primary content */}
{/* LEFT — primary content, split into tabs to keep each view focused */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
<BookingRouteServiceCard
booking={booking}
originLabel={row.originLabel}
destinationLabel={row.destinationLabel}
/>
<BookingMileServicesCard booking={booking} />
<BookingCargoCard booking={booking} />
{booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} />
)}
<BookingDocumentsCard
files={(booking.files ?? []).filter(
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
{showTabs ? (
<Tabs
value={activeTab}
onChange={setActiveTab}
variant="pills"
color="edr-blue"
keepMounted={false}
>
<Tabs.List mb="lg">
<Tabs.Tab
value="overview"
leftSection={<LayoutGrid size={16} />}
>
Overview
</Tabs.Tab>
{isGeneralContract && (
<Tabs.Tab value="orders" leftSection={<Layers size={16} />}>
Orders
</Tabs.Tab>
)}
{showClearanceTab && (
<Tabs.Tab
value="clearance"
leftSection={<ShieldCheck size={16} />}
>
Customer clearance
</Tabs.Tab>
)}
</Tabs.List>
<Tabs.Panel value="overview">
<OverviewPanel
booking={booking}
row={row}
onDownload={handleDownloadFile}
/>
</Tabs.Panel>
{isGeneralContract && (
<Tabs.Panel value="orders">
<ContractOrdersPanel
contractBookingId={booking.id}
isContainer={booking.freightType === "CONTAINER"}
/>
</Tabs.Panel>
)}
{showClearanceTab && (
<Tabs.Panel value="clearance">
<ClearanceReviewSection
bookingId={booking.id}
onChanged={() => refetch()}
/>
</Tabs.Panel>
)}
</Tabs>
) : (
<OverviewPanel
booking={booking}
row={row}
onDownload={handleDownloadFile}
/>
</Stack>
)}
</Grid.Col>
{/* RIGHT — sticky action / summary rail */}
@@ -233,3 +313,35 @@ export default function BookingRequestDetailPage() {
</PageContainer>
);
}
/** The booking's primary detail cards — route, services, cargo, contract, docs. */
function OverviewPanel({
booking,
row,
onDownload,
}: {
booking: BookingDetail;
row: ReturnType<typeof toBookingListRow>;
onDownload: (file: BookingFileView) => void;
}) {
return (
<Stack gap="lg">
<BookingRouteServiceCard
booking={booking}
originLabel={row.originLabel}
destinationLabel={row.destinationLabel}
/>
<BookingMileServicesCard booking={booking} />
<BookingCargoCard booking={booking} />
{booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} />
)}
<BookingDocumentsCard
files={(booking.files ?? []).filter(
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
)}
onDownload={onDownload}
/>
</Stack>
);
}

View File

@@ -0,0 +1,291 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import {
Alert,
Badge,
Box,
Grid,
Group,
Loader,
Paper,
Progress,
RingProgress,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import {
AlertCircle,
ArrowRight,
CheckCircle2,
Clock,
PackageCheck,
ShieldCheck,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail";
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
import { bookingsService } from "@/services/bookings.service";
import { useBookingDetail } from "@/hooks/bookings/useBookings";
export default function DocumentClearanceDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: booking } = useBookingDetail(id);
const {
data: clearance,
isLoading,
isError,
} = useQuery({
queryKey: ["clearance", id],
queryFn: () => bookingsService.getClearance(id!),
enabled: Boolean(id),
});
const stats = useMemo(() => {
const docs = (clearance?.documents ?? []).filter(
(d) => d.uploadedBy === "customer",
);
const total = docs.length;
const approved = docs.filter((d) => d.reviewStatus === "APPROVED").length;
const queried = docs.filter((d) => d.reviewStatus === "QUERIED").length;
const pending = total - approved - queried;
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
return { total, approved, queried, pending, pct };
}, [clearance]);
const reference = booking?.reference ?? "Clearance";
if (isLoading) {
return (
<PageContainer>
<Group justify="center" py={80} gap={10}>
<Loader color="edr-green" />
<Text c="dimmed">Loading clearance</Text>
</Group>
</PageContainer>
);
}
if (isError || !clearance) {
return (
<PageContainer>
<PageHeader
title="Clearance not found"
backTo="/dashboard/clearance"
breadcrumbs={[
{ label: "Document Clearance", href: "/dashboard/clearance" },
{ label: "Not found" },
]}
/>
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
We couldnt load this bookings clearance.
</Alert>
</PageContainer>
);
}
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title={reference}
backTo="/dashboard/clearance"
breadcrumbs={[
{ label: "Document Clearance", href: "/dashboard/clearance" },
{ label: reference },
]}
meta={
clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={13} />}
>
All approved
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={13} />}
>
Review pending
</Badge>
)
}
/>
<ClearanceHero booking={booking} clearance={clearance} stats={stats} />
<Grid gap="lg">
{/* LEFT — document review (shared with the Marketing booking detail) */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<ClearanceReviewSection bookingId={id!} hideSummary />
</Grid.Col>
{/* RIGHT — sticky progress gauge */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 24 }}>
<SectionCard
icon={PackageCheck}
title="Review progress"
accent="edr-green"
>
<Stack align="center" gap="sm">
<RingProgress
size={140}
thickness={12}
roundCaps
sections={[{ value: stats.pct, color: "edr-green" }]}
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1}>
{stats.pct}%
</Text>
<Text size="xs" c="dimmed">
approved
</Text>
</Stack>
}
/>
<Group gap="lg" justify="center">
<ProgressStat
color="edr-green"
label="Approved"
value={stats.approved}
/>
<ProgressStat
color="red"
label="Queried"
value={stats.queried}
/>
<ProgressStat
color="gray"
label="Pending"
value={stats.pending}
/>
</Group>
</Stack>
</SectionCard>
</Box>
</Grid.Col>
</Grid>
</Stack>
</PageContainer>
);
}
function ClearanceHero({
booking,
clearance,
stats,
}: {
booking: ReturnType<typeof useBookingDetail>["data"];
clearance: Freight.ClearanceView;
stats: { pct: number; approved: number; total: number };
}) {
const direction = booking?.tradeDirection ?? "—";
const origin =
booking?.originYard?.label ?? booking?.originYard?.code ?? "Origin";
const destination =
booking?.destinationYard?.label ??
booking?.destinationYard?.code ??
"Destination";
return (
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
<ShieldCheck size={26} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fw={800} fz={20} c="edr-text" truncate>
{booking?.reference ?? "Clearance"}
</Text>
<Badge
size="sm"
variant="light"
color={direction === "IMPORT" ? "edr-green" : "gray"}
radius="sm"
>
{direction}
</Badge>
{clearance.includesCustoms ? (
<Badge
size="sm"
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={12} />}
>
Customs
</Badge>
) : null}
</Group>
<Group gap={8} mt={6} wrap="nowrap">
<Text size="sm" fw={600} truncate maw={160}>
{origin}
</Text>
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={600} truncate maw={160}>
{destination}
</Text>
</Group>
</Box>
</Group>
<Box style={{ minWidth: 200, flex: 1, maxWidth: 320 }}>
<Group justify="space-between" mb={6}>
<Text size="xs" c="dimmed" fw={600}>
Document review
</Text>
<Text size="xs" c="dimmed">
{stats.approved}/{stats.total}
</Text>
</Group>
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
</Box>
</Group>
</Paper>
);
}
function ProgressStat({
color,
label,
value,
}: {
color: string;
label: string;
value: number;
}) {
return (
<Stack gap={2} align="center">
<Text fw={700} fz={18} c="edr-text">
{value}
</Text>
<Group gap={4} wrap="nowrap">
<Box
style={{
width: 7,
height: 7,
borderRadius: 999,
background: `var(--mantine-color-${color}-6)`,
}}
/>
<Text fz="11px" c="dimmed">
{label}
</Text>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,600 @@
import { useCallback, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import {
ActionIcon,
Badge,
Box,
Card,
Group,
ScrollArea,
SegmentedControl,
SimpleGrid,
Stack,
Tabs,
Text,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
ArrowRight,
Calendar,
ChevronRight,
Inbox,
LayoutGrid,
RefreshCw,
Search,
ShieldCheck,
ShipWheel,
Table as TableIcon,
Truck,
User,
X,
} from "lucide-react";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { KpiStrip } from "@/components/page/KpiStrip";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { bookingsService } from "@/services/bookings.service";
import type { BookingDetail } from "@/types/booking";
import {
CLEARANCE_REVIEW_STATUS,
CLEARANCE_TABS,
type ClearanceTabKey,
} from "@/features/clearance/clearance-tabs.config";
type ViewMode = "table" | "cards";
interface ClearanceRow {
id: string;
reference: string;
customerLabel: string;
tradeDirection: string;
freightType: string;
originLabel: string;
destinationLabel: string;
scheduledDate: string;
hasCustoms: boolean;
}
function labelFromRef(
ref?: { companyName?: string; label?: string; name?: string; code?: string },
fallback = "—",
): string {
if (!ref) return fallback;
return ref.companyName ?? ref.label ?? ref.name ?? ref.code ?? fallback;
}
function toClearanceRow(booking: BookingDetail): ClearanceRow {
return {
id: booking.id,
reference: booking.reference,
customerLabel: booking.isGovernment
? (booking.governmentInstitution ?? "Government")
: labelFromRef(booking.company, booking.companyId ?? undefined),
tradeDirection: booking.tradeDirection ?? "—",
freightType: booking.freightType ?? "—",
originLabel: labelFromRef(booking.originYard),
destinationLabel: labelFromRef(booking.destinationYard),
scheduledDate: booking.scheduledDate,
hasCustoms: Boolean(
booking.customsClearingEnabled ?? booking.serviceType?.includesCustoms,
),
};
}
function formatDate(iso?: string): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleDateString(undefined, {
day: "2-digit",
month: "short",
year: "numeric",
});
}
/**
* Icon-only chip for a booking's trade direction — Truck for import, ShipWheel
* for export — on a light background, matching the "awaiting review" badge
* styling. Keeps the cards within the white / light-gray / green palette and
* drops the text label in favour of a tooltip.
*/
function DirectionIcon({ direction }: { direction: string }) {
const isImport = direction === "IMPORT";
const Icon = isImport ? Truck : ShipWheel;
const label = isImport ? "Import" : direction === "EXPORT" ? "Export" : "—";
return (
<Tooltip label={label} withArrow>
<ThemeIcon
variant="light"
color={isImport ? "edr-green" : "gray"}
radius="md"
size={28}
aria-label={label}
>
<Icon size={15} strokeWidth={1.9} />
</ThemeIcon>
</Tooltip>
);
}
export default function DocumentClearanceListPage() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<ClearanceTabKey>("all");
const [query, setQuery] = useState("");
const [view, setView] = useState<ViewMode>("table");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const { data, isLoading, isError, isFetching, refetch } = useQuery({
queryKey: ["clearance", "list"],
queryFn: () =>
bookingsService.list({ status: CLEARANCE_REVIEW_STATUS, pageSize: 200 }),
});
// GL clears customs bookings only; non-customs clearance is reviewed by
// Marketing on the booking detail. Scope the queue defensively so a staff or
// marketing user opening this page still sees the customs queue.
const allRows = useMemo(
() => (data?.items ?? []).map(toClearanceRow).filter((r) => r.hasCustoms),
[data?.items],
);
// Per-tab counts drive the badge on each tab.
const tabCounts = useMemo(() => {
return {
all: allRows.length,
import: allRows.filter((r) => r.tradeDirection === "IMPORT").length,
export: allRows.filter((r) => r.tradeDirection === "EXPORT").length,
} satisfies Record<ClearanceTabKey, number>;
}, [allRows]);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
return allRows.filter((r) => {
if (activeTab === "import" && r.tradeDirection !== "IMPORT") return false;
if (activeTab === "export" && r.tradeDirection !== "EXPORT") return false;
if (!q) return true;
return (
r.reference.toLowerCase().includes(q) ||
r.customerLabel.toLowerCase().includes(q) ||
r.originLabel.toLowerCase().includes(q) ||
r.destinationLabel.toLowerCase().includes(q)
);
});
}, [allRows, activeTab, query]);
const total = rows.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const pagedRows = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return rows.slice(start, start + pagination.pageSize);
}, [rows, pagination.pageIndex, pagination.pageSize]);
const openDetail = useCallback(
(id: string) => navigate(`/dashboard/clearance/${id}`),
[navigate],
);
const columns: ColumnDef<ClearanceRow>[] = useMemo(
() => [
{
id: "booking",
header: () => <span className={bookingTable.headerCell}>Booking</span>,
cell: ({ row }) => {
const r = row.original;
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<ShieldCheck className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{r.reference}
</p>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{r.customerLabel}
</p>
</div>
</div>
);
},
},
{
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => {
const r = row.original;
return (
<Stack gap={4} py={2}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={500} truncate maw={120}>
{r.originLabel}
</Text>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={500} truncate maw={120}>
{r.destinationLabel}
</Text>
</Group>
<Group gap={8} align="center">
<DirectionIcon direction={r.tradeDirection} />
<Badge size="xs" variant="default" radius="sm">
{r.freightType}
</Badge>
</Group>
</Stack>
);
},
},
{
id: "scheduled",
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar className="size-3.5" />
{formatDate(row.original.scheduledDate)}
</span>
),
},
{
id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: () => (
<Badge size="sm" variant="light" color="edr-green" radius="sm">
Under review
</Badge>
),
},
{
id: "go",
size: 56,
cell: () => (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />
</Group>
),
},
],
[],
);
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Document Clearance"
subtitle="Review customer documents, raise queries, and finalize clearance for each booking."
meta={
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={13} />}
>
{tabCounts.all} awaiting review
</Badge>
}
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={() => refetch()}
loading={isFetching}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<KpiStrip
loading={isLoading}
items={[
{
label: "Awaiting review",
value: tabCounts.all,
icon: Inbox,
color: "edr-green",
},
{
label: "Import",
value: tabCounts.import,
icon: Truck,
color: "edr-green",
},
{
label: "Export",
value: tabCounts.export,
icon: ShipWheel,
color: "gray",
},
]}
/>
<Tabs
value={activeTab}
onChange={(v) => {
setActiveTab((v as ClearanceTabKey) ?? "all");
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
variant="pills"
color="edr-green"
>
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars="x">
<Tabs.List style={{ flexWrap: "nowrap", width: "max-content" }}>
{CLEARANCE_TABS.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.key;
return (
<Tabs.Tab
key={tab.key}
value={tab.key}
leftSection={<Icon size={15} />}
rightSection={
<Badge
size="sm"
radius="sm"
variant={isActive ? "white" : "light"}
color={isActive ? "edr-green" : "gray"}
>
{tabCounts[tab.key]}
</Badge>
}
>
{tab.label}
</Tabs.Tab>
);
})}
</Tabs.List>
</ScrollArea>
</Tabs>
<Card p={0} withBorder shadow="sm" radius="lg">
<Stack gap={0}>
<Box px="md" pt="md" pb="sm">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search reference, customer, or route…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
) : null
}
radius="lg"
style={{ flex: 1, minWidth: 220 }}
/>
<Group gap="sm" wrap="nowrap">
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
<SegmentedControl
size="sm"
radius="md"
value={view}
onChange={(v) => setView(v as ViewMode)}
data={[
{
value: "table",
label: (
<Group gap={6} wrap="nowrap">
<TableIcon size={15} />
<Box visibleFrom="sm">Table</Box>
</Group>
),
},
{
value: "cards",
label: (
<Group gap={6} wrap="nowrap">
<LayoutGrid size={15} />
<Box visibleFrom="sm">Cards</Box>
</Group>
),
},
]}
/>
</Group>
</Group>
</Box>
{view === "table" ? (
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
<DataTable<ClearanceRow, unknown>
columns={columns}
data={pagedRows}
status={
isLoading ? "loading" : isError ? "error" : "success"
}
onRowClick={(row) => openDetail(row.id)}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
) : (
<ClearanceCardGrid
rows={pagedRows}
loading={isLoading}
onOpen={openDetail}
/>
)}
</Stack>
</Card>
</Stack>
</PageContainer>
);
}
function ClearanceCardGrid({
rows,
loading,
onOpen,
}: {
rows: ClearanceRow[];
loading: boolean;
onOpen: (id: string) => void;
}) {
if (loading) {
return (
<Box px="md" py="xl">
<Text c="dimmed" ta="center">
Loading
</Text>
</Box>
);
}
if (rows.length === 0) {
return (
<Stack align="center" gap={8} py={48}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</ThemeIcon>
<Text c="dimmed">No bookings match this view.</Text>
</Stack>
);
}
return (
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md" p="md">
{rows.map((r) => (
<ClearanceCard key={r.id} row={r} onOpen={() => onOpen(r.id)} />
))}
</SimpleGrid>
);
}
function ClearanceCard({
row,
onOpen,
}: {
row: ClearanceRow;
onOpen: () => void;
}) {
return (
<Card
withBorder
shadow="sm"
radius="lg"
p="md"
onClick={onOpen}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
}
}}
style={{ cursor: "pointer", transition: "all 120ms ease" }}
className="hover:border-edr-green-4 hover:shadow-md"
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={40}>
<ShieldCheck size={19} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fw={700} size="sm" c="edr-text" truncate>
{row.reference}
</Text>
<Group gap={4} wrap="nowrap">
<User size={11} className="shrink-0 opacity-70" />
<Text size="xs" c="dimmed" truncate>
{row.customerLabel}
</Text>
</Group>
</Box>
</Group>
<Badge size="sm" variant="light" color="edr-green" radius="sm">
Under review
</Badge>
</Group>
<Box
mt="md"
p="sm"
style={{
borderRadius: 12,
background: "var(--mantine-color-edr-card-6)",
border: "1px solid var(--mantine-color-edr-border-6)",
}}
>
<Group gap={8} wrap="nowrap" justify="center">
<Text size="sm" fw={600} truncate maw={130}>
{row.originLabel}
</Text>
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={600} truncate maw={130}>
{row.destinationLabel}
</Text>
</Group>
</Box>
<Group justify="space-between" mt="md" wrap="nowrap">
<Group gap={8} wrap="nowrap">
<DirectionIcon direction={row.tradeDirection} />
<Badge size="xs" variant="default" radius="sm">
{row.freightType}
</Badge>
{row.hasCustoms ? (
<Tooltip label="Customs clearance" withArrow>
<ThemeIcon
variant="light"
color="edr-green"
radius="md"
size={28}
aria-label="Customs clearance"
>
<ShieldCheck size={15} strokeWidth={1.9} />
</ThemeIcon>
</Tooltip>
) : null}
</Group>
<Group gap={4} wrap="nowrap">
<Calendar size={13} className="text-muted-foreground" />
<Text size="xs" c="dimmed">
{formatDate(row.scheduledDate)}
</Text>
</Group>
</Group>
</Card>
);
}

View File

@@ -1,11 +1,14 @@
import {
ActionIcon,
Badge,
Box,
Card,
Group,
SegmentedControl,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
@@ -32,7 +35,7 @@ import {
} from "@/components/customers";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type { Company } from "@/types/customer";
import type { Company, CompanyStatus } from "@/types/customer";
import {
DataTable,
DataTableFooter,
@@ -45,14 +48,17 @@ export default function CustomersPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
// "" = all; otherwise a CompanyStatus to narrow the list (e.g. pending review).
const [statusFilter, setStatusFilter] = useState<"" | CompanyStatus>("");
const filter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
status: statusFilter || undefined,
}),
[pagination.pageIndex, pagination.pageSize, debouncedQuery],
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
);
const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} }));
@@ -107,7 +113,25 @@ export default function CustomersPage() {
{
id: "status",
header: "Status",
cell: ({ row }) => <CompanyStatusBadge status={row.original.status} />,
cell: ({ row }) => {
const pending = (row.original.companyProfiles ?? []).filter(
(p) => p.status === "pending",
).length;
return (
<Group gap={6} wrap="nowrap">
<CompanyStatusBadge status={row.original.status} />
{pending > 0 ? (
<Tooltip
label={`${pending} profile${pending > 1 ? "s" : ""} awaiting approval`}
>
<Badge color="yellow" variant="light" size="sm" radius="sm">
{pending} pending
</Badge>
</Tooltip>
) : null}
</Group>
);
},
},
{
id: "contact",
@@ -216,6 +240,20 @@ export default function CustomersPage() {
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<SegmentedControl
size="sm"
radius="md"
value={statusFilter || "all"}
onChange={(v) => {
setStatusFilter(v === "all" ? "" : (v as CompanyStatus));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "Pending approval", value: "pending" },
{ label: "Active", value: "active" },
]}
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>

View File

@@ -360,7 +360,7 @@ const FleetResourcePage = () => {
{config.subtitle}
</Text>
</div>
<Button leftSection={<Plus size={16} />} onClick={() => {
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
setEditing(null);
setFormOpen(true);
}}>
@@ -391,7 +391,7 @@ const FleetResourcePage = () => {
size="xs"
radius="md"
variant={filter.value === option.value ? "filled" : "outline"}
color="green"
styles={{ label: { fontWeight: 500 } }}
onClick={() => {
setListFilterValues((prev) => ({
...prev,
@@ -417,7 +417,7 @@ const FleetResourcePage = () => {
size="xs"
radius="md"
variant={statusFilter === option.value ? "filled" : "outline"}
color="green"
styles={{ label: { fontWeight: 500 } }}
onClick={() => setStatusFilter(option.value)}
>
{option.label}

View File

@@ -408,7 +408,13 @@ export default function RoutesPage() {
<TextInput
label="Name"
value={form.name}
onChange={(e) => setForm((current) => ({ ...current, name: e.currentTarget.value }))}
onChange={(e) => {
// Capture the value before the state updater runs — React may
// recycle the synthetic event, nulling currentTarget by the time
// the updater executes ("Cannot read properties of null").
const name = e.currentTarget.value;
setForm((current) => ({ ...current, name }));
}}
/>
<Group justify="space-between">
<Text size="sm" fw={500}>

View File

@@ -63,8 +63,8 @@ export const vehiclesConfig: FleetResourceConfig = {
],
formFields: [
{ name: "code", label: "Code", type: "text" },
{ name: "plateNumber", label: "Plate Number", type: "text", required: true },
{ name: "powerPlateNo", label: "Power Plate No", type: "text" },
{ name: "plateNumber", label: "Power Plate No", type: "text", required: true },
// { name: "powerPlateNo", label: "Power Plate No", type: "text" },
{ name: "trailerPlateNo", label: "Trailer Plate No", type: "text" },
{ name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS },
{ name: "manufacturer", label: "Manufacturer", type: "text", required: true },

View File

@@ -717,11 +717,11 @@ const FirstMilePage = () => {
/>
<Group gap="sm">
{selectedIds.length > 0 && (
<Button variant="light" leftSection={<Truck size={16} />} onClick={openBulkAssign}>
<Button variant="light" leftSection={<Truck size={16} />} onClick={openBulkAssign} styles={{ label: { fontWeight: 500 } }}>
Assign vehicle ({selectedIds.length})
</Button>
)}
<Button leftSection={<Truck size={16} />} onClick={openAccept}>
<Button leftSection={<Truck size={16} />} onClick={openAccept} styles={{ label: { fontWeight: 500 } }}>
Assign Mile
</Button>
</Group>
@@ -734,6 +734,7 @@ const FirstMilePage = () => {
key={option.value}
size="xs"
variant={active ? "filled" : "default"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => {
setStatusFilter(option.value);
setPagination((p) => ({ ...p, pageIndex: 0 }));

View File

@@ -629,11 +629,11 @@ const LastMilePage = () => {
/>
<Group gap="sm">
{selectedIds.length > 0 && (
<Button variant="light" leftSection={<Truck size={16} />} onClick={openBulkAssign}>
<Button variant="light" leftSection={<Truck size={16} />} onClick={openBulkAssign} styles={{ label: { fontWeight: 500 } }}>
Assign vehicle ({selectedIds.length})
</Button>
)}
<Button leftSection={<Truck size={16} />} onClick={() => openAssign(null)}>
<Button leftSection={<Truck size={16} />} onClick={() => openAssign(null)} styles={{ label: { fontWeight: 500 } }}>
Assign Mile
</Button>
</Group>
@@ -646,6 +646,7 @@ const LastMilePage = () => {
key={option.value}
size="xs"
variant={active ? "filled" : "default"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => {
setStatusFilter(option.value);
setPagination((p) => ({ ...p, pageIndex: 0 }));

View File

@@ -291,6 +291,14 @@ export default function TrainScheduleV2DetailPage() {
);
}
// All locomotives pulling the train (≥2), falling back to the legacy single loco.
const locomotives =
schedule.trainSet?.locomotives && schedule.trainSet.locomotives.length > 0
? schedule.trainSet.locomotives
: schedule.trainSet?.locomotive
? [schedule.trainSet.locomotive]
: [];
const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status);
const canFinalize = schedule.status === "DRAFT" && (schedule.bookings?.length ?? 0) > 0;
const canDispatch = schedule.status === "SCHEDULED";
@@ -811,13 +819,13 @@ export default function TrainScheduleV2DetailPage() {
<KpiStrip
items={[
{
label: "Locomotive",
value: schedule.trainSet?.locomotive?.code ?? "—",
hint: schedule.trainSet?.locomotive?.currentYardId
? schedule.trainSet.locomotive.currentYardId === schedule.originStation?.id
? `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? "origin yard"}`
: "Not at schedule origin yard"
: "No current yard set",
label: locomotives.length > 1 ? "Locomotives" : "Locomotive",
value: locomotives.length
? locomotives.map((l) => l.code).join(" + ")
: "—",
hint: locomotives.length
? `${locomotives.length} locomotive${locomotives.length > 1 ? "s" : ""}`
: "No locomotives assigned",
icon: Train,
},
{

View File

@@ -7,6 +7,7 @@ import {
Group,
Menu,
Modal,
MultiSelect,
Select,
SimpleGrid,
Stack,
@@ -82,7 +83,7 @@ export default function TrainScheduleV2ListPage() {
const [createOpen, setCreateOpen] = useState(false);
const [routeId, setRouteId] = useState("");
const [scheduleDate, setScheduleDate] = useState("");
const [locomotiveId, setLocomotiveId] = useState("");
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
const schedulesQuery = useQuery(
api.trainScheduling.scheduleList.queryOptions({ input: {} }),
@@ -113,7 +114,7 @@ export default function TrainScheduleV2ListPage() {
}, [selectedRoute]);
useEffect(() => {
setLocomotiveId("");
setLocomotiveIds([]);
}, [routeId]);
const allSchedules = schedulesQuery.data ?? [];
@@ -147,6 +148,7 @@ export default function TrainScheduleV2ListPage() {
s.origin,
s.destination,
s.locomotive?.code,
...(s.locomotives ?? []).map((l) => l.code),
s.freightType,
s.status,
]
@@ -229,21 +231,32 @@ export default function TrainScheduleV2ListPage() {
},
{
id: "loco",
header: "Locomotive",
header: "Locomotives",
meta: { headerClassName, cellClassName },
cell: ({ row }) =>
row.original.locomotive?.code ? (
cell: ({ row }) => {
const locos =
row.original.locomotives && row.original.locomotives.length > 0
? row.original.locomotives
: row.original.locomotive
? [row.original.locomotive]
: [];
if (!locos.length) {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
return (
<Group gap={6} wrap="nowrap">
<Train size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={500}>
{row.original.locomotive.code}
{locos[0].code}
{locos.length > 1 ? ` +${locos.length - 1}` : ""}
</Text>
</Group>
) : (
<Text size="sm" c="dimmed">
</Text>
),
);
},
},
{
id: "metrics",
@@ -331,13 +344,16 @@ export default function TrainScheduleV2ListPage() {
}, [navigate, cancel.isPending, cancel, toast]);
const handleCreate = async () => {
if (!routeId || !scheduleDate || !locomotiveId) {
toast({ title: "Select route, date, and locomotive", variant: "destructive" });
if (!routeId || !scheduleDate || locomotiveIds.length < 2) {
toast({
title: "Select route, date, and at least two locomotives",
variant: "destructive",
});
return;
}
try {
const created = await create.mutateAsync({
payload: { routeId, scheduleDate, locomotiveId },
payload: { routeId, scheduleDate, locomotiveIds },
});
toast({ title: "Train schedule created" });
setCreateOpen(false);
@@ -532,17 +548,25 @@ export default function TrainScheduleV2ListPage() {
setScheduleDate(raw ? new Date(raw).toISOString() : "");
}}
/>
<Select
label="Locomotive"
placeholder={routeId ? "Select locomotive" : "Select a route first"}
<MultiSelect
label="Locomotives"
description="A train must be pulled by at least two locomotives (front and back)"
placeholder={
routeId ? "Select at least two locomotives" : "Select a route first"
}
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code}${l.name ? `${l.name}` : ""}`,
}))}
value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")}
value={locomotiveIds}
onChange={setLocomotiveIds}
searchable
disabled={!routeId}
error={
locomotiveIds.length > 0 && locomotiveIds.length < 2
? "Select at least two locomotives"
: undefined
}
nothingFoundMessage={
routeId ? "No available locomotives for this corridor" : "Select a route first"
}

View File

@@ -0,0 +1,30 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import type { Freight } from "@edr/types";
/**
* Drawdown orders placed against a general contract (a booking with
* bookingType = GENERAL_CONTRACT). Each order spawns a child ONE_TIME booking
* that carries its own clearance/approval — managed on the child's detail page.
*/
export const bookingOrdersService = {
/** Orders placed against a general contract, with their lines + child status. */
listByContract: async (
contractBookingId: string,
): Promise<Freight.IBookingOrder[]> => {
const response = await client.get("/booking-orders", {
params: { contractBookingId },
});
return unwrap(response.data) as Freight.IBookingOrder[];
},
/** Contracted / ordered / remaining quantities for a general contract. */
pool: async (
contractBookingId: string,
): Promise<Freight.ContractQuantityLine[]> => {
const response = await client.get(
`/booking-orders/contract/${contractBookingId}/pool`,
);
return unwrap(response.data) as Freight.ContractQuantityLine[];
},
};

View File

@@ -2,6 +2,7 @@
export const BOOKING_STATUSES = [
"DRAFT",
"SUBMITTED",
"PRICE_CHANGED_PENDING_CONFIRM",
"CHANGES_REQUESTED",
"PENDING_APPROVAL",
"APPROVED_PENDING_SIGNATURE",
@@ -12,6 +13,8 @@ export const BOOKING_STATUSES = [
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
"SELECTED_FOR_BATCH",
"EXPIRED",
"PNR_GENERATED",
"PAYMENT_VERIFICATION_IN_PROGRESS",
"PAID",
@@ -21,6 +24,18 @@ export const BOOKING_STATUSES = [
"CANCELLED",
"PENDING_CONSOLIDATION",
"CONSOLIDATED",
"CONTRACT_ACTIVE",
"CONTRACT_CLOSED",
// Post counter-sign document-clearance gate.
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY",
"ROAD_DISPATCH_PENDING",
"OPERATION_REQUESTED",
// Operations review gate.
"OPERATION_REQUEST_PENDING",
"OPERATION_CHANGES_REQUESTED",
"OPERATION_PRICE_PENDING_CONFIRM",
] as const;
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
@@ -117,6 +132,10 @@ export interface BookingDetail {
isGovernment?: boolean;
governmentInstitution?: string | null;
status: BookingStatus;
/** ONE_TIME shipment vs an umbrella GENERAL_CONTRACT drawn down by orders. */
bookingType?: "ONE_TIME" | "GENERAL_CONTRACT";
/** General contracts only: when the ordering window closes. */
expiresAt?: string | null;
scheduledDate: string;
totalAmount: number;
adjustedTotalAmount?: number | null;
@@ -157,6 +176,8 @@ export interface BookingDetail {
firstMilePickupAddress?: string | null;
lastMileDeliveryAddress?: string | null;
equipmentReturn?: string;
customsClearingEnabled?: boolean;
customsClearingAgent?: string | null;
contractSummary?: string | null;
latestChangeRequestNote?: string | null;
nextStep?: BookingNextStep | null;
@@ -167,7 +188,7 @@ export interface BookingDetail {
company?: BookingNamedRef & Partial<BookingCompany>;
originYard?: BookingNamedRef;
destinationYard?: BookingNamedRef;
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number };
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean };
cargoType?: BookingNamedRef;
shippingLine?: BookingNamedRef;
bookingContainers?: BookingContainerLine[];
@@ -204,5 +225,6 @@ export interface BookingListRow {
governmentInstitution?: string | null;
consolidationPartnerId?: string | null;
consolidationPartnerReference?: string | null;
customsClearingEnabled?: boolean;
createdAt: string;
}

View File

@@ -146,12 +146,21 @@ export interface TrainScheduleListItem {
origin: string | null;
destination: string | null;
freightType?: FreightType | null;
locomotive: {
locomotive:
| {
id: string;
code: string;
name?: string | null;
currentYardId?: string | null;
}
| null;
/** All locomotives pulling this train (≥2). Falls back to `locomotive` for legacy rows. */
locomotives?: Array<{
id: string;
code: string;
name?: string | null;
currentYardId?: string | null;
} | null;
}>;
wagonCount: number;
totalWeightTons: number;
totalLengthMeters: number;
@@ -351,6 +360,16 @@ export interface TrainScheduleDetail {
maxPullWeightTons: number;
maxTrainLengthMeters?: number;
} | null;
/** All locomotives pulling this train (≥2). Falls back to `locomotive` for legacy rows. */
locomotives?: Array<{
id: string;
code: string;
name?: string | null;
status: string;
currentYardId?: string | null;
maxPullWeightTons: number;
maxTrainLengthMeters?: number;
}>;
wagons: Array<{
id: string;
sequenceNo: number;
@@ -460,7 +479,8 @@ export interface ReschedulePlan {
export interface CreateTrainSchedulePayload {
routeId: string;
scheduleDate: string;
locomotiveId: string;
/** Locomotives pulling the train (minimum 2 — front and back). */
locomotiveIds: string[];
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;

View File

@@ -1,16 +1,14 @@
import { AppLayout, type SidebarItem } from "@/components/AppLayout";
import { useDisclosure } from "@mantine/hooks";
import {
CalendarCheck,
Clock,
Home,
Layers,
Loader2,
MapPin,
Receipt,
Settings,
Sparkles,
} from "lucide-react";
import { useDisclosure } from "@mantine/hooks";
import { useEffect, useRef } from "react";
import {
Navigate,
@@ -21,8 +19,11 @@ import {
useNavigate,
} from "react-router-dom";
import useAuth from "./hooks/useAuth";
import OnboardingResumeBanner, {
AccountReviewBanner,
} from "./components/onboarding/OnboardingResumeBanner";
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
import useAuth from "./hooks/useAuth";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import MyPortalPage from "./pages/MyPortalPage";
import MySignaturePage from "./pages/MySignaturePage";
@@ -37,11 +38,11 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import EditBookingPage from "./pages/bookings/EditBookingPage";
import MyBookings from "./pages/bookings/MyBookings";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import ContractsList from "./pages/contracts/ContractsList";
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
import ContractsList from "./pages/contracts/ContractsList";
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
import TrackingPage from "./pages/tracking/TrackingPage";
function FullScreenSpinner() {
@@ -110,13 +111,11 @@ function isOnboardingAllowedPath(pathname: string): boolean {
* as users who haven't completed onboarding.
*/
function OnboardingGate() {
const { company, onboardingCompleted, companyStatus } = useAuth();
const { company, onboardingCompleted } = useAuth();
const location = useLocation();
const needsOnboarding = !company || !onboardingCompleted;
const allowedHere = isOnboardingAllowedPath(location.pathname);
// Onboarding done but not yet approved by an admin → awaiting-approval state.
const awaitingApproval = !needsOnboarding && companyStatus === "pending";
// Open by default while onboarding is pending (covers the login case).
const [wizardOpen, { open: openWizard, close: closeWizard }] =
@@ -146,10 +145,8 @@ function OnboardingGate() {
return (
<>
{needsOnboarding && !wizardOpen && (
<OnboardingResumeBanner onResume={openWizard} />
)}
{awaitingApproval && <PendingApprovalBanner />}
{needsOnboarding && <OnboardingResumeBanner onResume={openWizard} />}
{!needsOnboarding && <AccountReviewBanner />}
<Outlet />
<OnboardingWizardDialog
opened={needsOnboarding && wizardOpen}
@@ -159,41 +156,6 @@ function OnboardingGate() {
);
}
/** Slim sticky prompt shown on allowed pages after the wizard is dismissed. */
function OnboardingResumeBanner({ onResume }: { onResume: () => void }) {
return (
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#0EA371]/20 bg-[#ECF6F1] px-6 py-3">
<div className="flex items-center gap-2">
<Sparkles size={16} className="text-[#0A6F4D]" />
<span className="text-sm font-medium text-[#0A6F4D]">
Finish setting up your company to unlock bookings, tracking and
billing.
</span>
</div>
<button
type="button"
onClick={onResume}
className="rounded-lg bg-[#0EA371] px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
>
Continue onboarding
</button>
</div>
);
}
/** Shown after onboarding while the company awaits backoffice approval. */
function PendingApprovalBanner() {
return (
<div className="flex flex-wrap items-center gap-2 border-b border-amber-300/50 bg-amber-50 px-6 py-3">
<Clock size={16} className="text-amber-700" />
<span className="text-sm font-medium text-amber-800">
Your company is awaiting EDR approval. You can browse, but creating
bookings is disabled until your company is approved.
</span>
</div>
);
}
/** Keeps authenticated users off the login/signup pages. */
function RedirectIfAuthed() {
const { isPending, isAuthenticated } = useAuth();
@@ -308,7 +270,10 @@ const App = () => {
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/billing" element={<BillingPage />} />
{/* Profile was merged into Settings — keep old links working. */}
<Route path="/profile" element={<Navigate to="/settings" replace />} />
<Route
path="/profile"
element={<Navigate to="/settings" replace />}
/>
<Route path="/signature" element={<MySignaturePage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>

View File

@@ -53,7 +53,12 @@ export interface AppLayoutProps {
title?: string;
sidebarItems: SidebarItem[];
activeHref?: string;
onNavigate?: (href: string) => void;
/**
* Navigate to a route. Accepts an optional options object (e.g. `{ state }`)
* forwarded to the router — used to pass navigation state like `fresh: true`
* to the new-booking wizard. Compatible with react-router's `navigate`.
*/
onNavigate?: (href: string, options?: { state?: unknown }) => void;
enableThemeToggle?: boolean;
userName?: string;
userEmail?: string;
@@ -157,7 +162,8 @@ export function AppLayout({
const primaryDarkColor = theme.colors["edr-green"][7];
const activePath = activeHref.toLowerCase();
const navigate = (href: string) => onNavigate?.(href);
const navigate = (href: string, options?: { state?: unknown }) =>
onNavigate?.(href, options);
const toggleTheme = () => {
setColorScheme(computedColorScheme === "dark" ? "light" : "dark");
@@ -478,7 +484,7 @@ export function AppLayout({
<Menu.Item
leftSection={<Plus size={15} />}
color="edr-green"
onClick={() => navigate("/bookings/new")}
onClick={() => navigate("/bookings/new", { state: { fresh: true } })}
>
New Booking
</Menu.Item>

View File

@@ -0,0 +1,60 @@
import { Box, Button, Tooltip } from "@mantine/core";
import { Link } from "react-router-dom";
import { Lock, Plus } from "lucide-react";
import useAuth from "@/hooks/useAuth";
interface NewBookingButtonProps {
label?: string;
size?: string;
mt?: string;
}
/**
* New-booking entry point that respects approval status: a customer can only
* create bookings under a profile once the backoffice has approved it. While the
* active profile is pending the button is disabled with an explanation, so the
* gate is communicated rather than silently failing at submit time.
*/
export function NewBookingButton({
label = "New booking",
size,
mt,
}: NewBookingButtonProps) {
const { canBook, activeProfileStatus } = useAuth();
if (!canBook) {
const message =
activeProfileStatus === "pending"
? "Your profile is awaiting approval. You'll be able to create bookings as soon as it's approved."
: "Bookings aren't available for this profile yet.";
return (
<Tooltip label={message} multiline w={250} withArrow position="bottom-end">
<Box mt={mt}>
<Button
color="edr-green"
radius="md"
size={size}
disabled
leftSection={<Lock size={16} />}
>
{label}
</Button>
</Box>
</Tooltip>
);
}
return (
<Button
component={Link}
to="/bookings/new"
color="edr-green"
radius="md"
size={size}
mt={mt}
leftSection={<Plus size={16} />}
>
{label}
</Button>
);
}

View File

@@ -0,0 +1,191 @@
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, Clock } from "lucide-react";
import { api } from "@/services/api";
import useAuth from "@/hooks/useAuth";
import type { OnboardingRequirements } from "@/services/companies.service";
interface OnboardingResumeBannerProps {
/** Re-opens the onboarding wizard. */
onResume: () => void;
}
interface BannerCopy {
title: string;
subtitle: string;
cta: string;
}
/**
* Wording is driven entirely by the backend's outstanding-items list — the
* client never decides what's required, it just narrates what's left.
*/
function getCopy(
requirements: OnboardingRequirements | undefined,
pct: number,
): BannerCopy {
// No data yet (or nothing started) — treat it as a fresh start.
if (!requirements || requirements.progress.completed === 0) {
return {
title: "Set up your company profile",
subtitle: "Unlock bookings, tracking and billing — it only takes a minute.",
cta: "Start onboarding",
};
}
// Everything's filled in but not yet submitted for review.
if (requirements.isComplete) {
return {
title: "Everything's ready to go",
subtitle: "Submit your profile to send it for approval.",
cta: "Submit for review",
};
}
const remaining = requirements.outstanding.length;
if (remaining <= 2) {
return {
title: `Almost done — you're ${pct}% set up`,
subtitle: `Just ${remaining} more ${
remaining === 1 ? "item" : "items"
} to finish: ${requirements.outstanding.join(", ")}.`,
cta: "Finish onboarding",
};
}
return {
title: `You're ${pct}% set up`,
subtitle: `${requirements.progress.completed} of ${requirements.progress.total} details added — finish to unlock bookings, tracking and billing.`,
cta: "Continue onboarding",
};
}
/** Circular percentage meter that reads at a glance against the dark banner. */
function ProgressRing({ pct }: { pct: number }) {
const size = 56;
const stroke = 5;
const r = (size - stroke) / 2;
const circumference = 2 * Math.PI * r;
const offset = circumference * (1 - pct / 100);
return (
<span className="relative flex shrink-0 items-center justify-center">
<svg width={size} height={size} className="-rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="rgba(255,255,255,0.22)"
strokeWidth={stroke}
/>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="#6ee7b7"
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
style={{ transition: "stroke-dashoffset 600ms ease" }}
/>
</svg>
<span className="absolute text-sm font-bold text-white">{pct}%</span>
</span>
);
}
/**
* Prominent banner shown on onboarding-allowed pages after the wizard is
* dismissed. Progress and copy are read straight from the backend's onboarding
* requirements, so the banner always agrees with the wizard about what's left.
*/
export default function OnboardingResumeBanner({
onResume,
}: OnboardingResumeBannerProps) {
const requirementsQuery = useQuery(
api.companies.onboardingRequirements.queryOptions({ retry: false }),
);
const requirements = requirementsQuery.data;
const { completed, total } = requirements?.progress ?? {
completed: 0,
total: 0,
};
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
const { title, subtitle, cta } = getCopy(requirements, pct);
return (
<div className="bg-gradient-to-r from-[#065f46] via-[#0A6F4D] to-[#0A8A5F] px-6 py-4 shadow-md">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-4">
<ProgressRing pct={pct} />
<span className="flex flex-col gap-0.5">
<span className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#6ee7b7] opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-[#6ee7b7]" />
</span>
<span className="text-base font-bold tracking-tight text-white">
{title}
</span>
</span>
<span className="text-sm text-white/80">{subtitle}</span>
</span>
</div>
<button
type="button"
onClick={onResume}
className="inline-flex items-center gap-2 rounded-lg bg-white px-5 py-2.5 text-sm font-semibold text-[#0A6F4D] shadow-sm transition-transform hover:scale-[1.02] hover:bg-white/95"
>
{cta}
<ArrowRight size={16} />
</button>
</div>
</div>
);
}
/**
* Shown once onboarding is submitted but the company's operational profiles are
* still being reviewed. Communicates that approval is per-profile and that
* bookings unlock as each profile is cleared. Self-hides when nothing is pending.
*/
export function AccountReviewBanner() {
const { company } = useAuth();
const profiles = company?.company?.companyProfiles ?? [];
const pending = profiles.filter((p) => p.status === "pending");
const approved = profiles.filter((p) => p.status === "active");
if (profiles.length === 0 || pending.length === 0) return null;
const pendingLabel = pending
.map((p) => p.type.replace(/_/g, " "))
.join(", ");
return (
<div className="border-b border-amber-200 bg-amber-50 px-6 py-3">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700">
<Clock size={18} />
</span>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-semibold text-amber-900">
Your account is under review
</span>
<span className="text-xs text-amber-800">
We're reviewing your {pendingLabel}{" "}
{pending.length === 1 ? "profile" : "profiles"}. You can create
bookings under a profile as soon as it's approved.
</span>
</span>
</div>
<span className="text-xs font-medium text-amber-800">
{approved.length} of {profiles.length} approved
</span>
</div>
</div>
);
}

View File

@@ -14,8 +14,11 @@ import {
ArrowRight,
Building2,
CheckCircle2,
Clock,
FileText,
Globe2,
PartyPopper,
ShieldCheck,
UploadCloud,
User,
UserCheck,
@@ -43,6 +46,7 @@ type FormStep =
| "company"
| "personnel"
| "contact"
| "verify"
| "poa"
| "documents"
| "additional";
@@ -50,6 +54,7 @@ const FORM_STEPS: FormStep[] = [
"company",
"personnel",
"contact",
"verify",
"poa",
"documents",
"additional",
@@ -90,6 +95,11 @@ const STEP_META: Record<
title: "Contact Person",
description: "Who should we reach out to about this account?",
},
verify: {
icon: <ShieldCheck size={20} />,
title: "Verify Contact Person",
description: "Confirm the contact phone with a one-time SMS code.",
},
poa: {
icon: <FileText size={20} />,
title: "Power of Attorney",
@@ -142,10 +152,14 @@ export default function OnboardingWizardDialog({
onClose,
}: OnboardingWizardDialogProps) {
const queryClient = useQueryClient();
const { user, company, onboardingStep } = useAuth();
const { user, company, onboardingStep, onboardingCompleted } = useAuth();
const existingProfiles = company?.company?.companyProfiles ?? [];
const companyAlreadyStarted = Boolean(company?.company?.id);
// A draft can exist with zero operational profiles (e.g. an interrupted start).
// Such a draft must re-run role selection so the profiles actually get created
// — otherwise the user is stuck with nothing to upload a license against.
const hasOperationalProfiles = existingProfiles.length > 0;
const savedNationality =
(company?.company?.nationality as CompanyNationality | null) ?? null;
@@ -157,7 +171,11 @@ export default function OnboardingWizardDialog({
// Phases: nationality → role → form. If a draft already exists, resume
// straight into the form with nationality + roles pre-selected.
const [phase, setPhase] = useState<"nationality" | "role" | "form">(
companyAlreadyStarted ? "form" : "nationality",
companyAlreadyStarted
? hasOperationalProfiles
? "form"
: "role"
: "nationality",
);
const [nationality, setNationality] = useState<CompanyNationality | null>(
savedNationality,
@@ -174,6 +192,10 @@ export default function OnboardingWizardDialog({
// Mirror of CompanyProfileForm's active step so the global header + progress
// pill can reflect it (the form no longer renders its own stepper).
const [formStep, setFormStep] = useState<FormStep>(resumeFormStep);
// Once submission succeeds we swap the whole wizard body for a congratulations
// panel, and keep the modal open (the gate would otherwise tear it down the
// moment onboardingCompleted flips true).
const [completed, setCompleted] = useState(false);
// Saved profile data, for rehydrating the form fields after a refresh.
const profileQuery = useQuery(
@@ -184,6 +206,19 @@ export default function OnboardingWizardDialog({
}),
);
// Server-driven onboarding requirements: the backend decides which document
// set applies (by nationality) and what's still outstanding, so the client
// never makes that choice itself. This is the heavier "second request" — it's
// only issued while onboarding is still incomplete; once the getInfo flag says
// we're done, it never fires.
const requirementsQuery = useQuery(
api.companies.onboardingRequirements.queryOptions({
enabled: companyAlreadyStarted && !onboardingCompleted,
retry: false,
refetchOnWindowFocus: false,
}),
);
const refreshInfo = useCallback(
() =>
queryClient.invalidateQueries({
@@ -225,7 +260,10 @@ export default function OnboardingWizardDialog({
}
return api.companies.completeOnboarding.call();
},
onSuccess: refreshInfo,
onSuccess: async () => {
await refreshInfo();
setCompleted(true);
},
onError: (err) => setStartError(extractApiError(err).message),
});
@@ -260,7 +298,9 @@ export default function OnboardingWizardDialog({
resumedRef.current = true;
setRoles(existingProfiles.map((p) => p.type));
setNationality(savedNationality);
setPhase("form");
// Resume into the form only when profiles exist; otherwise send the user to
// role selection so the missing operational profiles get created.
setPhase(hasOperationalProfiles ? "form" : "role");
const idx = FORM_STEPS.indexOf(resumeFormStep);
if (idx > furthestIdxRef.current) furthestIdxRef.current = idx;
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -329,8 +369,22 @@ export default function OnboardingWizardDialog({
const stepMeta = STEP_META[activeStep];
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
// Closing from the congratulations panel also clears the completed flag so a
// future reopen (shouldn't happen once onboarded) starts clean.
const handleClose = useCallback(() => {
if (completed) setCompleted(false);
onClose();
}, [completed, onClose]);
// Prefer the backend-resolved document code; fall back to the local mapping
// only until the requirements query lands (the documents step is reached well
// after the draft — and thus the requirements — exist).
const resolvedDocumentSettingCode =
requirementsQuery.data?.documentSettingCode ??
documentSettingCode(effectiveNationality);
const formProps = {
documentSettingCode: documentSettingCode(effectiveNationality),
documentSettingCode: resolvedDocumentSettingCode,
documentFiles,
onDocumentFilesChange: setDocumentFiles,
user,
@@ -346,16 +400,20 @@ export default function OnboardingWizardDialog({
roleProfiles,
licenseFiles,
onLicenseChange: setLicenseFiles,
// Surface a failed final submit (license/document upload or complete) inside
// the form — otherwise the server message (e.g. a 500) would be invisible on
// the submit step.
submitError: phase === "form" ? startError : null,
};
return (
<Modal
opened={opened}
onClose={onClose}
withCloseButton
opened={opened || completed}
onClose={handleClose}
withCloseButton={!completed}
closeOnClickOutside={false}
closeOnEscape
size={1040}
closeOnEscape={!completed}
size={720}
radius="lg"
padding="xl"
centered
@@ -371,20 +429,25 @@ export default function OnboardingWizardDialog({
}
}}
title={
<Stack gap="md">
<Box>
<Group gap="sm" mb={4}>
{stepMeta.icon}
<Title order={3}>{stepMeta.title}</Title>
</Group>
<Text c="edr-muted" size="sm">
{stepMeta.description}
</Text>
</Box>
<ProgressPill current={activeIdx} total={WIZARD_STEPS.length} />
</Stack>
completed ? null : (
<Stack gap="md">
<Box>
<Group gap="sm" mb={4}>
{stepMeta.icon}
<Title order={3}>{stepMeta.title}</Title>
</Group>
<Text c="edr-muted" size="sm">
{stepMeta.description}
</Text>
</Box>
<ProgressPill current={activeIdx} total={WIZARD_STEPS.length} />
</Stack>
)
}
>
{completed ? (
<OnboardingCompletePanel onClose={handleClose} />
) : (
<Stack gap="xl">
{phase === "nationality" ? (
@@ -438,10 +501,65 @@ export default function OnboardingWizardDialog({
<CompanyProfileForm {...formProps} />
)}
</Stack>
)}
</Modal>
);
}
/**
* Replaces the wizard body once onboarding is submitted: congratulates the user
* and sets the expectation that their company is now under review, and that
* bookings unlock per profile as the team approves each one.
*/
function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
return (
<Stack gap="lg" align="center" py="md" ta="center">
<Box
className="flex h-16 w-16 items-center justify-center rounded-full"
style={{ background: "var(--mantine-color-edr-green-1)" }}
>
<PartyPopper size={32} className="text-[var(--mantine-color-edr-green-7)]" />
</Box>
<Box>
<Title order={3}>You're all set!</Title>
<Text c="edr-muted" size="sm" mt={4} maw={460}>
Thanks for completing your company profile. Your application has been
submitted and is now with our team for review.
</Text>
</Box>
<Stack
gap="sm"
w="100%"
maw={460}
p="md"
className="rounded-lg"
style={{ background: "var(--mantine-color-edr-green-0)" }}
>
<Group gap="sm" wrap="nowrap" align="flex-start">
<Clock size={18} className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]" />
<Text size="sm" ta="left">
Each operational profile (importer, exporter, freight forwarder) is
reviewed and approved individually.
</Text>
</Group>
<Group gap="sm" wrap="nowrap" align="flex-start">
<ShieldCheck size={18} className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]" />
<Text size="sm" ta="left">
You can start creating bookings under a profile as soon as it's
approved we'll let you know the moment that happens.
</Text>
</Group>
</Stack>
<Button color="edr-green" size="md" onClick={onClose} mt="xs">
Go to my dashboard
</Button>
</Stack>
);
}
/**
* Continuous progress pill: a single rounded track that fills left-to-right as
* the user advances, with faint ticks marking each step boundary.

View File

@@ -89,6 +89,7 @@ export const URL_CONSTANTS = {
ONBOARDING_START: "/api/companies/onboarding/start",
ONBOARDING_STEP: "/api/companies/onboarding-step",
ONBOARDING_COMPLETE: "/api/companies/onboarding/complete",
ONBOARDING_REQUIREMENTS: "/api/companies/onboarding/requirements",
DASHBOARD: "/api/companies/dashboard",
FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info",
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,

View File

@@ -74,13 +74,6 @@ const useAuth = () => {
setCookie("auth-token", res.token, 7);
setCookie("refresh-token", res.refreshToken, 7);
await authQuery.refetch();
const otpCode = res.otp?.split(" ")?.[6] ?? "";
localStorage.setItem("otp", otpCode);
localStorage.setItem("otp-phone", payload.phoneNumber);
localStorage.setItem("otp-email", payload.email);
api.auth.sendOTP
.call({ phone: payload.phoneNumber, otp: otpCode })
.catch(() => { });
return { success: true, data: res };
} catch (err) {
return { success: false, error: extractApiError(err) };
@@ -164,6 +157,15 @@ const useAuth = () => {
companyInfo?.profile?.onboardingCompleted ?? false;
const onboardingStep = companyInfo?.profile?.onboardingStep ?? null;
// Booking is gated on backoffice approval of the active operational profile:
// a customer can only book under a profile once its status is "active".
const activeProfile =
companyInfo?.company?.companyProfiles?.find(
(p) => p.id === activeCompanyProfileId,
) ?? null;
const activeProfileStatus = activeProfile?.status ?? null;
const canBook = activeProfileStatus === "active";
/** Refetch everything scoped to the active operational profile. */
const invalidateScopedData = async () => {
await Promise.all([
@@ -232,6 +234,8 @@ const useAuth = () => {
customer: isAuthenticated ? (companyQuery.data ?? null) : null,
activeProfileType,
activeCompanyProfileId,
activeProfileStatus,
canBook,
companyType,
companyStatus,
isCompanyApproved,

View File

@@ -9,7 +9,6 @@ import {
HelloSection,
InvoicesSection,
RecentActivitySection,
SetupPrompt,
ShipmentsSection,
StatsSection,
} from "./components";
@@ -21,7 +20,6 @@ export default function MyPortalPage() {
null,
);
const {
customer,
companyProfiles,
bookingsQuery,
dashboardQuery,
@@ -67,8 +65,6 @@ export default function MyPortalPage() {
</Group>
)}
<SetupPrompt show={!customer} />
<StatsSection
activeBookingsLength={activeBookings.length}
newActiveThisWeek={newActiveThisWeek}
@@ -108,9 +104,7 @@ export default function MyPortalPage() {
<FreightVolumeSection
totalTonnes={dashboard?.freightVolume.totalTonnes ?? 0}
totalValue={dashboard?.freightVolume.totalValue ?? 0}
currency={
(dashboard?.freightVolume.currency ?? "ETB") as Currency
}
currency={(dashboard?.freightVolume.currency ?? "ETB") as Currency}
ytdChangePct={dashboard?.freightVolume.ytdChangePct ?? 0}
volumePoints={volumePoints}
maxVolume={maxVolume}

View File

@@ -3,6 +3,12 @@ import { memo } from "react";
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
import { Stepper } from "./Stepper";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
import { BookingActionButton } from "@/pages/bookings/clearance/BookingActionButton";
import { bookingHasInlineAction } from "@/pages/bookings/clearance/bookingNextAction";
import {
ContractSignButton,
bookingIsSignable,
} from "@/pages/bookings/contract/ContractSignButton";
interface BookingRowProps {
booking: any;
@@ -23,6 +29,12 @@ export const BookingRow = memo(function BookingRow({
// instead of navigating to the detail page.
const canPay =
booking.status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
// Clearance/operation steps + changes-requested resubmit can be done in place
// via a modal on the row.
const hasInlineAction = bookingHasInlineAction(booking);
// Contract ready for signature → "View & sign" jumps straight to the
// full-page contract viewer where the signature flow lives.
const canSign = bookingIsSignable(booking);
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
const dest =
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
@@ -85,6 +97,10 @@ export const BookingRow = memo(function BookingRow({
</Group>
{canPay ? (
<PayNowButton booking={booking} size="sm" />
) : canSign ? (
<ContractSignButton booking={booking} size="sm" />
) : hasInlineAction ? (
<BookingActionButton booking={booking} size="sm" />
) : (
<Group
gap={5}

View File

@@ -26,7 +26,7 @@ export const HelloSection = memo(function HelloSection({
</Group>
</Box>
<Link to="/bookings/new">
<Link to="/bookings/new" state={{ fresh: true }}>
<Group
gap={14}
align="center"

View File

@@ -1,70 +0,0 @@
import { Box, Group, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, Truck, AlertTriangle } from "lucide-react";
import { memo } from "react";
import { Link } from "react-router-dom";
import { api } from "@/services/api";
import type { ProfileResponse } from "@/types/profile";
import { cv } from "../constants";
const REQUIRED_FIELDS: (keyof ProfileResponse)[] = [
"companyEmail",
"companyPhone",
"companyAddress",
"fanNumber",
"contactPersonName",
"contactPersonPhone",
"generalManagerName",
"generalManagerEmail",
"generalManagerPhone",
];
function isProfileIncomplete(profile?: ProfileResponse | null): boolean {
if (!profile) return true;
return REQUIRED_FIELDS.some((field) => !profile[field]);
}
interface SetupPromptProps {
show: boolean;
}
export const SetupPrompt = memo(function SetupPrompt({ show }: SetupPromptProps) {
const profileQuery = useQuery(
api.companies.getProfile.queryOptions({ retry: false }),
);
const incomplete = !profileQuery.isPending && isProfileIncomplete(profileQuery.data);
if (!show && !incomplete) return null;
return (
<Box className="rounded-2xl border border-edr-border bg-gradient-to-r from-edr-blue/5 to-edr-green/5 px-7 py-6">
<Group justify="space-between" align="center" wrap="nowrap">
<Box className="flex-1">
<Group gap={6} align="center" mb={6}>
{incomplete && <AlertTriangle size={16} color={cv("edr-orange")} />}
<Text fz={15} fw={700} c="edr-text">
{incomplete ? "Complete Your Profile" : "Setup your Company Profile"}
</Text>
</Group>
<Text fz={13} c="edr-muted" mb={12}>
{incomplete
? "Your company profile is incomplete. Fill in the missing details to unlock all features."
: "Complete your company information to unlock all features and start booking shipments."}
</Text>
<Link to="/settings" className="no-underline">
<Group gap={8} align="center" className="w-fit">
<Text fz={13} fw={600} c="edr-green.7">
{incomplete ? "Complete Profile" : "Complete Setup"}
</Text>
<ArrowRight size={16} color={cv("edr-green.7")} />
</Group>
</Link>
</Box>
<Box className="hidden shrink-0 sm:block">
<Truck size={48} color={cv("edr-blue")} opacity={0.3} />
</Box>
</Group>
</Box>
);
});

View File

@@ -1,15 +1,30 @@
import { Box, Group, Text } from "@mantine/core";
import { memo } from "react";
import type { LucideIcon } from "lucide-react";
import { memo } from "react";
import { cv } from "../constants";
/** Accent families map a KPI to a soft tile + strong ink pair from the theme. */
type Accent = "green" | "amber" | "blue" | "slate";
const ACCENTS: Record<Accent, { soft: string; ink: string }> = {
green: { soft: cv("edr-soft"), ink: cv("edr-green.7") },
amber: { soft: cv("edr-amber-soft"), ink: cv("edr-amber-text") },
blue: { soft: cv("edr-blue-soft"), ink: cv("edr-blue") },
slate: { soft: cv("edr-slate-soft"), ink: cv("edr-slate") },
};
interface StatKpiProps {
icon: LucideIcon;
label: string;
value: string;
delta: string;
deltaColor: string;
/** Color family for the icon chip. */
accent: Accent;
/** Tint of the delta pill — defaults to the card accent. */
deltaTone?: Accent | "muted";
/** Draw a separating border on the left (on wide layouts). */
divider?: boolean;
loading?: boolean;
}
export const StatKpi = memo(function StatKpi({
@@ -17,30 +32,67 @@ export const StatKpi = memo(function StatKpi({
label,
value,
delta,
deltaColor,
accent,
deltaTone,
divider,
loading,
}: StatKpiProps) {
const a = ACCENTS[accent];
const tone = deltaTone ?? accent;
const pill =
tone === "muted"
? { bg: cv("edr-slate-soft2"), fg: cv("edr-muted") }
: { bg: ACCENTS[tone].soft, fg: ACCENTS[tone].ink };
return (
<Box
px={4}
className={
divider ? "lg:border-l lg:border-edr-border lg:pl-7" : undefined
divider
? "flex flex-col lg:border-l lg:border-edr-divider lg:pl-4"
: "flex flex-col"
}
>
<Group gap={6} align="center" mb={7} wrap="nowrap">
<Icon size={15} color={cv("edr-muted")} className="shrink-0" />
<Text fz={12} fw={600} c="edr-muted" truncate>
{label}
</Text>
</Group>
<Group gap={8} align="flex-end" wrap="nowrap">
<Text fz={22} fw={800} lh={1} c="edr-text" truncate>
{value}
</Text>
<Text fz={12} fw={600} lh={1.3} c={deltaColor} truncate>
{delta}
</Text>
{/* Icon chip + metric label, aligned on one line. */}
<Group gap={12} wrap="nowrap" align="start">
<Box
className="flex size-10 shrink-0 items-center justify-center rounded-lg"
style={{ background: a.soft }}
>
<Icon size={18} color={a.ink} strokeWidth={2} />
</Box>
<Box>
<Box className="flex-row! flex items-end gap-2">
<Text
fz={24}
fw={800}
lh={1.1}
c="edr-text"
truncate
className="tracking-tight"
>
{loading ? "—" : value}
</Text>
{delta && !loading && (
<Box
px={8}
py={3}
className="inline-flex w-fit rounded-full"
style={{ background: pill.bg, maxWidth: "100%" }}
>
<Text fz={10} fw={700} lh={1.4} truncate style={{ color: pill.fg }}>
{delta}
</Text>
</Box>
)}
</Box>
<Text fz={12} mt="xs" fw={600} c="edr-muted" truncate>
{label}
</Text>
</Box>
</Group>
{/* Value + its trend pill, grouped together at the bottom of the cell. */}
</Box>
);
});

View File

@@ -1,7 +1,7 @@
import { formatCurrency } from "@/pages/billing/invoices.mock";
import { SimpleGrid } from "@mantine/core";
import { CheckCircle2, Clock3, Truck, Wallet } from "lucide-react";
import { memo } from "react";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import { formatPct } from "../constants";
import { Card } from "./Card";
import { StatKpi } from "./StatKpi";
@@ -29,39 +29,51 @@ export const StatsSection = memo(function StatsSection({
completionRate,
spendYtd,
spendYtdChangePct,
dashboardLoading,
}: StatsSectionProps) {
return (
<Card className="rounded-2xl border border-edr-border bg-gradient-to-br from-white to-edr-soft px-7 py-5">
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing={0} verticalSpacing={20}>
<Card
padding={24}
className="border-edr-divider! shadow-[0_1px_2px_rgba(16,24,40,0.04)]"
>
<SimpleGrid
cols={{ base: 2, lg: 4 }}
spacing={{ base: 20, lg: 0 }}
>
<StatKpi
icon={Truck}
accent="green"
label="Active Shipments"
value={bookingsLoading ? "—" : activeBookingsLength.toString()}
delta={bookingsLoading ? "" : `+${newActiveThisWeek} this week`}
deltaColor="edr-green.7"
value={activeBookingsLength.toString()}
delta={newActiveThisWeek > 0 ? `+${newActiveThisWeek} this week` : ""}
loading={bookingsLoading}
/>
<StatKpi
icon={Clock3}
accent="amber"
label="Awaiting Payment"
value={outstandingInvoicesLength.toString()}
delta={`${formatCurrency(totalOutstanding || 0, "ETB")} due`}
deltaColor="edr-amber-text"
loading={bookingsLoading}
divider
/>
<StatKpi
icon={CheckCircle2}
accent="blue"
label="Delivered (YTD)"
value={deliveredCount ?? "—"}
delta={completionRate ? `${completionRate}% completed` : ""}
deltaColor="edr-muted"
deltaTone="muted"
loading={dashboardLoading}
divider
/>
<StatKpi
icon={Wallet}
accent="green"
label="Spend YTD"
value={spendYtd ?? "—"}
delta={spendYtdChangePct ? `${formatPct(spendYtdChangePct)} YoY` : ""}
deltaColor="edr-green.7"
loading={dashboardLoading}
divider
/>
</SimpleGrid>

View File

@@ -6,8 +6,8 @@ export { FreightVolumeSection } from "./FreightVolumeSection";
export { HelloSection } from "./HelloSection";
export { InvoicesSection } from "./InvoicesSection";
export { RecentActivitySection } from "./RecentActivitySection";
export { SetupPrompt } from "./SetupPrompt";
export { ShipmentsSection } from "./ShipmentsSection";
export { StatKpi } from "./StatKpi";
export { StatsSection } from "./StatsSection";
export { Stepper } from "./Stepper";

View File

@@ -1,10 +1,13 @@
import {
ArrowRight,
CalendarClock,
CheckCircle2,
Clock3,
FileCheck2,
FilePen,
FileUp,
MapPin,
ShieldCheck,
Truck,
Wallet,
type LucideIcon,
@@ -161,6 +164,110 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
AWAITING_DOCUMENTS: {
stage: 3,
icon: FileUp,
iconColor: "edr-amber-text",
tile: "edr-amber-soft",
hint: "Clearance documents needed",
step: "edr-accent",
badgeLabel: "Docs needed",
badgeBg: "edr-amber-soft",
badgeText: "edr-amber-text",
badgeDot: "edr-accent",
action: { label: "Upload documents", kind: "amber", icon: ArrowRight },
},
DOCUMENTS_UNDER_REVIEW: {
stage: 3,
icon: ShieldCheck,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "Clearance under review · re-upload any queried docs",
step: "edr-blue-dot",
badgeLabel: "In review",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "Review documents", kind: "outline" },
},
CLEARANCE_READY: {
stage: 3,
icon: CalendarClock,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Cleared · choose a shipment day to proceed",
step: "edr-green.5",
badgeLabel: "Cleared",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "Schedule & proceed", kind: "amber", icon: ArrowRight },
},
OPERATION_REQUEST_PENDING: {
stage: 3,
icon: FileCheck2,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "Operation request under review by operations",
step: "edr-blue-dot",
badgeLabel: "Op. review",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "View", kind: "outline" },
},
OPERATION_REQUESTED: {
stage: 3,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Operation requested · operator taking it forward",
step: "edr-green.5",
badgeLabel: "Operation requested",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
OPERATION_CHANGES_REQUESTED: {
stage: 3,
icon: FilePen,
iconColor: "edr-amber-text",
tile: "edr-amber-soft",
hint: "Operations requested changes · please review",
step: "edr-accent",
badgeLabel: "Revise",
badgeBg: "edr-amber-soft",
badgeText: "edr-amber-text",
badgeDot: "edr-accent",
action: { label: "Review", kind: "dark" },
},
OPERATION_PRICE_PENDING_CONFIRM: {
stage: 3,
icon: Wallet,
iconColor: "edr-amber-text",
tile: "edr-amber-soft",
hint: "Price adjusted · confirm to proceed",
step: "edr-accent",
badgeLabel: "Confirm price",
badgeBg: "edr-amber-soft",
badgeText: "edr-amber-text",
badgeDot: "edr-accent",
action: { label: "Confirm", kind: "amber", icon: ArrowRight },
},
ROAD_DISPATCH_PENDING: {
stage: 3,
icon: Truck,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "Accepted · awaiting truck dispatch",
step: "edr-blue-dot",
badgeLabel: "Awaiting dispatch",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "View", kind: "outline" },
},
PNR_GENERATED: {
stage: 3,
icon: FileCheck2,
@@ -317,6 +424,84 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
PRICE_CHANGED_PENDING_CONFIRM: {
stage: 1,
icon: Wallet,
iconColor: "edr-amber-text",
tile: "edr-amber-soft",
hint: "Price changed · confirm to continue",
step: "edr-accent",
badgeLabel: "Confirm price",
badgeBg: "edr-amber-soft",
badgeText: "edr-amber-text",
badgeDot: "edr-accent",
action: { label: "Confirm", kind: "amber", icon: ArrowRight },
},
READY_FOR_ASSIGNMENT: {
stage: 2,
icon: FileCheck2,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "Approved · awaiting wagon assignment",
step: "edr-blue-dot",
badgeLabel: "Assigning",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "View", kind: "outline" },
},
WAGON_ASSIGNED: {
stage: 3,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Wagon assigned · preparing for loading",
step: "edr-green.5",
badgeLabel: "Wagon assigned",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
INVOICED: {
stage: 3,
icon: Wallet,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "Invoice issued · awaiting payment",
step: "edr-blue-dot",
badgeLabel: "Invoiced",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "View", kind: "outline" },
},
CONTRACT_ACTIVE: {
stage: 3,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Contract active · accepting orders",
step: "edr-green.5",
badgeLabel: "Active",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
CONTRACT_CLOSED: {
stage: 4,
icon: CheckCircle2,
iconColor: "edr-slate",
tile: "edr-slate-soft2",
hint: "Contract closed · quantity used or window elapsed",
step: "edr-step",
badgeLabel: "Closed",
badgeBg: "edr-slate-soft2",
badgeText: "edr-slate",
badgeDot: "edr-step",
action: { label: "View", kind: "outline" },
},
};
export const ACTION_PROPS: Record<

View File

@@ -0,0 +1,205 @@
import {
Alert,
Button,
Group,
Modal,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertCircle, Send, XCircle } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import type { Freight } from "@edr/types";
import { PriceChangeModal } from "@/pages/bookings/resubmit/PriceChangeModal";
import { ResubmitDocuments } from "@/pages/bookings/resubmit/ResubmitDocuments";
import { useResubmitFlow } from "@/pages/bookings/resubmit/useResubmitFlow";
import { CardTitle, PageShell, SectionCard } from "./components/layout";
import { BodyGrid } from "./components/layout";
import { ActionRequiredBanner, MutationErrors } from "./components/Notices";
import { PageHeader } from "./components/PageHeader";
import { EstimateCard } from "./components/pricing";
import { ScheduleCard } from "./components/ScheduleCard";
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard";
/**
* Detail-page view for a booking staff returned with CHANGES_REQUESTED.
*
* Unlike a fresh draft, this booking already went through submission, so the
* documents shown are exactly the files the customer submitted (`booking.files`)
* — not a fixed required-document list. The customer reviews the staff note,
* replaces any document they need to update, and resubmits in place.
*/
export function ChangesRequestedView({
booking,
onBookingUpdated,
}: {
booking: Freight.IBooking;
onBookingUpdated: () => void;
}) {
const navigate = useNavigate();
const flow = useResubmitFlow(booking, { onResubmitted: onBookingUpdated });
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
const { data: generatedPricing } = useQuery(
api.bookings.generatePrice.queryOptions({
input: { id: booking.id },
enabled: !booking.pricingBreakdown,
}),
);
const pricing = (booking.pricingBreakdown ??
generatedPricing ??
null) as Freight.PricingBreakdown | null;
const cancelMutation = useMutation({
mutationFn: (reason: string) =>
api.bookings.cancel.call({ id: booking.id, reason }),
onSuccess: () => {
setCancelDialogOpen(false);
onBookingUpdated();
},
});
return (
<PageShell>
<PageHeader
booking={booking}
menuActions={{
onCancel: () => setCancelDialogOpen(true),
onSupport: () => navigate("/support"),
}}
/>
<MutationErrors mutations={[...flow.mutations, cancelMutation]} />
<StatusHero booking={booking}>
{booking.latestChangeRequestNote ? (
<ActionRequiredBanner title="Review the requested changes, then resubmit.">
{booking.latestChangeRequestNote}
</ActionRequiredBanner>
) : undefined}
</StatusHero>
<BodyGrid
left={
<>
<ShipmentDetailsCard booking={booking} />
<SectionCard>
<Group justify="space-between" align="center" mb="md">
<CardTitle>Your documents</CardTitle>
</Group>
<Text fz="12.5px" c="#6B7C8E" mb="sm">
Update the documents for this booking, then resubmit for review.
Replace any that changed and attach any that are still required.
</Text>
<ResubmitDocuments flow={flow} />
{flow.validationError && (
<Alert
color="red"
radius="md"
icon={<AlertCircle size={16} />}
mt="md"
>
{flow.validationError}
</Alert>
)}
<Button
fullWidth
mt="lg"
radius={10}
color="#0C1A2B"
leftSection={<Send size={16} />}
onClick={flow.resubmit}
loading={flow.isBusy}
disabled={flow.isBusy}
styles={{
root: { height: 46 },
label: { fontSize: 14, fontWeight: 800 },
}}
>
{flow.isBusy ? "Resubmitting…" : "Resubmit for review"}
</Button>
</SectionCard>
</>
}
right={
<>
<EstimateCard
pricing={pricing}
title="Estimated Cost"
chip="Not invoiced"
/>
<ScheduleCard booking={booking} title="Schedule & Service" />
<SupportCard onCancel={() => setCancelDialogOpen(true)} />
</>
}
/>
<PriceChangeModal
data={flow.priceChange}
onClose={flow.clearPriceChange}
onConfirm={flow.confirmSubmit}
confirmPending={flow.confirmSubmitPending}
/>
<Modal
opened={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}
title={<Text fw={700}>Cancel booking</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Are you sure you want to cancel <strong>{booking.reference}</strong>?
This action cannot be undone.
</Text>
<TextInput
label="Reason for cancellation (optional)"
placeholder="e.g. Change of plans, duplicate booking…"
value={cancelReason}
onChange={(e) => setCancelReason(e.currentTarget.value)}
radius="md"
data-autofocus
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setCancelDialogOpen(false)}
>
Keep booking
</Button>
<Button
color="red"
radius="md"
onClick={() =>
cancelMutation.mutate(cancelReason.trim() || "Cancelled by customer")
}
disabled={cancelMutation.isPending}
loading={cancelMutation.isPending}
leftSection={
!cancelMutation.isPending ? <XCircle size={15} /> : undefined
}
>
Yes, cancel
</Button>
</Group>
</Stack>
</Modal>
</PageShell>
);
}

View File

@@ -31,11 +31,7 @@ import { CardTitle, PageShell, SectionCard } from "./components/layout";
import { CountChip, DocRow, IconSquare } from "./components/Documents";
import { EstimateCard } from "./components/pricing";
import { HeaderButton, PageHeader } from "./components/PageHeader";
import {
ActionRequiredBanner,
MutationErrors,
NoticeBanner,
} from "./components/Notices";
import { MutationErrors, NoticeBanner } from "./components/Notices";
import { ScheduleCard } from "./components/ScheduleCard";
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { StatusHero } from "./components/StatusHero";
@@ -77,9 +73,7 @@ export function DraftBookingView({
const { data: generatedPricing } = useQuery(
api.bookings.generatePrice.queryOptions({
input: { id: booking.id },
enabled:
(booking.status === "DRAFT" || booking.status === "CHANGES_REQUESTED") &&
!booking.pricingBreakdown,
enabled: booking.status === "DRAFT" && !booking.pricingBreakdown,
}),
);
const pricing = (booking.pricingBreakdown ??
@@ -87,17 +81,8 @@ export function DraftBookingView({
null) as Freight.PricingBreakdown | null;
const uploadMutation = useMutation({
mutationFn: async (files: Record<string, File | File[] | null>) => {
if (booking.status === "CHANGES_REQUESTED") {
const result = await api.bookings.update.call({
id: booking.id,
dto: {},
documents: files,
});
return result.booking;
}
return api.bookings.uploadDocuments.call({ id: booking.id, files });
},
mutationFn: (files: Record<string, File | File[] | null>) =>
api.bookings.uploadDocuments.call({ id: booking.id, files }),
onSuccess: () => {
setSelectedFiles({});
setDocError("");
@@ -190,19 +175,7 @@ export function DraftBookingView({
]}
/>
<StatusHero booking={booking}>
{booking.status === "CHANGES_REQUESTED" &&
booking.latestChangeRequestNote ? (
<ActionRequiredBanner
title="Review the requested changes, then resubmit."
onAction={() =>
navigate(`/bookings/${booking.id}/edit?section=documents`)
}
>
{booking.latestChangeRequestNote}
</ActionRequiredBanner>
) : undefined}
</StatusHero>
<StatusHero booking={booking} />
<BodyGrid
left={
@@ -294,8 +267,9 @@ export function DraftBookingView({
const isUploaded = uploadedCodes.has(doc.key);
const selected = selectedFiles[doc.key];
const file = booking.files?.find((f) => f.code === doc.key);
const allowReplace =
!isUploaded || booking.status === "CHANGES_REQUESTED";
// In a draft, an already-uploaded doc can still be replaced
// before first submit.
const allowReplace = !isUploaded;
return (
<DocRow
key={doc.key}

View File

@@ -93,7 +93,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
}
menuActions={{
onViewContract: booking.signedByCeoAt ? () => {} : undefined,
onRebook: () => navigate("/bookings/new"),
onRebook: () => navigate("/bookings/new", { state: { fresh: true } }),
onSupport: () => navigate("/support"),
}}
/>
@@ -110,14 +110,14 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
: "This booking process has been terminated."
}
reason={booking.latestChangeRequestNote}
onRebook={() => navigate("/bookings/new")}
onRebook={() => navigate("/bookings/new", { state: { fresh: true } })}
/>
) : isExpired ? (
<CancelledBanner
pillLabel="Expired"
title={`The payment window expired on ${fmtDate(booking.updatedAt)}.`}
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
onRebook={() => navigate("/bookings/new")}
onRebook={() => navigate("/bookings/new", { state: { fresh: true } })}
/>
) : isPendingConsolidation ? (
<ConsolidationWaitingBanner

View File

@@ -1,127 +1,28 @@
import {
Alert,
Box,
Button,
FileButton,
Group,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
Clock,
Download,
FileText,
Plus,
Upload,
} from "lucide-react";
import { useMemo, useState } from "react";
import { Alert, Button, Group } from "@mantine/core";
import { CheckCircle2, Upload } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import type { Freight } from "@edr/types";
import { ClearanceFlow } from "@/pages/bookings/clearance/ClearanceFlow";
import { useClearanceFlow } from "@/pages/bookings/clearance/useClearanceFlow";
import { CardTitle, SectionCard } from "./layout";
import { IconSquare } from "./Documents";
const GREEN = "#0A6F4D";
function StatusPill({ doc }: { doc: Freight.ClearanceDocument }) {
if (doc.reviewStatus === "APPROVED") {
return (
<Group gap={6} c={GREEN}>
<CheckCircle2 size={15} />
<Text fz="12px" fw={600} c={GREEN}>
Approved
</Text>
</Group>
);
}
if (doc.reviewStatus === "QUERIED") {
return (
<Group gap={6} c="#C0392B">
<AlertCircle size={15} />
<Text fz="12px" fw={600} c="#C0392B">
Queried
</Text>
</Group>
);
}
if (doc.file) {
return (
<Group gap={6} c="#2E5B96">
<Clock size={15} />
<Text fz="12px" fw={600} c="#2E5B96">
Pending review
</Text>
</Group>
);
}
return (
<Text fz="12px" fw={600} c="#9AA8B5">
Not uploaded
</Text>
);
}
/**
* Customer-facing clearance section: shows the resolved document grid, lets the
* customer (re)upload pending/queried documents plus ad-hoc named documents, and
* proceed to operation once Global Logistics marks the booking CLEARANCE_READY.
* Customer-facing clearance section on the booking detail page: shows the
* resolved document grid, lets the customer (re)upload pending/queried documents
* plus ad-hoc named documents, and proceed to operation once Global Logistics
* marks the booking CLEARANCE_READY.
*
* The flow body, calendar, and mutations are shared with the home-page action
* modal via `useClearanceFlow` / `ClearanceFlow`.
*/
export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const status = booking.status as string;
const flow = useClearanceFlow(booking);
const { data: clearance, isLoading } = useQuery(
api.bookings.getClearance.queryOptions({ input: { id: booking.id } }),
);
// Pending uploads keyed by fileKey, plus ad-hoc rows (label + file).
const [pending, setPending] = useState<Record<string, File>>({});
const [adHoc, setAdHoc] = useState<Array<{ name: string; file: File | null }>>(
[],
);
const refresh = () => {
queryClient.invalidateQueries({
queryKey: api.bookings.getClearance.queryKey({ id: booking.id }),
});
queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: booking.id }),
});
};
const uploadMutation = useMutation({
...api.bookings.submitClearanceDocuments.mutationOptions(),
onSuccess: () => {
setPending({});
setAdHoc([]);
refresh();
},
});
const proceedMutation = useMutation({
...api.bookings.proceedToOperation.mutationOptions(),
onSuccess: () => refresh(),
});
// Only the customer-input documents are uploadable here; GL output docs are
// shown read-only.
const customerDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
[clearance],
);
const glDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"),
[clearance],
);
if (status === "OPERATION_REQUESTED") {
if (flow.status === "OPERATION_REQUESTED") {
return (
<SectionCard>
<CardTitle>Operation</CardTitle>
@@ -132,246 +33,56 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
);
}
if (isLoading || !clearance) {
if (flow.isLoading || !flow.clearance) {
return (
<SectionCard>
<CardTitle>Clearance documents</CardTitle>
<Text fz="13px" c="dimmed" mt="sm">
Loading clearance
</Text>
</SectionCard>
);
}
const isReady = status === "CLEARANCE_READY";
const canUpload =
status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
function handleSubmit() {
const files: Record<string, File | null> = { ...pending };
adHoc.forEach((row, i) => {
if (row.file) files[`custom_${Date.now()}_${i}`] = row.file;
});
if (Object.keys(files).length === 0) return;
uploadMutation.mutate({ id: booking.id, files });
}
return (
<SectionCard>
<Group justify="space-between" align="center" mb="md">
<CardTitle>Clearance documents</CardTitle>
{clearance.includesCustoms && (
<Text fz="12px" fw={600} c="#9AA8B5">
Customs clearance
</Text>
)}
</Group>
{isReady ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
Clearance is ready. You can now proceed to operation.
</Alert>
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
Global Logistics is reviewing your documents. Queried documents below
need to be re-uploaded.
</Alert>
) : (
<Alert color="yellow" radius="md" icon={<AlertCircle size={18} />} mb="md">
Upload the documents below to start the clearance review.
</Alert>
)}
<Stack gap={10}>
{customerDocs.map((doc) => (
<Box
key={doc.fileKey}
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 12 }}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<Box c="#2E5B96">
<FileText size={18} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz="13.5px" fw={600} c="#10202F" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
{doc.file && (
<Text fz="12px" c="dimmed" truncate>
{doc.file.name}
</Text>
)}
</Box>
</Group>
<Group gap={10} wrap="nowrap">
<StatusPill doc={doc} />
{doc.file && (
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
)}
{canUpload && doc.reviewStatus !== "APPROVED" && (
<FileButton
onChange={(f) =>
f && setPending((p) => ({ ...p, [doc.fileKey]: f }))
}
accept="application/pdf,image/*"
>
{(props) => (
<Button
{...props}
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Upload size={13} />}
>
{pending[doc.fileKey] ? "Selected" : "Upload"}
</Button>
)}
</FileButton>
)}
</Group>
</Group>
{doc.reviewStatus === "QUERIED" && doc.note && (
<Text fz="12px" c="#C0392B" mt={6}>
Query: {doc.note}
</Text>
)}
{pending[doc.fileKey] && (
<Text fz="12px" c={GREEN} mt={6}>
Ready to upload: {pending[doc.fileKey].name}
</Text>
)}
</Box>
))}
</Stack>
{/* GL output documents (read-only to the customer). */}
{glDocs.length > 0 && (
<>
<Text fz="12.5px" fw={700} c="#10202F" mt="lg" mb={8}>
Customs output documents
</Text>
<Stack gap={8}>
{glDocs.map((doc) => (
<Group
key={doc.fileKey}
justify="space-between"
wrap="nowrap"
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 10 }}
<ClearanceFlow
booking={booking}
flow={flow}
footer={
<Group justify="flex-end" mt="lg" gap="sm">
{flow.canUpload && (
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
onClick={() => flow.submitDocuments()}
loading={flow.uploadMutation.isPending}
disabled={!flow.canSubmit}
>
<Text fz="13px" c="#10202F" truncate>
{doc.label}
</Text>
{doc.file ? (
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
) : (
<Text fz="12px" c="#9AA8B5">
Pending
</Text>
)}
</Group>
))}
</Stack>
</>
)}
{/* Ad-hoc / additional documents. */}
{canUpload && (
<Box mt="lg">
<Group justify="space-between" align="center" mb={8}>
<Text fz="12.5px" fw={700} c="#10202F">
Additional documents
</Text>
<Button
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Plus size={13} />}
onClick={() => setAdHoc((r) => [...r, { name: "", file: null }])}
>
Add document
</Button>
Submit documents
</Button>
)}
{flow.isReady && (
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
onClick={() =>
flow.proceedToOperation({
onSuccess: () => navigate(`/bookings/${booking.id}`),
})
}
loading={flow.proceedMutation.isPending}
disabled={!flow.scheduledDate}
>
Proceed to operation
</Button>
)}
</Group>
<Stack gap={8}>
{adHoc.map((row, i) => (
<Group key={i} gap={8} wrap="nowrap">
<TextInput
placeholder="Document name"
value={row.name}
onChange={(e) =>
setAdHoc((rows) =>
rows.map((r, j) =>
j === i ? { ...r, name: e.currentTarget.value } : r,
),
)
}
style={{ flex: 1 }}
radius="md"
/>
<FileButton
onChange={(f) =>
setAdHoc((rows) =>
rows.map((r, j) => (j === i ? { ...r, file: f } : r)),
)
}
accept="application/pdf,image/*"
>
{(props) => (
<Button {...props} variant="default" radius="md">
{row.file ? row.file.name.slice(0, 14) : "Choose file"}
</Button>
)}
</FileButton>
</Group>
))}
</Stack>
</Box>
)}
{uploadMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
{uploadMutation.error instanceof Error
? uploadMutation.error.message
: "Upload failed. Please try again."}
</Alert>
)}
<Group justify="flex-end" mt="lg" gap="sm">
{canUpload && (
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
onClick={handleSubmit}
loading={uploadMutation.isPending}
disabled={
Object.keys(pending).length === 0 &&
!adHoc.some((r) => r.file)
}
>
Submit documents
</Button>
)}
{isReady && (
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
onClick={() =>
proceedMutation.mutate(
{ id: booking.id },
{ onSuccess: () => navigate(`/bookings/${booking.id}`) },
)
}
loading={proceedMutation.isPending}
>
Proceed to operation
</Button>
)}
</Group>
}
/>
</SectionCard>
);
}

View File

@@ -177,6 +177,61 @@ export const STATUS_MAP: Record<
description: "Cargo has been consolidated with a partner shipment.",
stage: 5,
},
AWAITING_DOCUMENTS: {
title: "Clearance documents needed",
description:
"Upload the required clearance documents so your shipment can be reviewed.",
stage: 5,
},
DOCUMENTS_UNDER_REVIEW: {
title: "Documents under review",
description:
"Your clearance documents are being reviewed. Re-upload any queried documents to proceed.",
stage: 5,
},
CLEARANCE_READY: {
title: "Cleared — choose a shipment day",
description:
"Clearance is complete. Pick a shipment day and proceed to operation.",
stage: 5,
},
OPERATION_REQUESTED: {
title: "Operation requested",
description: "Operation requested. An operator will take your shipment forward.",
stage: 5,
},
CONTRACT_ACTIVE: {
title: "Contract active",
description: "This general contract is active and accepting drawdown orders.",
stage: 5,
},
CONTRACT_CLOSED: {
title: "Contract closed",
description:
"This general contract is closed — its reserved quantity has been used or its window has elapsed.",
stage: 7,
},
PRICE_CHANGED_PENDING_CONFIRM: {
title: "Price changed — confirm to proceed",
description:
"The price for this booking changed. Confirm the new price to continue.",
stage: 1,
},
READY_FOR_ASSIGNMENT: {
title: "Awaiting wagon assignment",
description: "Approved and queued for wagon assignment.",
stage: 2,
},
WAGON_ASSIGNED: {
title: "Wagon assigned",
description: "A wagon has been assigned and your cargo is being prepared for loading.",
stage: 5,
},
INVOICED: {
title: "Invoice issued",
description: "An invoice has been issued for this booking.",
stage: 5,
},
COMPLETED: {
title: "Service complete",
description: "Cargo delivered and service successfully terminated.",

View File

@@ -5,6 +5,7 @@ import { useParams } from "react-router-dom";
import { api } from "@/services/api";
import { ChangesRequestedView } from "./ChangesRequestedView";
import { DraftBookingView } from "./DraftBookingView";
import { PageShell, SectionCard } from "./components/layout";
import { ReadonlyBookingView } from "./ReadonlyBookingView";
@@ -77,6 +78,17 @@ export default function BookingDetailPage() {
);
}
// Staff returned the booking for changes: resubmit-with-updated-documents
// flow, driven by the files the customer actually submitted.
if (booking.status === "CHANGES_REQUESTED") {
return (
<ChangesRequestedView
booking={booking}
onBookingUpdated={refetchBooking}
/>
);
}
// Brand-new draft: collect the required documents before first submit.
if (isDraftLike(booking.status)) {
return (
<DraftBookingView booking={booking} onBookingUpdated={refetchBooking} />

View File

@@ -34,8 +34,8 @@ import {
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton";
import useAuth from "@/hooks/useAuth";
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
import { BookingActionButton } from "./clearance/BookingActionButton";
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
import {
BookingTypeBadge,
CargoModeCell,
@@ -65,7 +65,11 @@ import {
// ── Status filter options (grouped by lifecycle) ──────────────────────────────
const STATUS_FILTERS = [
{ key: "all", label: "All bookings", statuses: undefined as string | undefined },
{
key: "all",
label: "All bookings",
statuses: undefined as string | undefined,
},
{
key: "active",
label: "In progress",
@@ -81,12 +85,19 @@ const STATUS_FILTERS = [
},
{ key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" },
{ key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
{ key: "closed", label: "Cancelled / rejected", statuses: "CANCELLED,REJECTED" },
{
key: "closed",
label: "Cancelled / rejected",
statuses: "CANCELLED,REJECTED",
},
] as const;
type StatusFilterKey = (typeof STATUS_FILTERS)[number]["key"];
const SELECT_DATA = STATUS_FILTERS.map((f) => ({ value: f.key, label: f.label }));
const SELECT_DATA = STATUS_FILTERS.map((f) => ({
value: f.key,
label: f.label,
}));
// ── Summary stat cards (clickable lifecycle filters) ──────────────────────────
@@ -97,42 +108,42 @@ const STAT_CARDS: Array<{
iconBg: string;
iconColor: string;
}> = [
{
key: "all",
label: "All bookings",
icon: LayoutList,
iconBg: "#ECF6F1",
iconColor: "#0A8A5F",
},
{
key: "active",
label: "In progress",
icon: Package,
iconBg: "#FDF3E0",
iconColor: "#C77F09",
},
{
key: "payment",
label: "Awaiting payment",
icon: Wallet,
iconBg: "#FEF6E6",
iconColor: "#F2A516",
},
{
key: "draft",
label: "Drafts",
icon: FileEdit,
iconBg: "#F1F4F7",
iconColor: "#475569",
},
{
key: "done",
label: "Completed",
icon: CheckCircle2,
iconBg: "#ECF6F1",
iconColor: "#0A8A5F",
},
];
{
key: "all",
label: "All bookings",
icon: LayoutList,
iconBg: "#ECF6F1",
iconColor: "#0A8A5F",
},
{
key: "active",
label: "In progress",
icon: Package,
iconBg: "#FDF3E0",
iconColor: "#C77F09",
},
{
key: "payment",
label: "Awaiting payment",
icon: Wallet,
iconBg: "#FEF6E6",
iconColor: "#F2A516",
},
{
key: "draft",
label: "Drafts",
icon: FileEdit,
iconBg: "#F1F4F7",
iconColor: "#475569",
},
{
key: "done",
label: "Completed",
icon: CheckCircle2,
iconBg: "#ECF6F1",
iconColor: "#0A8A5F",
},
];
// ── Status badge (reuses the shared portal status config) ─────────────────────
@@ -140,8 +151,12 @@ function StatusBadge({ status }: { status: string }) {
const cfg = STATUS_CONFIG[status];
const label = cfg?.badgeLabel ?? status.replace(/_/g, " ");
const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
const text = cfg ? `var(--mantine-color-${cfg.badgeText}-7, #475569)` : "#475569";
const dot = cfg ? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)` : "#94A3B8";
const text = cfg
? `var(--mantine-color-${cfg.badgeText}-7, #475569)`
: "#475569";
const dot = cfg
? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)`
: "#94A3B8";
return (
<Group
gap={6}
@@ -192,27 +207,20 @@ function PrimaryAction({
fw={700}
fz={13}
rightSection={<ArrowRight size={14} />}
style={{ backgroundColor: "var(--mantine-color-edr-ink-0)", color: "#fff" }}
style={{
backgroundColor: "var(--mantine-color-edr-ink-0)",
color: "#fff",
}}
onClick={go}
>
Continue
</Button>
);
}
if (status === "CHANGES_REQUESTED") {
return (
<Button
size="xs"
radius="md"
fw={700}
fz={13}
color="orange"
rightSection={<ArrowRight size={14} />}
onClick={() => onNavigate(`/bookings/${id}/edit?section=documents`)}
>
Review changes
</Button>
);
// CHANGES_REQUESTED + clearance/operation steps are handled in place by a
// modal (update & resubmit, upload clearance docs, schedule & proceed).
if (bookingHasInlineAction(booking)) {
return <BookingActionButton booking={booking} size="xs" />;
}
const payableStatus = isGeneralContract
? "FULLY_EXECUTED"
@@ -221,7 +229,14 @@ function PrimaryAction({
return <PayNowButton booking={booking} />;
}
return (
<Button size="xs" radius="md" variant="default" fw={600} fz={13} onClick={go}>
<Button
size="xs"
radius="md"
variant="default"
fw={600}
fz={13}
onClick={go}
>
View
</Button>
);
@@ -233,7 +248,11 @@ function ColHeader({ label }: { label: string }) {
fz={11}
fw={700}
c="edr-muted"
style={{ letterSpacing: "0.6px", textTransform: "uppercase", whiteSpace: "nowrap" }}
style={{
letterSpacing: "0.6px",
textTransform: "uppercase",
whiteSpace: "nowrap",
}}
>
{label}
</Text>
@@ -248,22 +267,19 @@ function fmtDate(iso?: string | null): string {
return Number.isNaN(d.getTime())
? ""
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
year: "numeric",
month: "short",
day: "numeric",
});
}
// ── Main component ────────────────────────────────────────────────────────────
// Lightweight count query for a single lifecycle filter (reads only `total`).
function useStatusCount(
statuses: string | undefined,
companyProfileId?: string,
): number | undefined {
function useStatusCount(statuses: string | undefined): number | undefined {
const { data } = useQuery(
api.bookings.list.queryOptions({
input: { statuses, companyProfileId, page: 1, pageSize: 1 },
input: { statuses, page: 1, pageSize: 1 },
staleTime: 30_000,
}),
);
@@ -339,21 +355,10 @@ export default function MyBookings() {
const [query, setQuery] = useState("");
const [typeFilter, setTypeFilter] = useState<string | null>(null);
const [freightFilter, setFreightFilter] = useState<string | null>(null);
const [serviceFilter, setServiceFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<string>("");
const [createdTo, setCreatedTo] = useState<string>("");
// Operational-service options (importer / exporter / freight forwarder) for
// the per-page filter. Empty for non-customer companies.
const { company } = useAuth();
const companyProfiles = company?.company?.companyProfiles ?? [];
const serviceOptions = companyProfiles.map((p) => ({
value: p.id,
label: `${PROFILE_TYPE_LABELS[p.type] ?? p.type} · ${p.reference}`,
}));
const [trackingBooking, setTrackingBooking] = useState<Freight.IBooking | null>(
null,
);
const [trackingBooking, setTrackingBooking] =
useState<Freight.IBooking | null>(null);
const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
@@ -366,15 +371,10 @@ export default function MyBookings() {
};
const hasExtraFilters =
!!typeFilter ||
!!freightFilter ||
!!serviceFilter ||
!!createdFrom ||
!!createdTo;
!!typeFilter || !!freightFilter || !!createdFrom || !!createdTo;
const clearExtraFilters = () => {
setTypeFilter(null);
setFreightFilter(null);
setServiceFilter(null);
setCreatedFrom("");
setCreatedTo("");
resetPage();
@@ -385,7 +385,6 @@ export default function MyBookings() {
statuses,
bookingType: typeFilter ?? undefined,
freightType: freightFilter ?? undefined,
companyProfileId: serviceFilter ?? undefined,
createdFrom: createdFrom || undefined,
// include the whole selected end day
createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined,
@@ -396,7 +395,6 @@ export default function MyBookings() {
statuses,
typeFilter,
freightFilter,
serviceFilter,
createdFrom,
createdTo,
pagination.pageIndex,
@@ -408,33 +406,25 @@ export default function MyBookings() {
api.bookings.list.queryOptions({ input: filter }),
);
// Per-card lifecycle counts (one cheap query each, total-only). Scoped to the
// selected service so the cards match the filtered table.
const svc = serviceFilter ?? undefined;
const allCount = useStatusCount(undefined, svc);
// Per-card lifecycle counts (one cheap query each, total-only).
const allCount = useStatusCount(undefined);
const activeCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "active")!.statuses,
svc,
);
const paymentCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "payment")!.statuses,
svc,
);
const draftCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "draft")!.statuses,
svc,
);
const doneCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "done")!.statuses,
svc,
);
const transitCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "transit")!.statuses,
svc,
);
const closedCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "closed")!.statuses,
svc,
);
const cardCounts: Record<StatusFilterKey, number | undefined> = {
all: allCount,
@@ -462,8 +452,7 @@ export default function MyBookings() {
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
const showEmpty =
!isLoading && !isError && rows.length === 0;
const showEmpty = !isLoading && !isError && rows.length === 0;
const columns: ColumnDef<Freight.IBooking>[] = [
{
@@ -473,7 +462,8 @@ export default function MyBookings() {
header: () => <ColHeader label="Booking" />,
cell: ({ row }) => {
const b = row.original;
const cargoLabel = b.freightType === "BULK" ? "Bulk cargo" : "Container";
const cargoLabel =
b.freightType === "BULK" ? "Bulk cargo" : "Container";
return (
<Group gap={12} wrap="nowrap" align="center">
<Box
@@ -488,7 +478,11 @@ export default function MyBookings() {
justifyContent: "center",
}}
>
<Package size={18} color="var(--mantine-color-edr-green-7)" strokeWidth={2} />
<Package
size={18}
color="var(--mantine-color-edr-green-7)"
strokeWidth={2}
/>
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} c="edr-text" truncate>
@@ -594,7 +588,12 @@ export default function MyBookings() {
const booking = row.original;
const trackable = TRACKABLE_STATUSES.has(booking.status);
return (
<Group justify="flex-end" gap={8} wrap="nowrap" onClick={(e) => e.stopPropagation()}>
<Group
justify="flex-end"
gap={8}
wrap="nowrap"
onClick={(e) => e.stopPropagation()}
>
{trackable && (
<Button
size="xs"
@@ -612,7 +611,12 @@ export default function MyBookings() {
<PrimaryAction booking={booking} onNavigate={navigate} />
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
<Menu.Target>
<ActionIcon variant="transparent" size={30} radius="md" aria-label="More options">
<ActionIcon
variant="transparent"
size={30}
radius="md"
aria-label="More options"
>
<MoreVertical size={16} color="#9AA8B5" />
</ActionIcon>
</Menu.Target>
@@ -643,7 +647,12 @@ export default function MyBookings() {
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
<Box>
<Group gap={10} align="center">
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
<Title
order={1}
fw={800}
fz={26}
style={{ letterSpacing: "-0.01em" }}
>
Bookings
</Title>
</Group>
@@ -654,6 +663,7 @@ export default function MyBookings() {
<Button
component={Link}
to="/bookings/new"
state={{ fresh: true }}
color="edr-green"
radius="md"
leftSection={<Plus size={16} />}
@@ -683,7 +693,9 @@ export default function MyBookings() {
px={20}
py={14}
wrap="wrap"
style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}
style={{
borderBottom: "1px solid var(--mantine-color-edr-border-0)",
}}
>
<Group gap={10} wrap="wrap" style={{ flex: 1, minWidth: 260 }}>
<TextInput
@@ -709,7 +721,9 @@ export default function MyBookings() {
<Select
data={SELECT_DATA}
value={statusFilter}
onChange={(value) => selectFilter((value as StatusFilterKey) ?? "all")}
onChange={(value) =>
selectFilter((value as StatusFilterKey) ?? "all")
}
allowDeselect={false}
radius="md"
checkIconPosition="right"
@@ -751,22 +765,6 @@ export default function MyBookings() {
style={{ width: 150 }}
aria-label="Filter by cargo type"
/>
{serviceOptions.length > 1 && (
<Select
placeholder="All services"
data={serviceOptions}
value={serviceFilter}
onChange={(v) => {
setServiceFilter(v);
resetPage();
}}
clearable
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 200 }}
aria-label="Filter by service"
/>
)}
<TextInput
type="date"
value={createdFrom}
@@ -811,11 +809,19 @@ export default function MyBookings() {
{showEmpty ? (
<Stack align="center" gap={4} px="lg" py={64} ta="center">
<ThemeIcon size={56} radius="lg" color="edr-green" variant="light" mb="xs">
<ThemeIcon
size={56}
radius="lg"
color="edr-green"
variant="light"
mb="xs"
>
<Package size={28} />
</ThemeIcon>
<Text size="sm" fw={600} c="edr-text">
{query ? "No bookings match your search" : "No bookings here yet"}
{query
? "No bookings match your search"
: "No bookings here yet"}
</Text>
<Text size="xs" c="edr-muted" maw={320}>
{query
@@ -826,14 +832,10 @@ export default function MyBookings() {
<Button
component={Link}
to="/bookings/new"
state={{ fresh: true }}
size="sm"
color="edr-green"
radius="md"
mt="md"
leftSection={<Plus size={15} />}
>
Create first booking
</Button>
/>
)}
</Stack>
) : (
@@ -841,7 +843,9 @@ export default function MyBookings() {
columns={columns}
data={rows}
status={dataTableStatus}
onRowClick={(row) => navigate(`/bookings/${(row as Freight.IBooking).id}`)}
onRowClick={(row) =>
navigate(`/bookings/${(row as Freight.IBooking).id}`)
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
@@ -867,7 +871,8 @@ export default function MyBookings() {
bookingId={trackingBooking?.id ?? ""}
bookingReference={trackingBooking?.reference ?? ""}
originLabel={
trackingBooking?.originYard?.label ?? trackingBooking?.originYard?.code
trackingBooking?.originYard?.label ??
trackingBooking?.originYard?.code
}
destinationLabel={
trackingBooking?.destinationYard?.label ??

View File

@@ -29,7 +29,7 @@ import {
} from "lucide-react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth";
import {
BookingFormInputValues,
@@ -39,12 +39,17 @@ import {
getRouteDirection,
initialBookingFormValues,
operationToProfileType,
operationToTradeDirection,
stepFields,
type BookingDocuments,
type BookingFormValues,
type OperationType,
} from "./new-booking-form/schema";
import { StepIndicator } from "./new-booking-form/StepIndicator";
import {
clearBookingDraft,
useBookingDraft,
} from "./new-booking-form/useBookingDraft";
import {
Step0OperationType,
Step1ContractType,
@@ -53,11 +58,22 @@ import {
Step5CargoDetails,
Step8Review,
StepDocuments,
StepScheduling,
} from "./new-booking-form/steps";
type PriceModalMode = "submit" | "draft";
/** Human-readable label for a rate's charge unit (e.g. "per container"). */
function formatPriceUnit(unit: string): string {
const map: Record<string, string> = {
PER_CONTAINER: "per container",
PER_TON: "per ton",
PER_WAGON: "per wagon",
PER_KM: "per km",
FLAT: "flat",
};
return map[unit] ?? unit.replace(/_/g, " ").toLowerCase();
}
export default function NewBookingPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -67,6 +83,12 @@ export default function NewBookingPage() {
api.bookings.referenceData.queryOptions(),
);
// Booking is gated on profile approval: a customer whose active profile isn't
// approved yet is bounced back to the list, where the gate is explained.
if (!auth.isPending && auth.company && !auth.canBook) {
return <Navigate to="/bookings" replace />;
}
if (!auth.isPending && !auth.company) {
return (
<Box
@@ -176,6 +198,8 @@ export default function NewBookingPage() {
setPriceChangeResult(result);
return;
}
// Booking submitted — the saved wizard draft is no longer needed.
clearBookingDraft();
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
// A partial-wagon booking is parked until a partner is found — explain the
@@ -194,6 +218,8 @@ export default function NewBookingPage() {
return api.bookings.confirmSubmit.call({ id: priceBookingId });
},
onSuccess: (result) => {
// Booking submitted — the saved wizard draft is no longer needed.
clearBookingDraft();
setPriceChangeResult(null);
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
@@ -213,6 +239,8 @@ export default function NewBookingPage() {
return api.bookings.reject.call({ id: priceBookingId });
},
onSuccess: () => {
// The customer rejected this booking and will start over — drop the draft.
clearBookingDraft();
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate("/bookings");
@@ -236,17 +264,26 @@ export default function NewBookingPage() {
mode: "onChange",
});
// Persist the in-progress wizard to localStorage so a refresh doesn't lose it.
// The explicit "New Booking" entry points navigate with state.fresh = true to
// force a clean start; a plain refresh (no state) resumes the saved draft.
const location = useLocation();
const startFresh = (location.state as { fresh?: boolean } | null)?.fresh === true;
useBookingDraft({
form,
step,
setStep,
fresh: startFresh,
});
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");
const bookingType = form.watch("bookingType");
const isGeneralContract = bookingType === "general_contract";
const operationType = form.watch("operationType");
// General contracts have no shipment date at creation — the Schedule step
// (id 5) is skipped; the date is chosen per order against the contract later.
const visibleSteps = useMemo(
() => STEPS.filter((s) => !(isGeneralContract && s.id === 5)),
[isGeneralContract],
);
// The estimated shipment date lives in the Route step now; for general
// contracts that date field is simply hidden there (the date is chosen per
// order against the contract later). No dedicated schedule step remains.
const visibleSteps = useMemo(() => STEPS, []);
const visibleStepIds = useMemo<number[]>(
() => visibleSteps.map((s) => s.id),
[visibleSteps],
@@ -269,9 +306,16 @@ export default function NewBookingPage() {
(y) => y.id === destinationYard,
);
const route = getRouteDirection(origin, destination);
return route;
}, [originYard, destinationYard]);
// Country-based derivation is authoritative when both yards are tagged, but
// returns null if a yard is unselected or lacks a country (e.g. intercity
// yards with no country set → DOMESTIC). The operation chosen in step 0 is
// the user's explicit intent, so fall back to it to guarantee a valid value
// and avoid posting tradeDirection: null (which the API rejects with @IsIn).
return (
getRouteDirection(origin, destination) ??
(operationType ? operationToTradeDirection(operationType) : null)
);
}, [originYard, destinationYard, operationType, referenceData]);
// The company's onboarded profile types — drives which operations are offered
// and which profile each operation stamps the booking to.
@@ -365,13 +409,10 @@ export default function NewBookingPage() {
const isPerItem =
bulkChild?.unit_of_measure === Freight.CargoUnitOfMeasure.PerItem;
const isContract = data.bookingType === "general_contract";
// For bulk general contracts the contracted quantity is entered against the
// primary route in the route step; one-time bookings use the cargo-step
// amount. Item counts are rounded since fractional items are meaningless.
const bulkAmountRaw =
isContract && data.cargoType === "bulk"
? data.primaryRouteQuantity
: data.cargoWeight;
// Both one-time and general contracts take the bulk amount from the cargo
// step (cargoWeight) — general contracts no longer collect a per-route
// quantity. Item counts are rounded since fractional items are meaningless.
const bulkAmountRaw = data.cargoWeight;
const totalWeight =
data.cargoType === "container"
? 0
@@ -393,13 +434,14 @@ export default function NewBookingPage() {
bookingType: isContract
? Freight.BookingType.GeneralContract
: Freight.BookingType.OneTime,
// General contracts omit the shipment date — chosen per order later.
...(isContract
// The wizard captures a NON-BINDING estimate only — never the binding
// scheduledDate (that is chosen later at the operation-request step and
// validated against open departures). General contracts omit even the
// estimate; the date is chosen per order later.
...(isContract || !data.scheduledDate
? {}
: {
scheduledDate: data.scheduledDate
? new Date(data.scheduledDate).toISOString()
: new Date().toISOString(),
estimatedShipmentDate: new Date(data.scheduledDate).toISOString(),
}),
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
@@ -417,6 +459,9 @@ export default function NewBookingPage() {
// engine assigns the train, so no trainScheduleId is sent.
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
// Reefer is a customer choice for bulk only; container reefer is decided by
// the container type on the backend, so never send it for containers.
isReefer: data.cargoType === "bulk" ? data.isRefrigerated : false,
freightType:
data.cargoType === "container"
? ("CONTAINER" as const)
@@ -459,34 +504,23 @@ export default function NewBookingPage() {
}
: { customsClearingEnabled: false }),
...(cargoFreeText ? { cargoFreeText } : {}),
// Multi-route general contracts: route #1 is the primary origin/destination
// carrying the full contracted quantity; each extra route reserves its own.
// Multi-route general contracts: routes are pure origindestination lanes
// the contract covers — they carry NO quantity. Route #1 is the primary
// origin/destination; the rest come from the extra-routes step. The
// contracted quantity lives in a single shared pool (the container
// quantities / bulk total), drawn down per order against a chosen lane.
...(isContract
? {
routes: [
{
originYardId: data.originYard,
destinationYardId: data.destinationYard,
quantity:
data.cargoType === "container"
? data.containers.reduce(
(sum, c) => sum + Number(c.qty || 0),
0,
)
: totalWeight,
},
...(data.extraRoutes ?? [])
.filter(
(r) =>
r.originYard &&
r.destinationYard &&
Number(r.quantity) > 0,
)
.filter((r) => r.originYard && r.destinationYard)
.map((r) => ({
originYardId: r.originYard,
destinationYardId: r.destinationYard,
quantity: Number(r.quantity),
...(r.km && Number(r.km) > 0 ? { km: Number(r.km) } : {}),
})),
],
}
@@ -633,10 +667,7 @@ export default function NewBookingPage() {
isLoading={refDataLoading}
/>
)}
{step === 5 && (
<StepScheduling form={form} referenceData={referenceData} />
)}
{step === 6 && <StepDocuments documents={onboardingDocs} />}
{step === 6 && <StepDocuments form={form} />}
{step === 7 && (
<Step8Review
form={form}
@@ -728,9 +759,65 @@ export default function NewBookingPage() {
<Stack gap="md">
<Text size="sm" c="dimmed">
{priceModalMode === "submit"
? "Review your total price below. Confirm to submit for EDR staff review, or reject to discard this booking."
? "Review your total price below. Confirm to submit for EDR staff review, edit & regenerate to change details and re-price, or reject to discard this booking."
: "Your booking has been saved as a draft. Here is your estimated total price."}
</Text>
{pricingData.lineItems.length > 0 && (
<Box
p="md"
style={{
borderRadius: 16,
border: "1px solid var(--mantine-color-edr-border-0)",
background: "#fff",
}}
>
<Text
size="xs"
fw={700}
tt="uppercase"
c="edr-muted"
mb="xs"
style={{ letterSpacing: "0.06em" }}
>
Price breakdown
</Text>
<Stack gap={10}>
{pricingData.lineItems.map((item) => {
const hasUnit =
item.unitAmount != null &&
item.quantity != null &&
item.quantity > 0;
return (
<Group
key={item.code}
justify="space-between"
align="flex-start"
wrap="nowrap"
gap="sm"
>
<Box style={{ minWidth: 0 }}>
<Text size="sm" c="#10202F" fw={500}>
{item.description}
</Text>
{hasUnit && (
<Text size="xs" c="dimmed">
{item.quantity!.toLocaleString()} ×{" "}
{item.unitAmount!.toLocaleString()} {item.currency}
{item.unit
? ` · ${formatPriceUnit(item.unit)}`
: ""}
</Text>
)}
</Box>
<Text size="sm" fw={600} c="#10202F" style={{ whiteSpace: "nowrap" }}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
);
})}
</Stack>
</Box>
)}
<Box
p="lg"
style={{
@@ -769,6 +856,21 @@ export default function NewBookingPage() {
>
Reject
</Button>
{/* Go back to the wizard to change details, then Submit again to
regenerate the price. The draft booking is kept (priceBookingId
stays set), so re-submitting updates it instead of creating a
new one. */}
<Button
variant="default"
radius="md"
leftSection={<ChevronLeft size={16} />}
onClick={() => setPriceModalMode(null)}
disabled={
rejectMutation.isPending || confirmMutation.isPending
}
>
Edit &amp; regenerate
</Button>
<Button
color="edr-green"
radius="md"
@@ -777,7 +879,7 @@ export default function NewBookingPage() {
loading={confirmMutation.isPending}
disabled={rejectMutation.isPending}
>
Confirm & submit
Confirm &amp; submit
</Button>
</>
) : (
@@ -821,6 +923,69 @@ export default function NewBookingPage() {
{priceChangeResult.currency}
</Text>
</Group>
{priceChangeResult.lineItems &&
priceChangeResult.lineItems.length > 0 && (
<Box
p="md"
style={{
borderRadius: 16,
border: "1px solid var(--mantine-color-edr-border-0)",
background: "#fff",
}}
>
<Text
size="xs"
fw={700}
tt="uppercase"
c="edr-muted"
mb="xs"
style={{ letterSpacing: "0.06em" }}
>
Price breakdown
</Text>
<Stack gap={10}>
{priceChangeResult.lineItems.map((item) => {
const hasUnit =
item.unitAmount != null &&
item.quantity != null &&
item.quantity > 0;
return (
<Group
key={item.code}
justify="space-between"
align="flex-start"
wrap="nowrap"
gap="sm"
>
<Box style={{ minWidth: 0 }}>
<Text size="sm" c="#10202F" fw={500}>
{item.description}
</Text>
{hasUnit && (
<Text size="xs" c="dimmed">
{item.quantity!.toLocaleString()} ×{" "}
{item.unitAmount!.toLocaleString()}{" "}
{item.currency}
{item.unit
? ` · ${formatPriceUnit(item.unit)}`
: ""}
</Text>
)}
</Box>
<Text
size="sm"
fw={600}
c="#10202F"
style={{ whiteSpace: "nowrap" }}
>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
);
})}
</Stack>
</Box>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="default"

View File

@@ -0,0 +1,85 @@
import { Box, Button } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { AlertCircle, ArrowRight, PencilLine, Upload } from "lucide-react";
import type { Freight } from "@edr/types";
import { ResubmitBookingModal } from "@/pages/bookings/resubmit/ResubmitBookingModal";
import { BookingActionModal } from "./BookingActionModal";
import {
type BookingActionKind,
getBookingNextAction,
} from "./bookingNextAction";
const ICON_BY_KIND: Record<
BookingActionKind,
typeof Upload
> = {
UPLOAD_DOCUMENTS: Upload,
FIX_DOCUMENTS: AlertCircle,
SCHEDULE_OPERATION: ArrowRight,
};
interface BookingActionButtonProps {
booking: Freight.IBooking;
size?: "xs" | "sm";
}
/**
* Self-contained next-action trigger for a My Shipments row. Renders nothing
* when the booking has no customer-actionable clearance/operation step;
* otherwise shows a button that opens the in-place {@link BookingActionModal}.
*
* Drop it into a list row exactly like {@link PayNowButton} — it stops click
* propagation so it never triggers the row's navigation handler.
*/
export function BookingActionButton({
booking,
size = "sm",
}: BookingActionButtonProps) {
const [opened, { open, close }] = useDisclosure(false);
// Staff returned the booking for changes — let the customer update the docs
// they submitted and resubmit, in place.
const isChangesRequested = booking.status === "CHANGES_REQUESTED";
const action = isChangesRequested ? null : getBookingNextAction(booking);
if (!isChangesRequested && !action) return null;
const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine;
const label = action ? action.label : "Update & resubmit";
return (
// Mantine modals portal to <body>, but React events still bubble through
// the React tree to this button's ancestors — including the clickable list
// row. Stop click propagation here so interacting with the modal never
// triggers the row's navigate-to-detail handler.
<Box component="span" onClick={(e) => e.stopPropagation()}>
<Button
size={size}
radius="md"
fw={700}
fz={13}
color="edr-green"
leftSection={<Icon size={14} />}
onClick={(e) => {
e.stopPropagation();
open();
}}
>
{label}
</Button>
{isChangesRequested ? (
<ResubmitBookingModal
booking={booking}
opened={opened}
onClose={close}
/>
) : (
<BookingActionModal booking={booking} opened={opened} onClose={close} />
)}
</Box>
);
}

View File

@@ -0,0 +1,112 @@
import { Box, Button, Group, Modal, Text } from "@mantine/core";
import { CheckCircle2, Upload } from "lucide-react";
import type { Freight } from "@edr/types";
import { ClearanceFlow } from "./ClearanceFlow";
import { getBookingNextAction } from "./bookingNextAction";
import { useClearanceFlow } from "./useClearanceFlow";
interface BookingActionModalProps {
booking: Freight.IBooking;
opened: boolean;
onClose: () => void;
}
/**
* Home-page action modal: runs the full clearance / operation flow for a single
* booking without leaving the My Shipments list. The customer can upload the
* required documents, re-upload queried ones, then pick a shipment day and
* proceed to operation — all in place.
*
* Mounted only while `opened` so the clearance grid is fetched lazily and the
* staged-upload state resets every time the customer reopens it.
*/
export function BookingActionModal({
booking,
opened,
onClose,
}: BookingActionModalProps) {
if (!opened) return null;
return <BookingActionModalBody booking={booking} onClose={onClose} />;
}
function BookingActionModalBody({
booking,
onClose,
}: {
booking: Freight.IBooking;
onClose: () => void;
}) {
const action = getBookingNextAction(booking);
const flow = useClearanceFlow(booking);
const reference = booking.reference;
const handleSubmit = () => flow.submitDocuments();
const handleProceed = () => flow.proceedToOperation({ onSuccess: onClose });
return (
<Modal
opened
onClose={onClose}
centered
size={560}
radius={16}
padding={24}
title={
<Box>
<Text fz={16} fw={800} c="#10202F">
{action?.title ?? "Booking"}
</Text>
<Text fz={12} c="dimmed" ff="monospace">
{reference}
</Text>
</Box>
}
overlayProps={{ backgroundOpacity: 0.5, blur: 4 }}
styles={{ body: { paddingTop: 8 } }}
>
{flow.isLoading || !flow.clearance ? (
<Text fz="13px" c="dimmed" py="md">
Loading clearance
</Text>
) : (
<ClearanceFlow
booking={booking}
flow={flow}
footer={
<Group justify="flex-end" mt="xl" gap="sm">
<Button variant="default" radius="md" onClick={onClose}>
Close
</Button>
{flow.canUpload && (
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
onClick={handleSubmit}
loading={flow.uploadMutation.isPending}
disabled={!flow.canSubmit}
>
Submit documents
</Button>
)}
{flow.isReady && (
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
onClick={handleProceed}
loading={flow.proceedMutation.isPending}
disabled={!flow.scheduledDate}
>
Proceed to operation
</Button>
)}
</Group>
}
/>
)}
</Modal>
);
}

View File

@@ -0,0 +1,315 @@
import {
Alert,
Box,
Button,
FileButton,
Group,
Stack,
Text,
TextInput,
} from "@mantine/core";
import {
AlertCircle,
CheckCircle2,
Clock,
Download,
FileText,
Plus,
Upload,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { IconSquare } from "../BookingDetailPage/components/Documents";
import { OperationDatePicker } from "./OperationDatePicker";
import type { ClearanceFlowController } from "./useClearanceFlow";
const GREEN = "#0A6F4D";
function StatusPill({ doc }: { doc: Freight.ClearanceDocument }) {
if (doc.reviewStatus === "APPROVED") {
return (
<Group gap={6} c={GREEN}>
<CheckCircle2 size={15} />
<Text fz="12px" fw={600} c={GREEN}>
Approved
</Text>
</Group>
);
}
if (doc.reviewStatus === "QUERIED") {
return (
<Group gap={6} c="#C0392B">
<AlertCircle size={15} />
<Text fz="12px" fw={600} c="#C0392B">
Queried
</Text>
</Group>
);
}
if (doc.file) {
return (
<Group gap={6} c="#2E5B96">
<Clock size={15} />
<Text fz="12px" fw={600} c="#2E5B96">
Pending review
</Text>
</Group>
);
}
return (
<Text fz="12px" fw={600} c="#9AA8B5">
Not uploaded
</Text>
);
}
interface ClearanceFlowProps {
booking: Freight.IBooking;
flow: ClearanceFlowController;
/**
* Rendered at the bottom of the flow (the submit / proceed buttons). Host
* supplies this so the detail card and the modal can place actions in their
* own footer chrome.
*/
footer?: React.ReactNode;
}
/**
* Presentational body of the customer clearance/operation flow: the required
* document grid (with re-upload of pending/queried docs), GL output documents,
* ad-hoc documents, and the shipment-day picker once CLEARANCE_READY.
*
* All state lives in the `flow` controller (see `useClearanceFlow`) so this can
* be dropped into either the booking detail card or the home-page action modal.
*/
export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
const {
clearance,
customerDocs,
glDocs,
isReady,
canUpload,
isInitialUpload,
status,
pending,
adHoc,
missingRequired,
stagePending,
addAdHocRow,
setAdHocName,
setAdHocFile,
scheduledDate,
setScheduledDate,
uploadMutation,
proceedMutation,
} = flow;
if (!clearance) return null;
return (
<Stack gap={0}>
{isReady ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
{clearance.includesCustoms
? "Customs clearance is complete and your cleared documents are available below. You can now proceed to operation."
: "Clearance is ready. You can now proceed to operation."}
</Alert>
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
{clearance.includesCustoms
? "Global Logistics is reviewing your documents and will clear your shipment. Only re-upload the documents flagged with a query below — approved documents stay as they are."
: "Our team is reviewing your documents. Only re-upload the documents flagged with a query below — approved documents stay as they are."}
</Alert>
) : (
<Alert color="yellow" radius="md" icon={<AlertCircle size={18} />} mb="md">
{clearance.includesCustoms
? "Upload every required document customs needs (marked *) to start the review. Global Logistics will clear your shipment and return the cleared documents here."
: "Upload every required clearance document (marked *) below to start the review."}
</Alert>
)}
{isInitialUpload && missingRequired.length > 0 && (
<Alert color="yellow" variant="light" radius="md" mb="md" p="xs">
<Text fz="12px" c="#9A5B00">
Still required:{" "}
{missingRequired.map((d) => d.label).join(", ")}
</Text>
</Alert>
)}
<Stack gap={10}>
{customerDocs.map((doc) => (
<Box
key={doc.fileKey}
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 12 }}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<Box c="#2E5B96">
<FileText size={18} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz="13.5px" fw={600} c="#10202F" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
{doc.file && (
<Text fz="12px" c="dimmed" truncate>
{doc.file.name}
</Text>
)}
</Box>
</Group>
<Group gap={10} wrap="nowrap">
<StatusPill doc={doc} />
{doc.file && (
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
)}
{canUpload && doc.reviewStatus !== "APPROVED" && (
<FileButton
onChange={(f) => f && stagePending(doc.fileKey, f)}
accept="application/pdf,image/*"
>
{(props) => (
<Button
{...props}
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Upload size={13} />}
>
{pending[doc.fileKey] ? "Selected" : "Upload"}
</Button>
)}
</FileButton>
)}
</Group>
</Group>
{doc.reviewStatus === "QUERIED" && doc.note && (
<Text fz="12px" c="#C0392B" mt={6}>
Query: {doc.note}
</Text>
)}
{pending[doc.fileKey] && (
<Text fz="12px" c={GREEN} mt={6}>
Ready to upload: {pending[doc.fileKey].name}
</Text>
)}
</Box>
))}
</Stack>
{/* GL output documents (read-only to the customer). */}
{glDocs.length > 0 && (
<>
<Text fz="12.5px" fw={700} c="#10202F" mt="lg" mb={8}>
Customs output documents
</Text>
<Stack gap={8}>
{glDocs.map((doc) => (
<Group
key={doc.fileKey}
justify="space-between"
wrap="nowrap"
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 10 }}
>
<Text fz="13px" c="#10202F" truncate>
{doc.label}
</Text>
{doc.file ? (
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
) : (
<Text fz="12px" c="#9AA8B5">
Pending
</Text>
)}
</Group>
))}
</Stack>
</>
)}
{/* Ad-hoc / additional documents. */}
{canUpload && (
<Box mt="lg">
<Group justify="space-between" align="center" mb={8}>
<Text fz="12.5px" fw={700} c="#10202F">
Additional documents
</Text>
<Button
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Plus size={13} />}
onClick={addAdHocRow}
>
Add document
</Button>
</Group>
<Stack gap={8}>
{adHoc.map((row, i) => (
<Group key={i} gap={8} wrap="nowrap">
<TextInput
placeholder="Document name"
value={row.name}
onChange={(e) => setAdHocName(i, e.currentTarget.value)}
style={{ flex: 1 }}
radius="md"
/>
<FileButton
onChange={(f) => setAdHocFile(i, f)}
accept="application/pdf,image/*"
>
{(props) => (
<Button {...props} variant="default" radius="md">
{row.file ? row.file.name.slice(0, 14) : "Choose file"}
</Button>
)}
</FileButton>
</Group>
))}
</Stack>
</Box>
)}
{uploadMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
{uploadMutation.error instanceof Error
? uploadMutation.error.message
: "Upload failed. Please try again."}
</Alert>
)}
{isReady && (
<Box mt="lg">
<Text fz="13px" fw={700} c="#10202F" mb={6}>
Choose your shipment day
</Text>
<Text fz="12px" c="dimmed" mb="sm">
Only days with a scheduled departure on your route can be selected.
The operations team assigns the specific train for that day.
</Text>
<OperationDatePicker
originYardId={booking.originYard?.id}
destinationYardId={booking.destinationYard?.id}
value={scheduledDate}
onChange={setScheduledDate}
/>
</Box>
)}
{proceedMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
{proceedMutation.error instanceof Error
? proceedMutation.error.message
: "Could not request the operation. Please try again."}
</Alert>
)}
{footer}
</Stack>
);
}

View File

@@ -0,0 +1,219 @@
import { Box, Button, Group, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
addMonths,
eachDayOfInterval,
endOfMonth,
endOfWeek,
format,
isSameMonth,
isToday,
startOfMonth,
startOfWeek,
} from "date-fns";
import {
Calendar as CalendarIcon,
Check,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { useMemo, useState } from "react";
import { api } from "@/services/api";
interface OperationDatePickerProps {
originYardId?: string;
destinationYardId?: string;
value: string;
onChange: (date: string) => void;
}
/**
* Compact month calendar for picking the binding shipment day at the
* operation-request step. Only days that have an OPEN scheduled departure on the
* booking route are selectable; all other days are disabled.
*
* Shared by the booking detail clearance card and the home-page action modal.
*/
export function OperationDatePicker({
originYardId,
destinationYardId,
value,
onChange,
}: OperationDatePickerProps) {
const [month, setMonth] = useState(() => startOfMonth(new Date()));
const { data: availableDays, isLoading } = useQuery(
api.bookings.getAvailableDays.queryOptions({
input: { originYardId, destinationYardId },
enabled: !!originYardId && !!destinationYardId,
}),
);
const departureDays = useMemo(
() => new Set(availableDays ?? []),
[availableDays],
);
const cells = useMemo(() => {
const start = startOfWeek(startOfMonth(month), { weekStartsOn: 1 });
const end = endOfWeek(endOfMonth(month), { weekStartsOn: 1 });
return eachDayOfInterval({ start, end }).map((date) => {
const dateString = format(date, "yyyy-MM-dd");
return {
date,
dateString,
day: date.getDate(),
inMonth: isSameMonth(date, month),
today: isToday(date),
selected: value === dateString,
hasDeparture: departureDays.has(dateString),
};
});
}, [month, departureDays, value]);
return (
<Box
style={{
border: "1px solid #E6ECF2",
borderRadius: 12,
padding: 14,
maxWidth: 340,
}}
>
<Group justify="space-between" align="center" mb="sm">
<Button
variant="default"
size="xs"
px={6}
radius="xl"
onClick={() => setMonth((m) => addMonths(m, -1))}
>
<ChevronLeft size={15} />
</Button>
<Text fz="13px" fw={700} c="#10202F">
{format(month, "MMMM yyyy")}
</Text>
<Button
variant="default"
size="xs"
px={6}
radius="xl"
onClick={() => setMonth((m) => addMonths(m, 1))}
>
<ChevronRight size={15} />
</Button>
</Group>
{isLoading ? (
<Group justify="center" py="md" gap={8}>
<CalendarIcon size={15} color="#9AA8B5" />
<Text fz="12px" c="dimmed">
Loading available days
</Text>
</Group>
) : (
<>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 4,
marginBottom: 6,
}}
>
{["M", "T", "W", "T", "F", "S", "S"].map((d, i) => (
<Text key={i} ta="center" fz="10px" fw={700} c="#9AA8B5">
{d}
</Text>
))}
</Box>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 4,
}}
>
{cells.map((c) => {
const clickable = c.hasDeparture && c.inMonth;
return (
<button
key={c.dateString}
type="button"
disabled={!clickable}
onClick={() => clickable && onChange(c.dateString)}
style={{
position: "relative",
height: 34,
borderRadius: 8,
fontSize: 12.5,
fontWeight: c.selected ? 800 : 600,
cursor: clickable ? "pointer" : "default",
border: c.selected
? "1.5px solid #12B981"
: clickable
? "1px solid #CDEBDD"
: "1px solid transparent",
background: c.selected
? "#12B981"
: clickable
? "#F4FBF7"
: "transparent",
color: c.selected
? "#fff"
: !c.inMonth
? "#CBD5E1"
: clickable
? "#0A6F4D"
: "#C4CDD6",
transition: "all 120ms ease",
}}
>
{c.day}
{c.hasDeparture && c.inMonth && !c.selected && (
<span
style={{
position: "absolute",
bottom: 4,
left: "50%",
transform: "translateX(-50%)",
width: 4,
height: 4,
borderRadius: "50%",
background: "#12B981",
}}
/>
)}
{c.selected && (
<Check
size={11}
color="#fff"
strokeWidth={3}
style={{
position: "absolute",
bottom: 3,
left: "50%",
transform: "translateX(-50%)",
}}
/>
)}
</button>
);
})}
</Box>
{value && (
<Text fz="12px" c="#0A6F4D" fw={600} mt="sm">
Selected: {format(new Date(value + "T00:00:00"), "EEE, MMM d yyyy")}
</Text>
)}
{!isLoading && departureDays.size === 0 && (
<Text fz="12px" c="orange.7" mt="sm">
No scheduled departures found for this route yet.
</Text>
)}
</>
)}
</Box>
);
}

View File

@@ -0,0 +1,68 @@
import type { Freight } from "@edr/types";
/**
* The customer-actionable clearance/operation steps a booking can be sitting on.
* These are the statuses where the *customer* must do something next — upload
* documents, re-upload a queried document, or pick a shipment day and proceed
* to operation.
*/
export type BookingActionKind =
| "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs
| "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them
| "SCHEDULE_OPERATION"; // CLEARANCE_READY — pick a day and proceed to operation
export interface BookingNextAction {
kind: BookingActionKind;
/** Button label shown on the My Shipments row. */
label: string;
/** Modal title. */
title: string;
}
const ACTION_BY_STATUS: Record<string, BookingNextAction> = {
AWAITING_DOCUMENTS: {
kind: "UPLOAD_DOCUMENTS",
label: "Upload documents",
title: "Upload clearance documents",
},
DOCUMENTS_UNDER_REVIEW: {
kind: "FIX_DOCUMENTS",
label: "Review documents",
title: "Clearance documents",
},
CLEARANCE_READY: {
kind: "SCHEDULE_OPERATION",
label: "Schedule & proceed",
title: "Schedule your shipment",
},
};
/**
* Resolve the customer's next clearance/operation action for a booking, or
* `null` when there's nothing for them to do at this stage. Pure + cheap so it
* can be called inline while rendering a list row.
*
* Note: `DOCUMENTS_UNDER_REVIEW` always surfaces an action because the customer
* may need to re-upload a queried document; the modal itself shows a read-only
* "under review" state when nothing is actually queried.
*/
export function getBookingNextAction(
booking: Pick<Freight.IBooking, "status">,
): BookingNextAction | null {
return ACTION_BY_STATUS[booking.status as string] ?? null;
}
/**
* Whether a booking has an in-place action the customer can take from a list
* row via a modal — either a clearance/operation step, or a CHANGES_REQUESTED
* booking that needs documents updated and resubmitting. Used to decide whether
* to render {@link BookingActionButton}.
*/
export function bookingHasInlineAction(
booking: Pick<Freight.IBooking, "status">,
): boolean {
return (
booking.status === "CHANGES_REQUESTED" ||
getBookingNextAction(booking) !== null
);
}

View File

@@ -0,0 +1,14 @@
export { BookingActionButton } from "./BookingActionButton";
export { BookingActionModal } from "./BookingActionModal";
export { ClearanceFlow } from "./ClearanceFlow";
export { OperationDatePicker } from "./OperationDatePicker";
export {
getBookingNextAction,
type BookingActionKind,
type BookingNextAction,
} from "./bookingNextAction";
export {
useClearanceFlow,
type AdHocDoc,
type ClearanceFlowController,
} from "./useClearanceFlow";

View File

@@ -0,0 +1,164 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { api } from "@/services/api";
import type { Freight } from "@edr/types";
export type AdHocDoc = { name: string; file: File | null };
/**
* Encapsulates everything the customer-facing clearance/operation flow needs:
* the clearance grid query, the staged uploads (keyed pending + ad-hoc docs),
* the chosen shipment day, and the submit / proceed mutations.
*
* Both the booking detail clearance card and the home-page action modal drive
* their UI off this single hook so the behaviour stays in lock-step.
*/
export function useClearanceFlow(booking: Freight.IBooking) {
const queryClient = useQueryClient();
const status = booking.status as string;
const clearanceQuery = useQuery(
api.bookings.getClearance.queryOptions({ input: { id: booking.id } }),
);
const clearance = clearanceQuery.data;
// Pending uploads keyed by fileKey, plus ad-hoc rows (label + file).
const [pending, setPending] = useState<Record<string, File>>({});
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
// Binding shipment day chosen for the operation request (yyyy-MM-dd).
const [scheduledDate, setScheduledDate] = useState<string>("");
const refresh = () => {
queryClient.invalidateQueries({
queryKey: api.bookings.getClearance.queryKey({ id: booking.id }),
});
queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: booking.id }),
});
queryClient.invalidateQueries({
queryKey: api.bookings.list.queryKey(),
});
};
const uploadMutation = useMutation({
...api.bookings.submitClearanceDocuments.mutationOptions(),
onSuccess: () => {
setPending({});
setAdHoc([]);
refresh();
},
});
const proceedMutation = useMutation({
...api.bookings.proceedToOperation.mutationOptions(),
onSuccess: () => refresh(),
});
// Only the customer-input documents are uploadable here; GL output docs are
// shown read-only.
const customerDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
[clearance],
);
const glDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"),
[clearance],
);
const isReady = status === "CLEARANCE_READY";
const canUpload =
status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
// The very first upload (nothing in review yet). Here every required document
// must be provided. Once GL has started reviewing (DOCUMENTS_UNDER_REVIEW) the
// customer is only re-uploading queried/pending docs, so we don't re-gate on
// the full required set.
const isInitialUpload = status === "AWAITING_DOCUMENTS";
const hasStagedFiles =
Object.keys(pending).length > 0 || adHoc.some((r) => r.file);
// A required customer document is satisfied when it already has an uploaded
// file or the customer has just staged one for this submission.
const missingRequired = useMemo(
() =>
customerDocs.filter(
(d) => d.required && !d.file && !pending[d.fileKey],
),
[customerDocs, pending],
);
// Initial upload: block submit until every required field has a file (and at
// least one file is actually staged to send). Re-upload rounds only need at
// least one staged file — the customer fixes the specific queried documents.
const canSubmit = isInitialUpload
? hasStagedFiles && missingRequired.length === 0
: hasStagedFiles;
// --- staged-upload mutators ----------------------------------------------
const stagePending = (fileKey: string, file: File) =>
setPending((p) => ({ ...p, [fileKey]: file }));
const addAdHocRow = () => setAdHoc((r) => [...r, { name: "", file: null }]);
const setAdHocName = (index: number, name: string) =>
setAdHoc((rows) =>
rows.map((r, j) => (j === index ? { ...r, name } : r)),
);
const setAdHocFile = (index: number, file: File | null) =>
setAdHoc((rows) =>
rows.map((r, j) => (j === index ? { ...r, file } : r)),
);
// --- actions --------------------------------------------------------------
const submitDocuments = (opts?: { onSuccess?: () => void }) => {
const files: Record<string, File | null> = { ...pending };
adHoc.forEach((row, i) => {
if (row.file) files[`custom_${Date.now()}_${i}`] = row.file;
});
if (Object.keys(files).length === 0) return;
uploadMutation.mutate({ id: booking.id, files }, { onSuccess: opts?.onSuccess });
};
const proceedToOperation = (opts?: { onSuccess?: () => void }) => {
if (!scheduledDate) return;
proceedMutation.mutate(
{ id: booking.id, scheduledDate },
{ onSuccess: opts?.onSuccess },
);
};
return {
status,
clearance,
isLoading: clearanceQuery.isLoading,
customerDocs,
glDocs,
isReady,
canUpload,
isInitialUpload,
// staged upload state
pending,
adHoc,
hasStagedFiles,
missingRequired,
canSubmit,
stagePending,
addAdHocRow,
setAdHocName,
setAdHocFile,
// schedule
scheduledDate,
setScheduledDate,
// mutations
uploadMutation,
proceedMutation,
submitDocuments,
proceedToOperation,
};
}
export type ClearanceFlowController = ReturnType<typeof useClearanceFlow>;

View File

@@ -0,0 +1,59 @@
import { Button } from "@mantine/core";
import { FileSignature } from "lucide-react";
import { useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types";
/**
* Statuses where the contract is ready for the customer to review and sign.
* These are the only states where {@link ContractSignButton} renders — once the
* customer has signed, the row falls back to its normal "View" action.
*/
const SIGNABLE_STATUSES = ["CONTRACT_READY", "APPROVED_PENDING_SIGNATURE"];
export function bookingIsSignable(
booking: Pick<Freight.IBooking, "status">,
): boolean {
return SIGNABLE_STATUSES.includes(booking.status as string);
}
interface ContractSignButtonProps {
booking: Freight.IBooking;
size?: "xs" | "sm";
}
/**
* Self-contained "View & Sign" trigger for a My Shipments row. Renders nothing
* unless the booking's contract is ready to be signed; otherwise shows a button
* that navigates to the full-page contract viewer ({@link BookingContractPage})
* where the signature flow lives.
*
* Drop it into a list row exactly like {@link PayNowButton} — it stops click
* propagation so it never triggers the row's navigation handler.
*/
export function ContractSignButton({
booking,
size = "sm",
}: ContractSignButtonProps) {
const navigate = useNavigate();
if (!bookingIsSignable(booking)) return null;
return (
<Button
size={size}
radius="md"
fw={700}
fz={13}
color="edr-green"
leftSection={<FileSignature size={14} />}
onClick={(e) => {
// Don't let the surrounding row-click handler fire.
e.stopPropagation();
navigate(`/bookings/${booking.id}/contract`);
}}
>
View &amp; sign
</Button>
);
}

View File

@@ -57,7 +57,13 @@ export function StepIndicator({
}),
}}
>
{done ? <Check style={{ width: 15, height: 15 }} strokeWidth={3} /> : item.id}
{done ? (
<Check style={{ width: 15, height: 15 }} strokeWidth={3} />
) : (
// Display the 1-based position, not the raw step id — ids can
// be non-contiguous (e.g. the schedule step was removed).
index + 1
)}
</div>
<span
style={{

View File

@@ -8,7 +8,6 @@ export const STEPS = [
{ id: 2, label: "Service Type & Mile", short: "Service" },
{ id: 3, label: "Cargo Details", short: "Cargo" },
{ id: 4, label: "Route", short: "Route" },
{ id: 5, label: "Estimated Date", short: "Schedule" },
{ id: 6, label: "Documents", short: "Documents" },
{ id: 7, label: "Review & Submit", short: "Submit" },
] as const;
@@ -141,22 +140,20 @@ export const bookingFormSchema = z
customsClearingAgent: z.string().default(""),
originYard: z.string().min(1, "Select an origin yard."),
destinationYard: z.string().min(1, "Select a destination yard."),
// Quantity reserved on the PRIMARY route of a GENERAL contract, in the unit
// of the selected commodity (items vs tons). Customers enter it explicitly in
// the route step so the primary route reads consistently with the extra
// routes below. Ignored for one-time bookings; for containers the value is
// derived from the container count instead (see buildApiPayload).
// Retained for payload/back-compat only — no longer collected in the UI.
// The contracted quantity now comes from the cargo step (cargoWeight), the
// same as a one-time booking, so per-route quantity is no longer entered.
primaryRouteQuantity: z.string().default(""),
// Additional routes for a GENERAL contract (the primary origin/destination
// above is route #1). Each adds another (origin, destination, quantity) pool.
// Ignored for one-time bookings.
// above is route #1). Each route is just an (origin, destination) pair —
// identical to the one-time route — so a contract can cover several routes.
// Ignored for one-time bookings. quantity/km kept for payload back-compat.
extraRoutes: z
.array(
z.object({
originYard: z.string(),
destinationYard: z.string(),
quantity: z.string(),
// Road distance for this route; used to bill road (truck) orders.
quantity: z.string().default(""),
km: z.string().default(""),
}),
)
@@ -222,11 +219,10 @@ export const bookingFormSchema = z
)
.refine(
(data) => {
// General contracts capture bulk quantity per route (primaryRouteQuantity),
// not via the cargo-step cargoWeight — so only validate it for one-time
// bulk bookings.
// Both one-time and general contracts capture the bulk amount in the cargo
// step (cargoWeight). General contracts no longer collect a per-route
// quantity, so the cargo amount is the single source for the contract total.
if (data.cargoType !== "bulk") return true;
if (data.bookingType === "general_contract") return true;
const quantity = Number(data.cargoWeight);
return !!data.cargoWeight && !Number.isNaN(quantity) && quantity > 0;
},
@@ -245,14 +241,10 @@ export const bookingFormSchema = z
message: "Select a shipment date.",
});
}
// Customs clearing agent is required once the customs service is enabled.
if (data.customsClearingEnabled && !data.customsClearingAgent.trim()) {
ctx.addIssue({
code: "custom",
path: ["customsClearingAgent"],
message: "Enter the customs clearing agent.",
});
}
// The customs clearing agent is only the customer's own broker, named when
// the service does NOT bundle customs (EDR/GL handles it otherwise). It is
// never required: when the service includes customs the agent is left blank
// on purpose, so requiring it would silently block submission.
if (data.cargoType === "bulk") {
if (!data.cargoTypePath[0]) {
ctx.addIssue({
@@ -262,19 +254,6 @@ export const bookingFormSchema = z
});
}
}
// General contracts reserve quantity per route. The primary route's quantity
// is entered in the route step; containers derive it from the container
// count, so only bulk cargo requires it here.
if (data.bookingType === "general_contract" && data.cargoType === "bulk") {
const qty = Number(data.primaryRouteQuantity);
if (!data.primaryRouteQuantity || Number.isNaN(qty) || qty <= 0) {
ctx.addIssue({
code: "custom",
path: ["primaryRouteQuantity"],
message: "Enter a quantity greater than 0.",
});
}
}
if (data.cargoType === "container") {
data.containers.forEach((c, i) => {
if (!c.qty || +c.qty < 1) {
@@ -349,8 +328,9 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
"extraRoutes",
"isHazardous",
"isRefrigerated",
// Estimated shipment date now lives in the Route step (one-time bookings only).
"scheduledDate",
],
5: ["scheduledDate"],
6: ["documents"],
7: ["notes"],
};

View File

@@ -296,7 +296,11 @@ export function SelectField({
placeholder={placeholder}
disabled={disabled}
data={data}
value={String(field.value) || null}
value={
field.value === undefined || field.value === null || field.value === ""
? null
: String(field.value)
}
onChange={(v) => field.onChange(v ?? "")}
onBlur={field.onBlur}
error={error?.message}

View File

@@ -1,16 +1,32 @@
import { Box, Group, Stack, Text } from "@mantine/core";
import { Box, Group, Loader, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, FileText, FileUp } from "lucide-react";
import { SmartFileInput } from "@edr/ui-common";
import { type UseFormReturn } from "react-hook-form";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import {
type BookingDocuments,
type BookingFormInputValues,
type BookingFormValues,
} from "./schema";
import { StepCard, StepHeader } from "./shared";
export interface OnboardingDoc {
name: string;
url: string;
size: number;
mimeType?: string;
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
/** Onboarding document setting code for the company's nationality. */
function documentSettingCode(nationality: string | null | undefined): string {
return nationality === "foreign"
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
function formatSize(bytes: number): string {
function formatSize(bytes?: number): string {
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
@@ -18,57 +34,58 @@ function formatSize(bytes: number): string {
}
/**
* Read-only documents step: lists the documents the company uploaded during
* onboarding for the active operational profile. These are attached to the
* booking automatically at submission — the customer is never asked to re-upload.
* Editable documents step. Mirrors the company onboarding documents (TIN,
* passport, investment/commercial license, national ID, …) and lets the customer
* attach or replace them FOR THIS BOOKING. Selections are stored on the form's
* `documents` field and saved against the specific booking on submit — editing
* here never touches the company profile.
*
* The documents already on file from onboarding are shown as a reference so the
* customer can see what EDR already has; they only need to upload here if they
* want to override a document for this booking.
*/
export function StepDocuments({ documents }: { documents: OnboardingDoc[] }) {
const total = documents.length;
export function StepDocuments({ form }: { form: BookingForm }) {
const auth = useAuth();
const nationality = auth.company?.company?.nationality as
| string
| null
| undefined;
const docSettingQuery = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode(nationality) },
}),
);
// Documents already on file from onboarding (read-only reference).
const onboardingDocs = (() => {
const profiles = auth.company?.company?.companyProfiles ?? [];
const active =
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0];
return active?.licenseFiles ?? [];
})();
const documents = (form.watch("documents") ?? {}) as BookingDocuments;
const setDocuments = (next: Record<string, File | File[] | null>) => {
form.setValue("documents", next, { shouldDirty: true });
};
return (
<StepCard>
<StepHeader
icon={<FileUp size={22} />}
title="Documents"
description="The documents from your onboarding will be attached to this booking automatically. No re-upload is needed."
description="Attach the documents for this booking. They default to what you uploaded during onboarding — upload here only to override a document for this specific booking."
/>
<Group
gap={10}
align="center"
wrap="nowrap"
className="rounded-xl"
style={{
border: "1px solid var(--mantine-color-edr-border-0)",
backgroundColor: "var(--mantine-color-gray-0)",
padding: "12px 16px",
}}
>
<Box
style={{
flexShrink: 0,
width: 32,
height: 32,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 999,
backgroundColor: total > 0 ? "#ECF6F1" : "#FBECEC",
color: total > 0 ? "#0A6F4D" : "#B42318",
}}
>
{total > 0 ? <CheckCircle2 size={16} /> : <FileUp size={16} />}
</Box>
<Text size="sm" c="dimmed">
{total > 0
? `${total} onboarding ${total === 1 ? "document" : "documents"} will be attached to this booking.`
: "No onboarding documents found on your active profile. You can add documents later from the booking page."}
</Text>
</Group>
{total > 0 && (
<Stack gap={10} mt={4}>
{documents.map((doc, i) => (
{onboardingDocs.length > 0 && (
<Stack gap={10} mb="lg">
<Text fz={13} fw={700} c="#10202F">
On file from your onboarding
</Text>
{onboardingDocs.map((doc, i) => (
<Group
key={`${doc.url}-${i}`}
gap={12}
@@ -116,13 +133,35 @@ export function StepDocuments({ documents }: { documents: OnboardingDoc[] }) {
>
<CheckCircle2 size={15} />
<Text size="xs" fw={600} c="#0A6F4D">
Uploaded
On file
</Text>
</Box>
</Group>
))}
</Stack>
)}
<Text fz={13} fw={700} c="#10202F" mb="sm">
Documents for this booking
</Text>
{docSettingQuery.isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" color="edr-green" />
</Group>
) : docSettingQuery.data ? (
<SmartFileInput
file={docSettingQuery.data}
value={documents}
onChange={setDocuments}
/>
) : (
<Text size="sm" c="dimmed">
No document requirements are configured for your account. The documents
on file from your onboarding will be attached to this booking
automatically.
</Text>
)}
</StepCard>
);
}

View File

@@ -1,12 +1,11 @@
import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core";
import type { ReactNode } from "react";
import { FileText, Layers, Train, Truck } from "lucide-react";
import { Check, FileText, Info, Layers, Train, Truck } from "lucide-react";
import { useEffect, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { BookingFormInputValues, type BookingFormValues } from "./schema";
import {
fieldStyles,
OptionCard,
OptionFieldError,
StepCard,
StepHeader,
@@ -40,7 +39,6 @@ export function Step2ServiceType({
serviceType ?? {};
const firstMileEnabled = form.watch("firstMile.enabled");
const lastMileEnabled = form.watch("lastMile.enabled");
const customsClearingEnabled = form.watch("customsClearingEnabled");
const prevServiceType = useRef(serviceType);
useEffect(() => {
@@ -65,12 +63,17 @@ export function Step2ServiceType({
if (!prev || prev === serviceType) return;
if (!includesCustoms)
if (includesCustoms) {
form.setValue("customsClearingEnabled", true, { shouldDirty: true });
form.setValue("customsClearingAgent", "", { shouldDirty: true });
} else {
form.setValue("customsClearingEnabled", false, { shouldDirty: true });
form.setValue("customsClearingAgent", "", { shouldDirty: true });
}
}, [serviceTypeId, form]);
const showServiceSections =
includesCustoms || includesFirstMile || includesLastMile;
serviceType != null || includesFirstMile || includesLastMile;
return (
<StepCard>
<StepHeader
@@ -84,17 +87,14 @@ export function Step2ServiceType({
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-3 sm:grid-cols-2">
{referenceData?.service
.filter((s) => s.canBeBookedAlone)
.map((s) => (
<OptionCard
<ServiceTypeCard
key={s.id}
selected={field.value === s.id}
onClick={() => field.onChange(s.id)}
icon={<Train className="h-5 w-5" />}
iconBg="#EEF0FB"
iconColor="#4F46E5"
title={s.serviceName}
description={s.description}
/>
@@ -263,43 +263,95 @@ export function Step2ServiceType({
)}
{/* Customs Clearing */}
{includesCustoms && (
<Controller
name="customsClearingEnabled"
control={form.control}
render={({ field }) => (
<ServiceToggle
icon={<FileText size={18} />}
title="Customs Clearing Service"
description="EDR handles customs documentation and clearance on your behalf."
checked={field.value ?? false}
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("customsClearingAgent", "", {
shouldDirty: true,
shouldValidate: true,
});
}
{includesCustoms ? (
<Box
px={16}
py={14}
style={{
borderRadius: 14,
border: "1.5px solid #CDEBDD",
background: "#F6FBF8",
}}
>
<Group gap={13} align="flex-start" wrap="nowrap">
<Box
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#ECF6F1",
color: "#0A6F4D",
}}
>
{customsClearingEnabled && (
<Controller
name="customsClearingAgent"
control={form.control}
render={({ field: af, fieldState }) => (
<TextInput
{...af}
mt="sm"
placeholder="Customs clearing agent *"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
</ServiceToggle>
<FileText size={18} />
</Box>
<Box>
<Text fz={14} fw={700} c="#10202F">
Customs Clearing Service
</Text>
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
Customs documentation and clearance is included automatically with this service.
</Text>
</Box>
<Box style={{ flexShrink: 0, marginLeft: "auto" }}>
<Group gap={6} align="center">
<Info size={14} color="#0A6F4D" />
<Text fz={12} fw={600} c="#0A6F4D">Included</Text>
</Group>
</Box>
</Group>
</Box>
) : (
<Controller
name="customsClearingAgent"
control={form.control}
render={({ field, fieldState }) => (
<Box
px={16}
py={14}
style={{
borderRadius: 14,
border: "1.5px solid #E6ECF2",
background: "#fff",
}}
>
<Group gap={13} align="flex-start" wrap="nowrap" mb="sm">
<Box
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#F1F4F7",
color: "#64748B",
}}
>
<FileText size={18} />
</Box>
<Box>
<Text fz={14} fw={700} c="#10202F">
Customs Clearing Agent
</Text>
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
Enter the name of the customs clearing agent for this shipment.
</Text>
</Box>
</Group>
<TextInput
{...field}
placeholder="Customs clearing agent name"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
</Box>
)}
/>
)}
@@ -309,6 +361,92 @@ export function Step2ServiceType({
);
}
/**
* Compact service-type selection card. A single horizontal row (icon · text ·
* radio) — deliberately smaller than the shared OptionCard so the service list
* stays scannable.
*/
function ServiceTypeCard({
selected,
onClick,
title,
description,
}: {
selected: boolean;
onClick: () => void;
title?: ReactNode;
description?: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
style={{
width: "100%",
textAlign: "left",
cursor: "pointer",
borderRadius: 12,
padding: "12px 14px",
transition: "all 140ms ease",
border: `1.5px solid ${selected ? "#12B981" : "#E6ECF2"}`,
background: selected ? "#F4FBF7" : "#fff",
boxShadow: selected
? "0 0 0 1px #12B981, 0 4px 12px rgba(14,163,113,0.10)"
: "0 1px 2px rgba(16,24,40,0.04)",
}}
onMouseEnter={(e) => {
if (!selected) e.currentTarget.style.borderColor = "#BFE3D2";
}}
onMouseLeave={(e) => {
if (!selected) e.currentTarget.style.borderColor = "#E6ECF2";
}}
>
<Group gap={11} align="center" wrap="nowrap">
<Box
style={{
width: 34,
height: 34,
flexShrink: 0,
borderRadius: 9,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: selected ? "#E3F4EC" : "#EEF0FB",
color: selected ? "#0A6F4D" : "#4F46E5",
}}
>
<Train size={17} />
</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text fz={13.5} fw={700} c="#10202F" truncate>
{title}
</Text>
{description && (
<Text fz={11.5} c="#6B7C8E" truncate style={{ lineHeight: 1.35 }}>
{description}
</Text>
)}
</Box>
<Box
style={{
width: 18,
height: 18,
flexShrink: 0,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: selected ? "none" : "1.5px solid #CBD5E1",
background: selected ? "#12B981" : "transparent",
}}
>
{selected && <Check size={11} color="#fff" strokeWidth={3} />}
</Box>
</Group>
</button>
);
}
function ServiceToggle({
icon,
title,

View File

@@ -4,13 +4,14 @@ import {
Button,
Divider,
Group,
NumberInput,
Skeleton,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import {
CalendarDays,
Flame,
MapPin,
Plus,
@@ -18,7 +19,7 @@ import {
Snowflake,
Trash2,
} from "lucide-react";
import { useMemo } from "react";
import { useCallback, useEffect, useMemo } from "react";
import {
Controller,
useFieldArray,
@@ -48,42 +49,109 @@ export function Step4Route({
}) {
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");
const operationType = form.watch("operationType");
const isGeneralContract = form.watch("bookingType") === "general_contract";
// The operation chosen in step 0 fixes which end of the route is inside
// Ethiopia. Djibouti yards stand in for "outside Ethiopia" (the port),
// mirroring getRouteDirection / the backend's deriveTradeDirection:
// import → origin outside (Djibouti), destination Ethiopia
// export → origin Ethiopia, destination outside (Djibouti)
// intercity → both Ethiopia (domestic)
// _ff variants share the trade direction of their base operation.
const { originCountry, destinationCountry } = useMemo(() => {
switch (operationType) {
case "import":
case "import_ff":
return { originCountry: "Djibouti", destinationCountry: "Ethiopia" };
case "export":
case "export_ff":
return { originCountry: "Ethiopia", destinationCountry: "Djibouti" };
case "intercity":
return { originCountry: "Ethiopia", destinationCountry: "Ethiopia" };
default:
return { originCountry: null, destinationCountry: null };
}
}, [operationType]);
const {
fields: extraRoutes,
append: appendRoute,
remove: removeRoute,
} = useFieldArray({ control: form.control, name: "extraRoutes" });
// useFieldArray's `fields` don't re-render on value change, so watch the live
// route values to filter each row's yard options by what it has selected.
const watchedExtraRoutes = form.watch("extraRoutes") ?? [];
const yardOptions = useMemo(() => {
if (!referenceData?.yard) return [];
return referenceData.yard.map((y) => ({ value: y.id, label: y.name }));
}, [referenceData]);
const originData = useMemo(() => {
return yardOptions
.filter((o) => o.value !== destinationYard)
.filter((o) => {
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
if (!dest) return true;
const origin = referenceData?.yard.find((y) => y.id === o.value);
// Filter the yard list to one side of a route: the yards in `country` (the
// operation type fixes which country each end must be in — see originCountry /
// destinationCountry above), excluding the yard already chosen on the other
// end of the same route so origin and destination can never match.
const yardsForSide = useCallback(
(country: string | null, excludeYardId: string) =>
yardOptions
.filter((o) => o.value !== excludeYardId)
.filter((o) => {
if (!country) return true;
const yard = referenceData?.yard.find((y) => y.id === o.value);
return yard?.country === country;
}),
[yardOptions, referenceData],
);
// can't go from Djibouti to Djibouti
if (dest?.country === "Djibouti" && origin?.country == "Djibouti")
return false;
return true;
});
}, [yardOptions, destinationYard]);
const destData = useMemo(() => {
return yardOptions.filter((o) => o.value !== originYard);
}, [yardOptions, originYard]);
const originData = useMemo(
() => yardsForSide(originCountry, destinationYard),
[yardsForSide, originCountry, destinationYard],
);
const destData = useMemo(
() => yardsForSide(destinationCountry, originYard),
[yardsForSide, destinationCountry, originYard],
);
const origin = referenceData?.yard.find((y) => y.id === originYard);
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
const direction = getRouteDirection(origin, dest);
// Changing the operation type (step 0) can invalidate a yard already chosen
// here — e.g. switching import→export flips which end must be in Ethiopia.
// Clear any selection that no longer matches the operation's required country
// so the customer can't submit a route that contradicts the operation.
useEffect(() => {
if (originCountry && origin && origin.country !== originCountry) {
form.setValue("originYard", "");
}
}, [originCountry, origin, form]);
useEffect(() => {
if (destinationCountry && dest && dest.country !== destinationCountry) {
form.setValue("destinationYard", "");
}
}, [destinationCountry, dest, form]);
// Same cleanup for the extra contract routes: when the operation type changes,
// clear any extra-route yard whose country no longer matches the required side
// so an added route can't contradict the operation either.
useEffect(() => {
watchedExtraRoutes.forEach((route, i) => {
const ro = referenceData?.yard.find((y) => y.id === route?.originYard);
if (originCountry && ro && ro.country !== originCountry) {
form.setValue(`extraRoutes.${i}.originYard`, "");
}
const rd = referenceData?.yard.find(
(y) => y.id === route?.destinationYard,
);
if (destinationCountry && rd && rd.country !== destinationCountry) {
form.setValue(`extraRoutes.${i}.destinationYard`, "");
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [originCountry, destinationCountry, referenceData, form]);
const directionStyle: Record<string, string> = {
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
IMPORT: "bg-amber-50 text-amber-800 border-amber-200",
@@ -97,25 +165,26 @@ export function Step4Route({
const stationSelectDisabled = yardOptions.length === 0;
// General contracts reserve quantity per route. The unit (items vs tons) comes
// from the commodity picked in the cargo step, mirroring step5-cargo-details:
// PER_ITEM → a whole item count; otherwise an estimated tonnage. Container
// contracts reserve quantity by container count instead, so no quantity input
// is shown for them here.
// A general contract can cover several routes, but each route is just an
// (origin, destination) pair — the same shape as the one-time route. The
// contracted quantity comes from the cargo step, so no per-route quantity or
// distance is collected here.
const cargoType = form.watch("cargoType");
const cargoTypePath = form.watch("cargoTypePath") ?? [];
const isContainer = cargoType === "container";
const selectedCommodity = useMemo(() => {
const parentId = cargoTypePath[0];
const childId = cargoTypePath[1];
if (!referenceData?.cargo_type || !parentId || !childId) return null;
const group = referenceData.cargo_type.find((g) => g.id === parentId);
return group?.children?.find((c) => c.id === childId) ?? null;
}, [referenceData, cargoTypePath]);
const isPerItem = selectedCommodity?.unit_of_measure === "PER_ITEM";
const quantityLabel = isPerItem ? "Quantity (Items)" : "Quantity (Tons)";
const quantityStep = isPerItem ? 1 : 0.01;
const showRouteQuantity = isGeneralContract && !isContainer;
// The reefer toggle only exists for bulk; if the customer switches to
// containers, drop any reefer flag they set so it can't ride along unseen.
useEffect(() => {
if (cargoType !== "bulk" && form.getValues("isRefrigerated")) {
form.setValue("isRefrigerated", false);
}
}, [cargoType, form]);
// Earliest selectable shipment date (today, local) for the date input's `min`.
const todayISODate = useMemo(() => {
const now = new Date();
const tz = now.getTimezoneOffset() * 60000;
return new Date(now.getTime() - tz).toISOString().slice(0, 10);
}, []);
return (
<StepCard>
@@ -168,21 +237,23 @@ export function Step4Route({
{directionLabel[direction]}
</div>
)}
{showRouteQuantity && (
<Box style={{ maxWidth: 220 }}>
{/* Estimated shipment date — one-time bookings only. General contracts
pick the date per order drawn against the contract later. */}
{!isGeneralContract && (
<Box style={{ maxWidth: 280 }}>
<Controller
name="primaryRouteQuantity"
name="scheduledDate"
control={form.control}
render={({ field, fieldState }) => (
<NumberInput
label={`${quantityLabel} *`}
placeholder={isPerItem ? "e.g. 500" : "e.g. 1200"}
description="Quantity reserved on the primary route."
min={0}
step={quantityStep}
<TextInput
type="date"
label="Estimated shipment date *"
description="A planning estimate. You'll confirm the actual date when you request the operation."
min={todayISODate}
leftSection={<CalendarDays size={16} />}
error={fieldState.error?.message}
value={field.value === "" ? "" : Number(field.value)}
onChange={(v) => field.onChange(String(v ?? ""))}
value={field.value ?? ""}
onChange={(e) => field.onChange(e.currentTarget.value)}
radius="md"
/>
)}
@@ -216,96 +287,75 @@ export function Step4Route({
</Button>
</Group>
<Text fz={12} c="#6B7C8E" mb={12}>
A general contract can reserve quantity across several routes. The
route above is your primary route; add more routes and set the
quantity reserved for each.
A general contract can cover several routes. The route above is your
primary route; add more origindestination routes the contract should
cover.
</Text>
<Stack gap={12}>
{extraRoutes.map((rf, i) => (
<Group
key={rf.id}
gap={10}
align="flex-start"
wrap="nowrap"
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 12 }}
>
<Box style={{ flex: 1 }}>
<Controller
name={`extraRoutes.${i}.originYard`}
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Origin"
placeholder="Origin..."
data={yardOptions}
/>
)}
/>
</Box>
<Box style={{ flex: 1 }}>
<Controller
name={`extraRoutes.${i}.destinationYard`}
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Destination"
placeholder="Destination..."
data={yardOptions}
/>
)}
/>
</Box>
<Box style={{ width: 140 }}>
<Controller
name={`extraRoutes.${i}.quantity`}
control={form.control}
render={({ field }) => (
<NumberInput
label={quantityLabel}
placeholder="0"
min={0}
step={quantityStep}
value={field.value === "" ? "" : Number(field.value)}
onChange={(v) => field.onChange(String(v ?? ""))}
radius="md"
/>
)}
/>
</Box>
<Box style={{ width: 110 }}>
<Controller
name={`extraRoutes.${i}.km`}
control={form.control}
render={({ field }) => (
<NumberInput
label="Distance (km)"
placeholder="0"
min={0}
step={1}
value={field.value === "" ? "" : Number(field.value)}
onChange={(v) => field.onChange(String(v ?? ""))}
radius="md"
/>
)}
/>
</Box>
<Button
variant="subtle"
color="red"
size="xs"
mt={24}
px={6}
onClick={() => removeRoute(i)}
{extraRoutes.map((rf, i) => {
// Each extra route is constrained by the SAME operation type as the
// primary route: its origin must sit in originCountry and its
// destination in destinationCountry. Watch this row's current values
// so each side also excludes the yard picked on the other side.
const rowOrigin = watchedExtraRoutes[i]?.originYard ?? "";
const rowDestination =
watchedExtraRoutes[i]?.destinationYard ?? "";
const rowOriginData = yardsForSide(originCountry, rowDestination);
const rowDestData = yardsForSide(destinationCountry, rowOrigin);
return (
<Group
key={rf.id}
gap={10}
align="flex-start"
wrap="nowrap"
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 12 }}
>
<Trash2 size={16} />
</Button>
</Group>
))}
<Box style={{ flex: 1 }}>
<Controller
name={`extraRoutes.${i}.originYard`}
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Origin"
placeholder="Origin..."
disabled={stationSelectDisabled}
data={rowOriginData}
/>
)}
/>
</Box>
<Box style={{ flex: 1 }}>
<Controller
name={`extraRoutes.${i}.destinationYard`}
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Destination"
placeholder="Destination..."
disabled={stationSelectDisabled}
data={rowDestData}
/>
)}
/>
</Box>
<Button
variant="subtle"
color="red"
size="xs"
mt={24}
px={6}
onClick={() => removeRoute(i)}
>
<Trash2 size={16} />
</Button>
</Group>
);
})}
</Stack>
</Box>
)}
@@ -329,21 +379,26 @@ export function Step4Route({
/>
)}
/>
<Controller
name="isRefrigerated"
control={form.control}
render={({ field }) => (
<ToggleRow
icon={<Snowflake size={18} />}
iconBg="#E9F0F8"
iconColor="#2E5B96"
title="Refrigerated Cargo"
description="Temperature-controlled transport applies a refrigeration surcharge."
checked={field.value}
onChange={(v) => field.onChange(v)}
/>
)}
/>
{/* Reefer is a customer choice for bulk freight only. For containers the
reefer surcharge is driven by the container type, so the toggle is
hidden there to avoid a control that doesn't affect the price. */}
{cargoType === "bulk" && (
<Controller
name="isRefrigerated"
control={form.control}
render={({ field }) => (
<ToggleRow
icon={<Snowflake size={18} />}
iconBg="#E9F0F8"
iconColor="#2E5B96"
title="Refrigerated Cargo"
description="Temperature-controlled transport applies a refrigeration surcharge."
checked={field.value}
onChange={(v) => field.onChange(v)}
/>
)}
/>
)}
</Stack>
</StepCard>
);

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useRef } from "react";
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
import { Package, Plus, Trash2, Weight } from "lucide-react";
import { ActionIcon, Button, Skeleton, Text, TextInput } from "@mantine/core";
@@ -56,11 +56,25 @@ export function Step5CargoDetails({
);
}, [referenceData]);
// Reset the chosen commodity ONLY when the parent group actually changes to a
// different one. The previous version reset on every render where parentId was
// truthy, which wiped a valid commodity whenever the step re-rendered or was
// revisited (e.g. navigating Back/Next or restoring a saved draft) — making the
// commodity selection appear not to stick. Tracking the previous parent lets us
// clear the child on a real parent switch while leaving an existing selection
// intact on mount/re-render.
const prevParentIdRef = useRef<string | undefined>(parentId);
useEffect(() => {
if (parentId) {
form.setValue("cargoTypePath", [parentId, ""], { shouldDirty: true });
if (prevParentIdRef.current === parentId) return;
const switchedToAnotherParent =
!!prevParentIdRef.current && !!parentId;
prevParentIdRef.current = parentId;
if (switchedToAnotherParent) {
form.setValue("cargoTypePath", [parentId as string, ""], {
shouldDirty: true,
});
}
}, [parentId]);
}, [parentId, form]);
const selectedCommodity = useMemo(() => {
if (!referenceData?.cargo_type || !parentId || !childId) return null;

View File

@@ -35,7 +35,8 @@ export const REVIEW_STEP_TARGETS = {
service: 2,
cargo: 3,
route: 4,
schedule: 5,
// The estimated shipment date now lives in the Route step.
schedule: 4,
documents: 6,
} as const;
@@ -159,14 +160,10 @@ export function Step8Review({
.join(", ")
: "";
// For bulk general contracts the quantity is reserved per route (the primary
// route's amount lives in primaryRouteQuantity); one-time bookings use the
// cargo-step cargoWeight.
const isGeneralContract = values.bookingType === "general_contract";
const bulkAmount =
isGeneralContract && values.cargoType === "bulk"
? Number(values.primaryRouteQuantity || 0)
: Number(values.cargoWeight || 0);
// Both one-time and general contracts take the bulk amount from the cargo step
// (cargoWeight); general contracts no longer collect a per-route quantity.
const bulkAmount = Number(values.cargoWeight || 0);
const totalVgm =
values.cargoType === "container"
? values.containers.reduce(
@@ -175,8 +172,18 @@ export function Step8Review({
)
: bulkAmount;
// Documents are reused from onboarding (read-only) and attached on submit.
const onboardingDocsCount = onboardingDocs.length;
// Documents the customer attached for THIS booking (keyed by document field).
// Onboarding docs on the active profile are still listed as a fallback so the
// customer can see what is already on file.
const attachedDocs = Object.entries(
(values.documents ?? {}) as Record<string, File | File[] | null>,
)
.filter(([, v]) => (Array.isArray(v) ? v.length > 0 : Boolean(v)))
.map(([key, v]) => ({
name: Array.isArray(v) ? (v[0]?.name ?? key) : ((v as File).name ?? key),
}));
const onboardingDocsCount = attachedDocs.length || onboardingDocs.length;
const docsToShow = attachedDocs.length > 0 ? attachedDocs : onboardingDocs;
const selectedCommodity = (() => {
if (values.cargoType !== "bulk" || !referenceData) return null;
@@ -347,13 +354,15 @@ export function Step8Review({
/>
</OverviewSection>
<OverviewSection
icon={<Calendar size={18} />}
title="Schedule"
onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)}
>
<DetailRow label="Estimated shipment date" value={scheduleLabel} />
</OverviewSection>
{!isGeneralContract && (
<OverviewSection
icon={<Calendar size={18} />}
title="Schedule"
onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)}
>
<DetailRow label="Estimated shipment date" value={scheduleLabel} />
</OverviewSection>
)}
<OverviewSection
icon={<Package size={18} />}
@@ -400,7 +409,7 @@ export function Step8Review({
>
<Stack gap="xs">
{onboardingDocsCount > 0 ? (
onboardingDocs.map((doc, i) => (
docsToShow.map((doc, i) => (
<Group
key={`${doc.name}-${i}`}
justify="space-between"
@@ -413,7 +422,7 @@ export function Step8Review({
</Text>
</Group>
<Text size="xs" c="dimmed">
Uploaded
Attached
</Text>
</Group>
))
@@ -421,13 +430,13 @@ export function Step8Review({
<Group gap="xs" wrap="nowrap">
<Circle size={16} className="text-gray-300 shrink-0" />
<Text size="sm" c="dimmed">
No onboarding documents found on your active profile.
No documents attached yet.
</Text>
</Group>
)}
</Stack>
<Text size="xs" c="dimmed" mt="sm">
Documents from your onboarding will be attached to this booking.
These documents will be attached to this booking.
</Text>
</OverviewSection>
@@ -463,10 +472,12 @@ export function Step8Review({
done={Boolean(values.originYard && values.destinationYard)}
label="Route selected"
/>
<ReadinessItem
done={Boolean(values.scheduledDate)}
label="Shipment day selected"
/>
{!isGeneralContract && (
<ReadinessItem
done={Boolean(values.scheduledDate)}
label="Shipment date selected"
/>
)}
<ReadinessItem
done={
values.cargoType === "container"
@@ -477,7 +488,7 @@ export function Step8Review({
/>
<ReadinessItem
done={onboardingDocsCount > 0}
label="Onboarding documents attached"
label="Documents attached"
/>
</Stack>
</Paper>

View File

@@ -0,0 +1,141 @@
import { useEffect, useRef } from "react";
import type { UseFormReturn } from "react-hook-form";
import type { BookingFormInputValues, BookingFormValues } from "./schema";
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
const STORAGE_KEY = "edr.freight.bookingDraft.v1";
// Debounce writes so we don't hit localStorage on every keystroke.
const WRITE_DELAY_MS = 400;
interface BookingDraftSnapshot {
step: number;
values: Partial<BookingFormInputValues>;
savedAt: number;
}
/**
* Uploaded files can't be serialized to localStorage, so the documents map is
* stripped before persisting. The customer re-attaches files when they resume —
* everything else (operation, route, cargo, etc.) survives a refresh.
*/
function stripUnserializable(
values: BookingFormInputValues,
): Partial<BookingFormInputValues> {
const { documents: _documents, ...rest } = values;
return rest;
}
function readDraft(): BookingDraftSnapshot | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as BookingDraftSnapshot;
if (!parsed || typeof parsed !== "object" || !parsed.values) return null;
return parsed;
} catch {
// Corrupt or unavailable storage — treat as no draft.
return null;
}
}
export function clearBookingDraft(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore storage errors (private mode, quota, etc.).
}
}
/**
* Persists the in-progress booking wizard to localStorage so a refresh (or
* accidental navigation) doesn't lose the customer's work, and restores it on
* the next visit.
*
* `fresh` is set by the explicit "New Booking" entry points (they navigate with
* `state: { fresh: true }`). A plain refresh has no such state, so:
* - fresh === true → discard any saved draft and start clean.
* - fresh !== true → restore the saved draft and resume where they left off.
*
* Returns `clearDraft` so the page can wipe the draft once the booking is
* actually submitted.
*/
export function useBookingDraft({
form,
step,
setStep,
fresh,
}: {
form: BookingForm;
step: number;
setStep: (step: number) => void;
fresh: boolean;
}): { clearDraft: () => void } {
// Restore (or clear) exactly once on mount.
const restoredRef = useRef(false);
useEffect(() => {
if (restoredRef.current) return;
restoredRef.current = true;
if (fresh) {
clearBookingDraft();
return;
}
const draft = readDraft();
if (!draft) return;
// Merge over current defaults so any new schema fields keep their defaults.
form.reset(
{ ...form.getValues(), ...draft.values },
{ keepDefaultValues: true },
);
if (typeof draft.step === "number") setStep(draft.step);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Persist on every form change (debounced) and whenever the step changes.
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const stepRef = useRef(step);
stepRef.current = step;
const write = () => {
try {
const snapshot: BookingDraftSnapshot = {
step: stepRef.current,
values: stripUnserializable(form.getValues()),
savedAt: Date.now(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch {
// Ignore storage errors (private mode, quota, etc.).
}
};
useEffect(() => {
// Don't persist until the initial restore/clear has run.
if (!restoredRef.current) return;
const sub = form.watch(() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(write, WRITE_DELAY_MS);
});
return () => {
sub.unsubscribe();
if (timerRef.current) clearTimeout(timerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form]);
// Step changes are immediate (no debounce) so a refresh lands on the right step.
useEffect(() => {
if (!restoredRef.current) return;
write();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]);
return { clearDraft: clearBookingDraft };
}

View File

@@ -0,0 +1,84 @@
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
import type { SubmitBookingResponse } from "@/services/bookings.service";
interface PriceChangeModalProps {
data: SubmitBookingResponse | null;
onClose: () => void;
onConfirm: () => void;
confirmPending: boolean;
}
/**
* Shown when submitting a booking returns a changed price: the customer must
* confirm the new total before the submit completes. Shared by the detail-page
* resubmit flow and the home-page resubmit modal.
*/
export function PriceChangeModal({
data,
onClose,
onConfirm,
confirmPending,
}: PriceChangeModalProps) {
return (
<Modal
opened={data !== null}
onClose={onClose}
title={<Text fw={700}>Price has changed</Text>}
radius="lg"
centered
>
{data && (
<Stack gap="md">
<Text size="sm" c="dimmed">
{data.message ??
"The booking price has been updated. Confirm to submit with the new total."}
</Text>
{data.previousTotalAmount !== undefined && (
<Group justify="space-between">
<Text size="sm" c="dimmed">
Previous total
</Text>
<Text size="sm" td="line-through">
{data.previousTotalAmount.toLocaleString()} {data.currency}
</Text>
</Group>
)}
<Group justify="space-between">
<Text fw={700}>New total</Text>
<Text fw={800} c="edr-green">
{data.totalAmount.toLocaleString()} {data.currency}
</Text>
</Group>
{data.lineItems && data.lineItems.length > 0 && (
<Stack gap={4}>
{data.lineItems.map((item) => (
<Group key={item.code} justify="space-between">
<Text size="sm" c="dimmed">
{item.description}
</Text>
<Text size="sm" fw={600}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
))}
</Stack>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={onClose}>
Review later
</Button>
<Button
color="edr-green"
radius="md"
loading={confirmPending}
onClick={onConfirm}
>
Confirm &amp; submit
</Button>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -0,0 +1,114 @@
import { Alert, Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
import { AlertCircle, MessageSquareWarning, Send } from "lucide-react";
import type { Freight } from "@edr/types";
import { PriceChangeModal } from "./PriceChangeModal";
import { ResubmitDocuments } from "./ResubmitDocuments";
import { useResubmitFlow } from "./useResubmitFlow";
interface ResubmitBookingModalProps {
booking: Freight.IBooking;
opened: boolean;
onClose: () => void;
}
/**
* Home-page modal for a CHANGES_REQUESTED booking: shows the staff change
* request, lets the customer update the documents they submitted, and resubmit
* for review — all without leaving the My Shipments list.
*
* Mounted only while `opened` so replacement state resets on each open.
*/
export function ResubmitBookingModal({
booking,
opened,
onClose,
}: ResubmitBookingModalProps) {
if (!opened) return null;
return <ResubmitBookingModalBody booking={booking} onClose={onClose} />;
}
function ResubmitBookingModalBody({
booking,
onClose,
}: {
booking: Freight.IBooking;
onClose: () => void;
}) {
const flow = useResubmitFlow(booking, { onResubmitted: onClose });
return (
<>
<Modal
opened
onClose={onClose}
centered
size={560}
radius={16}
padding={24}
title={
<Box>
<Text fz={16} fw={800} c="#10202F">
Update &amp; resubmit
</Text>
<Text fz={12} c="dimmed" ff="monospace">
{booking.reference}
</Text>
</Box>
}
overlayProps={{ backgroundOpacity: 0.5, blur: 4 }}
styles={{ body: { paddingTop: 8 } }}
>
<Stack gap="md">
{booking.latestChangeRequestNote && (
<Alert
color="orange"
radius="md"
icon={<MessageSquareWarning size={18} />}
title="Changes requested by EDR"
>
{booking.latestChangeRequestNote}
</Alert>
)}
<ResubmitDocuments flow={flow} />
{flow.validationError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
{flow.validationError}
</Alert>
)}
{flow.mutations.some((m) => m.isError) && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
Something went wrong. Please try again.
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={onClose}>
Close
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Send size={16} />}
onClick={flow.resubmit}
loading={flow.isBusy}
>
Resubmit booking
</Button>
</Group>
</Stack>
</Modal>
<PriceChangeModal
data={flow.priceChange}
onClose={flow.clearPriceChange}
onConfirm={flow.confirmSubmit}
confirmPending={flow.confirmSubmitPending}
/>
</>
);
}

View File

@@ -0,0 +1,123 @@
import { Box, Group, Loader, Stack, Text } from "@mantine/core";
import { SmartFileInput } from "@edr/ui-common";
import { CheckCircle2, Download, FileText } from "lucide-react";
import { IconSquare } from "../BookingDetailPage/components/Documents";
import { labelForDocCode } from "./resubmitDocs";
import type { ResubmitFlowController } from "./useResubmitFlow";
/**
* Document section for resubmitting a CHANGES_REQUESTED booking.
*
* Mirrors the new-booking document step: the fields come from the company's
* onboarding document setting (TIN, license, ID, passport, …) rendered via
* SmartFileInput. Documents already submitted on the booking are shown as an
* "on file" reference; the customer uploads here only to replace one, or to fill
* any required field that has nothing on file yet (those block resubmit).
*/
export function ResubmitDocuments({ flow }: { flow: ResubmitFlowController }) {
const { files, setting, settingLoading, documents, setDocuments, fieldErrors } =
flow;
// One reference row per distinct doc already on the booking (latest upload).
const onFile = dedupeLatestByCode(files);
return (
<Stack gap="lg">
{onFile.length > 0 && (
<Stack gap={8}>
<Text fz={13} fw={700} c="#10202F">
Already submitted
</Text>
{onFile.map((file) => (
<Group
key={file.id}
gap={12}
align="center"
wrap="nowrap"
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: "10px 14px" }}
>
<Box
style={{
flexShrink: 0,
width: 34,
height: 34,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 9,
backgroundColor: "#EAF1FB",
color: "#2E5B96",
}}
>
<FileText size={17} />
</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text fz="13px" fw={600} c="#10202F" truncate>
{labelForDocCode(file.code)}
</Text>
<Text fz="12px" c="dimmed" truncate>
{file.name}
</Text>
</Box>
<Group gap={8} wrap="nowrap">
<Group gap={5} c="#0A6F4D">
<CheckCircle2 size={14} />
<Text fz="11.5px" fw={600} c="#0A6F4D">
On file
</Text>
</Group>
<IconSquare
href={file.signedUrl ?? file.url}
icon={<Download size={15} />}
/>
</Group>
</Group>
))}
</Stack>
)}
<Box>
<Text fz={13} fw={700} c="#10202F" mb="xs">
Update documents
</Text>
<Text fz="12px" c="dimmed" mb="sm">
Replace any document you need to change. Documents marked required must
be on file before you can resubmit.
</Text>
{settingLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" color="edr-green" />
</Group>
) : setting ? (
<SmartFileInput
file={setting}
value={documents}
onChange={setDocuments}
errors={fieldErrors}
/>
) : (
<Text fz="13px" c="dimmed">
No document requirements are configured for your account. You can
resubmit using the documents already on file.
</Text>
)}
</Box>
</Stack>
);
}
type BookingFile = ResubmitFlowController["files"][number];
/** Keep one row per code (the most recent upload, i.e. last in the array). */
function dedupeLatestByCode(files: BookingFile[]): BookingFile[] {
const order: string[] = [];
const latest = new Map<string, BookingFile>();
for (const file of files) {
if (!latest.has(file.code)) order.push(file.code);
latest.set(file.code, file);
}
return order.map((code) => latest.get(code)!);
}

View File

@@ -0,0 +1,13 @@
export { PriceChangeModal } from "./PriceChangeModal";
export { ResubmitBookingModal } from "./ResubmitBookingModal";
export { ResubmitDocuments } from "./ResubmitDocuments";
export { labelForDocCode, type BookingFile } from "./resubmitDocs";
export {
documentSettingCode,
useBookingDocumentSetting,
} from "./useBookingDocumentSetting";
export {
useResubmitFlow,
type DocumentsValue,
type ResubmitFlowController,
} from "./useResubmitFlow";

View File

@@ -0,0 +1,33 @@
import type { Freight } from "@edr/types";
import { REQUIRED_DOC_FIELDS } from "../BookingDetailPage/constants";
/** A single uploaded file on a booking. */
export type BookingFile = NonNullable<Freight.IBooking["files"]>[number];
/** Human labels for known document codes (shipment + onboarding documents). */
const LABEL_BY_CODE = new Map<string, string>([
...REQUIRED_DOC_FIELDS.map((d) => [d.key, d.label] as const),
// Company onboarding document codes (see file-upload-settings seeder).
["tin_certificate", "TIN Certificate"],
["commercial_license", "Commercial License"],
["business_license", "Business License / Trade License"],
["investment_license", "Investment License"],
["national_id", "National ID"],
["national_id_passport", "National ID / Passport"],
["passport", "Passport"],
]);
/**
* Turn a file `code` (e.g. "tin_certificate", "commercial_invoice",
* "custom_172..._0") into a human label. Known codes use their configured label;
* ad-hoc / unknown codes are title-cased from the code itself.
*/
export function labelForDocCode(code: string): string {
const known = LABEL_BY_CODE.get(code);
if (known) return known;
return code
.replace(/^custom_\d+_\d+$/, "Additional document")
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}

View File

@@ -0,0 +1,35 @@
import { useQuery } from "@tanstack/react-query";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
/** Onboarding document setting code for the company's nationality. */
export function documentSettingCode(
nationality: string | null | undefined,
): string {
return nationality === "foreign"
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
/**
* Fetches the FileUploadSetting that describes the documents a booking requires
* (TIN, license, national ID, passport, …) — the same setting the new-booking
* document step uses, resolved from the company's nationality.
*
* Shared by the resubmit modal and the changes-requested detail view so both
* render an identical document section.
*/
export function useBookingDocumentSetting() {
const auth = useAuth();
const nationality = auth.company?.company?.nationality as
| string
| null
| undefined;
return useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode(nationality) },
}),
);
}

View File

@@ -0,0 +1,179 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { api } from "@/services/api";
import type { SubmitBookingResponse } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { useBookingDocumentSetting } from "./useBookingDocumentSetting";
export type DocumentsValue = Record<string, File | File[] | null>;
/** True when a SmartFileInput value holds at least one file for a key. */
function hasFile(value: File | File[] | null | undefined): boolean {
if (!value) return false;
return Array.isArray(value) ? value.length > 0 : true;
}
/**
* Drives the "update documents and resubmit" flow for a booking that staff
* returned with `CHANGES_REQUESTED`.
*
* The document section mirrors the new-booking step: it's driven by the
* company's onboarding document setting (TIN, license, ID, passport, …) via
* SmartFileInput. Fields already present on the booking are treated as on file;
* any required field with neither an existing file nor a freshly-picked one
* blocks resubmit.
*
* Shared by the booking detail page and the home-page modal.
*/
export function useResubmitFlow(
booking: Freight.IBooking,
opts?: { onResubmitted?: () => void },
) {
const queryClient = useQueryClient();
const settingQuery = useBookingDocumentSetting();
// The booking may arrive from the lightweight list endpoint, which omits
// `files`. Fetch the full record so the already-submitted documents (and the
// required-field check that depends on them) are accurate everywhere.
const filesAlreadyLoaded = booking.files !== undefined;
const detailQuery = useQuery(
api.bookings.get.queryOptions({
input: { id: booking.id },
enabled: !filesAlreadyLoaded,
}),
);
const detailed = detailQuery.data ?? booking;
const files = detailed.files ?? [];
// Freshly-selected files keyed by fileKey (SmartFileInput value).
const [documents, setDocuments] = useState<DocumentsValue>({});
const [priceChange, setPriceChange] = useState<SubmitBookingResponse | null>(
null,
);
const [validationError, setValidationError] = useState<string>("");
// Only surface per-field "required" errors once the user has tried to submit.
const [showErrors, setShowErrors] = useState(false);
// Codes already attached to the booking from the original submission.
const existingCodes = useMemo(
() => new Set(files.map((f) => f.code)),
[files],
);
const fields = settingQuery.data?.fields ?? [];
// Required fields that have neither an existing file nor a newly-picked one.
const missingRequiredKeys = useMemo(() => {
return fields
.filter((f) => f.isRequired)
.filter(
(f) => !existingCodes.has(f.fileKey) && !hasFile(documents[f.fileKey]),
)
.map((f) => f.fileKey);
}, [fields, existingCodes, documents]);
const hasNewFiles = Object.values(documents).some(hasFile);
const invalidateLists = () =>
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
const updateMutation = useMutation({
mutationFn: (files: DocumentsValue) =>
api.bookings.update.call({ id: booking.id, dto: {}, documents: files }),
});
const submitMutation = useMutation({
mutationFn: () => api.bookings.submit.call({ id: booking.id }),
onSuccess: (result) => {
if (result.priceChanged) {
setPriceChange(result);
return;
}
finishResubmit();
},
});
const confirmSubmitMutation = useMutation({
mutationFn: () => api.bookings.confirmSubmit.call({ id: booking.id }),
onSuccess: () => {
setPriceChange(null);
finishResubmit();
},
});
function finishResubmit() {
queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: booking.id }),
});
invalidateLists();
opts?.onResubmitted?.();
}
/**
* Validate required documents, upload any newly-selected ones, then resubmit
* the booking for review.
*/
function resubmit() {
if (missingRequiredKeys.length > 0) {
setShowErrors(true);
setValidationError(
"Please attach all required documents before resubmitting.",
);
return;
}
setShowErrors(false);
setValidationError("");
const files: DocumentsValue = {};
for (const [key, value] of Object.entries(documents)) {
if (hasFile(value)) files[key] = value;
}
if (Object.keys(files).length > 0) {
updateMutation.mutate(files, {
onSuccess: () => {
setDocuments({});
submitMutation.mutate();
},
});
} else {
submitMutation.mutate();
}
}
const isBusy =
settingQuery.isLoading ||
updateMutation.isPending ||
submitMutation.isPending ||
confirmSubmitMutation.isPending;
return {
booking: detailed,
/** Documents already attached to the booking from the original submission. */
files,
setting: settingQuery.data,
settingLoading: settingQuery.isLoading || detailQuery.isLoading,
existingCodes,
documents,
setDocuments,
hasNewFiles,
missingRequiredKeys,
/** Per-field errors for SmartFileInput; only set after a failed submit. */
fieldErrors: showErrors
? Object.fromEntries(missingRequiredKeys.map((k) => [k, "Required"]))
: {},
canResubmit: missingRequiredKeys.length === 0,
validationError,
resubmit,
isBusy,
priceChange,
clearPriceChange: () => setPriceChange(null),
confirmSubmit: () => confirmSubmitMutation.mutate(),
confirmSubmitPending: confirmSubmitMutation.isPending,
mutations: [updateMutation, submitMutation, confirmSubmitMutation] as const,
};
}
export type ResubmitFlowController = ReturnType<typeof useResubmitFlow>;

View File

@@ -67,6 +67,29 @@ export default function ContractDetailPage() {
enabled: !!id && contract?.status !== "DRAFT",
});
// Multi-route contracts return one line per contracted route; single-route
// contracts return []. Drives the Routes section + the per-route order flow.
const { data: routeLines } = useQuery({
...api.bookingOrders.routes.queryOptions({
input: { contractBookingId: id! },
}),
enabled: !!id && contract?.status !== "DRAFT",
});
const poolLines = pool ?? [];
// Overall utilization across every pool line — drives the header ring + stat.
// Declared before the early returns so hook order stays stable across renders.
const totals = useMemo(() => {
const contracted = poolLines.reduce(
(s, l) => s + (l.contractedQuantity || 0),
0,
);
const ordered = poolLines.reduce((s, l) => s + (l.orderedQuantity || 0), 0);
const pct = contracted > 0 ? Math.round((ordered / contracted) * 100) : 0;
return { contracted, ordered, pct };
}, [poolLines]);
if (isLoading) {
return (
<Center mih={400} p="xl">
@@ -98,20 +121,8 @@ export default function ContractDetailPage() {
const isContainer = contract.freightType === "CONTAINER";
const isActive = contract.status === "CONTRACT_ACTIVE";
const awaitingPayment = contract.status === "FULLY_EXECUTED";
const poolLines = pool ?? [];
const showPool = contract.status !== "DRAFT";
// Overall utilization across every pool line — drives the header ring + stat.
const totals = useMemo(() => {
const contracted = poolLines.reduce(
(s, l) => s + (l.contractedQuantity || 0),
0,
);
const ordered = poolLines.reduce((s, l) => s + (l.orderedQuantity || 0), 0);
const pct = contracted > 0 ? Math.round((ordered / contracted) * 100) : 0;
return { contracted, ordered, pct };
}, [poolLines]);
return (
<Box style={{ padding: "28px 32px 40px" }}>
<Stack gap="lg">
@@ -218,6 +229,53 @@ export default function ContractDetailPage() {
</Group>
)}
{/* Contracted routes — every origin/destination pair the contract covers,
with its own remaining pool. Orders draw down one route at a time. */}
{showPool && routeLines && routeLines.length > 0 && (
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<Group gap={8} mb="lg">
<Text fw={700} fz={16} style={{ color: INK }}>
Contracted routes
</Text>
<Badge size="sm" variant="light" color="violet" radius="sm">
{routeLines.length}
</Badge>
</Group>
<Text fz={13} c="dimmed" mb="md" mt={-8}>
Lanes this contract covers. Orders draw from the shared pool below
pick a lane per order for scheduling and routing.
</Text>
<Stack gap={10}>
{routeLines.map((route) => (
<Group
key={route.routeLineId}
justify="space-between"
wrap="nowrap"
p="sm"
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
>
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={38} radius="md" variant="light" color="edr-green">
<MapPin size={18} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} style={{ color: INK }} truncate>
{route.originYardName ?? route.originYardId} {" "}
{route.destinationYardName ?? route.destinationYardId}
</Text>
{route.km != null && (
<Text fz={12} c="dimmed" truncate>
{route.km} km
</Text>
)}
</Box>
</Group>
</Group>
))}
</Stack>
</Card>
)}
{/* Drawdown pool */}
{showPool && (
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
@@ -356,14 +414,15 @@ export default function ContractDetailPage() {
Ship {new Date(order.scheduledDate).toLocaleDateString()}
{" · "}
{order.lines
.map(
(l) =>
`${Number.isInteger(l.quantity) ? l.quantity : l.quantity.toFixed(2)}${
l.containerTypeName
? ` ${l.containerTypeName}`
: ""
}`,
)
.map((l) => {
const qty = Number(l.quantity);
const label = Number.isInteger(qty)
? `${qty}`
: qty.toFixed(2);
return `${label}${
l.containerTypeName ? ` ${l.containerTypeName}` : ""
}`;
})
.join(", ")}
</Text>
</Box>

View File

@@ -209,7 +209,7 @@ export default function ContractsList() {
radius="md"
size="md"
leftSection={<Plus size={16} />}
onClick={() => navigate("/bookings/new")}
onClick={() => navigate("/bookings/new", { state: { fresh: true } })}
>
New Contract
</Button>

View File

@@ -59,9 +59,10 @@ export function PlaceOrderDialog({
const isMultiRoute = routeLines.length > 0;
const selectedRoute = routeLines.find((r) => r.routeLineId === routeLineId);
// The route the order ships on drives both the available-days query and the
// remaining-quantity check: the chosen route line for multi-route contracts,
// else the contract's own origin/destination.
// The route the order ships on drives ONLY the available-days (schedule) query:
// the chosen lane for multi-route contracts, else the contract's own
// origin/destination. Quantity is always drawn from the shared pool below —
// routes are pure lanes and carry no quantity.
const originYardId = isMultiRoute
? selectedRoute?.originYardId
: contract.originYard?.id;
@@ -129,15 +130,12 @@ export function PlaceOrderDialog({
setReeferQty("");
}
// Total quantity across the order; haz/reefer counts cannot exceed it.
const orderTotalQty = isMultiRoute
? typeof quantities["__route__"] === "number"
? (quantities["__route__"] as number)
: 0
: pool.reduce((sum, l) => {
const raw = quantities[lineKey(l)];
return sum + (typeof raw === "number" ? raw : 0);
}, 0);
// Total quantity across the order; haz/reefer counts cannot exceed it. Always
// summed from the shared pool lines, regardless of routing.
const orderTotalQty = pool.reduce((sum, l) => {
const raw = quantities[lineKey(l)];
return sum + (typeof raw === "number" ? raw : 0);
}, 0);
const hazValue = hazardousOn && typeof hazardousQty === "number" ? hazardousQty : 0;
const reeferValue = reeferOn && typeof reeferQty === "number" ? reeferQty : 0;
@@ -153,30 +151,8 @@ export function PlaceOrderDialog({
function handleSubmit() {
if (!scheduledDate) return;
if (isMultiRoute) {
if (!selectedRoute) return;
const raw = quantities["__route__"];
const qty = typeof raw === "number" ? raw : 0;
if (qty <= 0) return;
if (!hazReeferValid) return;
createMutation.mutate({
contractBookingId: contract.id,
routeLineId: selectedRoute.routeLineId,
scheduledDate: new Date(scheduledDate).toISOString(),
lines: [
{
containerTypeId: isContainer
? (selectedRoute.containerTypeId ?? null)
: null,
quantity: qty,
hazardousQuantity: hazValue,
reeferQuantity: reeferValue,
},
],
});
return;
}
// Multi-route contracts require a chosen lane (drives scheduling/routing).
if (isMultiRoute && !selectedRoute) return;
const lines: Freight.CreateBookingOrderLineDto[] = pool
.map((line) => {
@@ -201,19 +177,20 @@ export function PlaceOrderDialog({
createMutation.mutate({
contractBookingId: contract.id,
// The lane only routes/schedules the order; quantity comes from the pool.
...(isMultiRoute && selectedRoute
? { routeLineId: selectedRoute.routeLineId }
: {}),
scheduledDate: new Date(scheduledDate).toISOString(),
lines,
});
}
const orderableLines = pool.filter((l) => l.remainingQuantity > 0);
const routeQtyRaw = quantities["__route__"];
const hasQuantity = isMultiRoute
? typeof routeQtyRaw === "number" && routeQtyRaw > 0
: pool.some((l) => {
const raw = quantities[lineKey(l)];
return typeof raw === "number" && raw > 0;
});
const hasQuantity = pool.some((l) => {
const raw = quantities[lineKey(l)];
return typeof raw === "number" && raw > 0;
});
const canSubmit =
!!scheduledDate &&
hasQuantity &&
@@ -221,13 +198,10 @@ export function PlaceOrderDialog({
(!isMultiRoute || !!selectedRoute) &&
!createMutation.isPending;
// Routes are pure lanes — the label shows origin → destination only.
const routeOptions = routeLines.map((r) => ({
value: r.routeLineId,
label: `${r.originYardName ?? r.originYardId}${r.destinationYardName ?? r.destinationYardId} · ${formatQuantity(
r.remainingQuantity,
null,
isContainer,
)} remaining`,
label: `${r.originYardName ?? r.originYardId}${r.destinationYardName ?? r.destinationYardId}`,
}));
return (
@@ -285,104 +259,67 @@ export function PlaceOrderDialog({
styles={{ input: { height: 44 } }}
/>
{isMultiRoute ? (
<Stack gap="sm">
<Text fz={13} fw={600} style={{ color: INK }}>
Quantity
</Text>
{!selectedRoute ? (
<Text fz={13} c="dimmed">
Select a route to draw down from.
</Text>
) : selectedRoute.remainingQuantity <= 0 ? (
<Alert color="gray" radius="md" icon={<AlertCircle size={16} />}>
This route is fully drawn down no quantity remains.
</Alert>
) : (
<Group justify="space-between" wrap="nowrap" gap="md">
<div style={{ flex: 1 }}>
<Text fz={14} fw={600} style={{ color: INK }}>
{selectedRoute.containerTypeName ??
(isContainer ? "Containers" : "Tons")}
</Text>
<Text fz={12} c="dimmed">
{formatQuantity(
selectedRoute.remainingQuantity,
null,
isContainer,
)}{" "}
remaining
</Text>
</div>
<NumberInput
value={quantities["__route__"] ?? ""}
onChange={(v) =>
setQuantities({ __route__: v === "" ? "" : Number(v) })
}
min={0}
max={selectedRoute.remainingQuantity}
step={isContainer ? 1 : 0.5}
clampBehavior="strict"
radius="md"
w={130}
placeholder="0"
/>
</Group>
)}
</Stack>
) : (
<Stack gap="sm">
<Text fz={13} fw={600} style={{ color: INK }}>
Quantity
</Text>
{orderableLines.length === 0 && (
<Alert color="gray" radius="md" icon={<AlertCircle size={16} />}>
This contract is fully drawn down no quantity remains.
</Alert>
{isMultiRoute && !selectedRoute ? (
<Text fz={13} c="dimmed">
Select a route first, then enter how much to ship on it.
</Text>
) : (
<>
{orderableLines.length === 0 && (
<Alert color="gray" radius="md" icon={<AlertCircle size={16} />}>
This contract is fully drawn down no quantity remains.
</Alert>
)}
{orderableLines.map((line) => {
const key = lineKey(line);
const label = isContainer
? (line.containerTypeName ?? "Containers")
: line.unitOfMeasure === "PER_ITEM"
? "Items"
: "Tons";
return (
<Group key={key} justify="space-between" wrap="nowrap" gap="md">
<div style={{ flex: 1 }}>
<Text fz={14} fw={600} style={{ color: INK }}>
{label}
</Text>
<Text fz={12} c="dimmed">
{formatQuantity(
line.remainingQuantity,
line.unitOfMeasure,
isContainer,
)}{" "}
remaining
</Text>
</div>
<NumberInput
value={quantities[key] ?? ""}
onChange={(v) =>
setQuantities((prev) => ({
...prev,
[key]: v === "" ? "" : Number(v),
}))
}
min={0}
max={line.remainingQuantity}
step={
isContainer || line.unitOfMeasure === "PER_ITEM" ? 1 : 0.5
}
clampBehavior="strict"
radius="md"
w={130}
placeholder="0"
/>
</Group>
);
})}
</>
)}
{orderableLines.map((line) => {
const key = lineKey(line);
const label = isContainer
? (line.containerTypeName ?? "Containers")
: line.unitOfMeasure === "PER_ITEM"
? "Items"
: "Tons";
return (
<Group key={key} justify="space-between" wrap="nowrap" gap="md">
<div style={{ flex: 1 }}>
<Text fz={14} fw={600} style={{ color: INK }}>
{label}
</Text>
<Text fz={12} c="dimmed">
{formatQuantity(
line.remainingQuantity,
line.unitOfMeasure,
isContainer,
)}{" "}
remaining
</Text>
</div>
<NumberInput
value={quantities[key] ?? ""}
onChange={(v) =>
setQuantities((prev) => ({
...prev,
[key]: v === "" ? "" : Number(v),
}))
}
min={0}
max={line.remainingQuantity}
step={isContainer || line.unitOfMeasure === "PER_ITEM" ? 1 : 0.5}
clampBehavior="strict"
radius="md"
w={130}
placeholder="0"
/>
</Group>
);
})}
</Stack>
)}
<Stack gap="sm">
<Text fz={13} fw={600} style={{ color: INK }}>

View File

@@ -65,6 +65,21 @@ export const CONTRACT_STATUS_CONFIG: Record<
FULLY_EXECUTED: { label: "Awaiting Payment", color: "#9A6700", bg: "#FFF6E5" },
CONTRACT_ACTIVE: { label: "Active", color: "#0A6F4D", bg: "#E7F6EE" },
CONTRACT_CLOSED: { label: "Closed", color: "#6B7C8E", bg: "#EEF2F6" },
// Drawdown-order statuses, mirrored from the order's child booking as it moves
// through the same flow as a one-time booking (clearance → accept → pay →
// allocate). Reused by the order badge on ContractDetailPage.
PENDING: { label: "Pending", color: "#9A6700", bg: "#FFF6E5" },
AWAITING_DOCUMENTS: { label: "Awaiting Documents", color: "#9A6700", bg: "#FFF6E5" },
DOCUMENTS_UNDER_REVIEW: { label: "Documents Under Review", color: "#2E5B96", bg: "#EAF1FB" },
CLEARANCE_READY: { label: "Clearance Ready", color: "#0A6F4D", bg: "#E7F6EE" },
OPERATION_REQUEST_PENDING: { label: "Operation Review", color: "#9A6700", bg: "#FFF6E5" },
OPERATION_CHANGES_REQUESTED: { label: "Changes Requested", color: "#9A6700", bg: "#FFF6E5" },
OPERATION_PRICE_PENDING_CONFIRM: { label: "Confirm New Price", color: "#9A6700", bg: "#FFF6E5" },
ROAD_DISPATCH_PENDING: { label: "Awaiting Dispatch", color: "#9A6700", bg: "#FFF6E5" },
SELECTED_FOR_BATCH: { label: "Awaiting Payment", color: "#9A6700", bg: "#FFF6E5" },
PAID: { label: "Paid", color: "#0A6F4D", bg: "#E7F6EE" },
IN_TRANSIT: { label: "In Transit", color: "#2E5B96", bg: "#EAF1FB" },
COMPLETED: { label: "Completed", color: "#0A6F4D", bg: "#E7F6EE" },
EXPIRED: { label: "Expired", color: "#B42318", bg: "#FEECEB" },
CANCELLED: { label: "Cancelled", color: "#B42318", bg: "#FEECEB" },
REJECTED: { label: "Rejected", color: "#B42318", bg: "#FEECEB" },

View File

@@ -44,6 +44,7 @@ import type {
CompanyProfileResponse,
CreateCompanyPayload,
DashboardSummary,
OnboardingRequirements,
ProfileTypeValue,
} from "./companies.service";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
@@ -171,6 +172,12 @@ export const api = {
"completeOnboarding",
companiesService.completeOnboarding,
),
onboardingRequirements: endpoint<void, OnboardingRequirements>(
"companies",
"onboardingRequirements",
companiesService.getOnboardingRequirements,
),
},
bookings: {
@@ -269,10 +276,14 @@ export const api = {
bookingsService.submitClearanceDocuments(id, files),
),
proceedToOperation: endpoint<{ id: string }, Freight.IBooking>(
proceedToOperation: endpoint<
{ id: string; scheduledDate: string },
Freight.IBooking
>(
"bookings",
"proceedToOperation",
({ id }) => bookingsService.proceedToOperation(id),
({ id, scheduledDate }) =>
bookingsService.proceedToOperation(id, scheduledDate),
),
checkPayment: endpoint<{ orderId: string }, { status: string }>(

View File

@@ -206,8 +206,14 @@ export const bookingsService = {
return data.data;
},
proceedToOperation: async (id: string): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/clearance/proceed`);
proceedToOperation: async (
id: string,
scheduledDate: string,
): Promise<Freight.IBooking> => {
const { data } = await client.post(
`/api/bookings/${id}/clearance/proceed`,
{ scheduledDate },
);
return data.data;
},

View File

@@ -82,6 +82,47 @@ export interface CompanyInfoResponse {
company: CompanyResponse;
}
/** A single onboarding document field, as resolved and described by the backend. */
export interface OnboardingDocumentField {
fileKey: string;
fileLabel: string;
helpText: string | null;
isRequired: boolean;
isMultiple: boolean;
maxFiles: number;
allowedExtensions: string[];
maxSizeMb: number;
displayOrder: number;
uploaded: boolean;
}
export interface OnboardingLicenseProfile {
profileId: string;
type: string;
reference: string;
uploaded: boolean;
}
/**
* Server-driven onboarding requirements. The portal renders this verbatim: the
* backend decides which documents apply (by nationality) and what is still
* outstanding, so the client never hardcodes required fields or document sets.
*/
export interface OnboardingRequirements {
documentSettingCode: string;
nationality: string;
companyInfo: {
complete: boolean;
missingFields: { key: string; label: string }[];
};
documents: OnboardingDocumentField[];
licenseProfiles: OnboardingLicenseProfile[];
progress: { completed: number; total: number };
isComplete: boolean;
onboardingCompleted: boolean;
outstanding: string[];
}
export interface CompanyProfileInput {
type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter";
businessLicense?: string;
@@ -229,6 +270,14 @@ export const companiesService = {
return unwrap(response.data);
},
/** Server-driven list of outstanding onboarding requirements + completeness. */
getOnboardingRequirements: async (): Promise<OnboardingRequirements> => {
const response = await client.get<ApiResponse<OnboardingRequirements>>(
URL_CONSTANTS.COMPANIES_API.ONBOARDING_REQUIREMENTS,
);
return unwrap(response.data);
},
uploadDocuments: async (
companyId: string,
files: Record<string, File | File[] | null>,

View File

@@ -34,7 +34,8 @@ export interface SignupResponse {
export interface OtpPayload {
phone: string;
otp: string;
/** Required on verify; omitted on send (the server generates the code). */
otp?: string;
}
export interface OtpResponse {

View File

@@ -29,6 +29,8 @@ export interface ProfileResponse {
contactPersonPosition: string | null;
contactPersonEmail: string | null;
contactPersonPhone: string | null;
/** Phone that passed SMS OTP verification (resumes the verify step's state). */
contactVerifiedPhone: string | null;
generalManagerName: string | null;
generalManagerEmail: string | null;
generalManagerPhone: string | null;
@@ -66,6 +68,7 @@ export interface UpdateProfilePayload {
contactPersonPosition?: string;
contactPersonEmail?: string;
contactPersonPhone?: string;
contactVerifiedPhone?: string;
generalManagerName?: string;
generalManagerEmail?: string;
generalManagerPhone?: string;