mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 16:35:42 +00:00
fix conflict
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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} />,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 couldn’t load this booking’s 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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<
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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 origin→destination 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 & 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 & 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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>;
|
||||
@@ -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 & sign
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -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={{
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 origin–destination 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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 & submit
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)!);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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) },
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 }>(
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user