mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +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;
|
||||
|
||||
Reference in New Issue
Block a user