mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Merge pull request #330 from Tria-plc/freight_feature/contrat
Freight feature/contrat
This commit is contained in:
@@ -53,7 +53,7 @@ const BookingDetailPage = () => {
|
||||
id: "1",
|
||||
quantity: 2,
|
||||
vgmPerUnitTons: 11.25,
|
||||
containerType: { label: "20FT Standard", sizeFt: 20, isReefer: false },
|
||||
containerType: { label: "20FT Standard", sizeFt: 20 },
|
||||
},
|
||||
],
|
||||
approvalSteps: [
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
FileSignature,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
Milestone,
|
||||
Package,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
@@ -288,6 +289,16 @@ export default function BookingRequestDetailPage() {
|
||||
booking={booking}
|
||||
mutations={mutations}
|
||||
/>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="default"
|
||||
leftSection={<Milestone size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/bookings/${booking.id}/milestones`)
|
||||
}
|
||||
>
|
||||
View clearance milestones
|
||||
</Button>
|
||||
{showContractButton && (
|
||||
<Button
|
||||
fullWidth
|
||||
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
import {
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
ChevronRight,
|
||||
History,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
RefreshCw,
|
||||
@@ -46,12 +48,15 @@ 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";
|
||||
type PageTab = "queue" | "history";
|
||||
|
||||
const CLEARANCE_REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW";
|
||||
const CLEARANCE_HISTORY_STATUS = "CLEARANCE_READY";
|
||||
|
||||
interface ClearanceRow {
|
||||
id: string;
|
||||
@@ -62,6 +67,7 @@ interface ClearanceRow {
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
scheduledDate: string;
|
||||
updatedAt: string;
|
||||
hasCustoms: boolean;
|
||||
}
|
||||
|
||||
@@ -85,6 +91,7 @@ function toClearanceRow(booking: BookingDetail): ClearanceRow {
|
||||
originLabel: labelFromRef(booking.originYard),
|
||||
destinationLabel: labelFromRef(booking.destinationYard),
|
||||
scheduledDate: booking.scheduledDate,
|
||||
updatedAt: booking.updatedAt ?? "",
|
||||
hasCustoms: Boolean(
|
||||
booking.customsClearingEnabled ?? booking.serviceType?.includesCustoms,
|
||||
),
|
||||
@@ -102,12 +109,6 @@ function formatDate(iso?: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -129,33 +130,45 @@ function DirectionIcon({ direction }: { direction: string }) {
|
||||
|
||||
export default function DocumentClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [pageTab, setPageTab] = useState<PageTab>("queue");
|
||||
const [activeTab, setActiveTab] = useState<ClearanceTabKey>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [view, setView] = useState<ViewMode>("table");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
const isHistory = pageTab === "history";
|
||||
|
||||
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
||||
queryKey: ["clearance", "list"],
|
||||
queryKey: ["clearance", "list", isHistory],
|
||||
queryFn: () =>
|
||||
bookingsService.list({ status: CLEARANCE_REVIEW_STATUS, pageSize: 200 }),
|
||||
bookingsService.list({
|
||||
status: isHistory ? CLEARANCE_HISTORY_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],
|
||||
);
|
||||
const allRows = useMemo(() => {
|
||||
// GL clearance queue: customs bookings only
|
||||
const rows = (data?.items ?? []).map(toClearanceRow).filter((r) => r.hasCustoms);
|
||||
|
||||
// Per-tab counts drive the badge on each tab.
|
||||
const tabCounts = useMemo(() => {
|
||||
return {
|
||||
if (isHistory) {
|
||||
return [...rows].sort((a, b) => {
|
||||
const ta = new Date(a.updatedAt || 0).getTime();
|
||||
const tb = new Date(b.updatedAt || 0).getTime();
|
||||
return tb - ta;
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}, [data?.items, isHistory]);
|
||||
|
||||
const tabCounts = useMemo(
|
||||
() => ({
|
||||
all: allRows.length,
|
||||
import: allRows.filter((r) => r.tradeDirection === "IMPORT").length,
|
||||
export: allRows.filter((r) => r.tradeDirection === "EXPORT").length,
|
||||
} satisfies Record<ClearanceTabKey, number>;
|
||||
}, [allRows]);
|
||||
}),
|
||||
[allRows],
|
||||
);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
@@ -185,6 +198,26 @@ export default function DocumentClearanceListPage() {
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const statusBadge = isHistory ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<CheckCircle size={13} />}
|
||||
>
|
||||
{tabCounts.all} cleared
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={13} />}
|
||||
>
|
||||
{tabCounts.all} awaiting review
|
||||
</Badge>
|
||||
);
|
||||
|
||||
const columns: ColumnDef<ClearanceRow>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -249,11 +282,16 @@ export default function DocumentClearanceListPage() {
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: () => (
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
Under review
|
||||
</Badge>
|
||||
),
|
||||
cell: () =>
|
||||
isHistory ? (
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
Cleared
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" variant="light" color="yellow" radius="sm">
|
||||
Under review
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "go",
|
||||
@@ -265,7 +303,7 @@ export default function DocumentClearanceListPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[isHistory],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -274,16 +312,7 @@ export default function DocumentClearanceListPage() {
|
||||
<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>
|
||||
}
|
||||
meta={statusBadge}
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
@@ -298,13 +327,45 @@ export default function DocumentClearanceListPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Group>
|
||||
<SegmentedControl
|
||||
value={pageTab}
|
||||
onChange={(v) => {
|
||||
setPageTab(v as PageTab);
|
||||
setActiveTab("all");
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
value: "queue",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Inbox size={14} />
|
||||
<span>Queue</span>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "history",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<History size={14} />
|
||||
<span>History</span>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]}
|
||||
radius="md"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<KpiStrip
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Awaiting review",
|
||||
label: isHistory ? "Cleared" : "Awaiting review",
|
||||
value: tabCounts.all,
|
||||
icon: Inbox,
|
||||
icon: isHistory ? CheckCircle : Inbox,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
@@ -370,10 +431,7 @@ export default function DocumentClearanceListPage() {
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.currentTarget.value);
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
rightSection={
|
||||
query ? (
|
||||
@@ -430,9 +488,7 @@ export default function DocumentClearanceListPage() {
|
||||
<DataTable<ClearanceRow, unknown>
|
||||
columns={columns}
|
||||
data={pagedRows}
|
||||
status={
|
||||
isLoading ? "loading" : isError ? "error" : "success"
|
||||
}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={(row) => openDetail(row.id)}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
@@ -454,6 +510,7 @@ export default function DocumentClearanceListPage() {
|
||||
<ClearanceCardGrid
|
||||
rows={pagedRows}
|
||||
loading={isLoading}
|
||||
isHistory={isHistory}
|
||||
onOpen={openDetail}
|
||||
/>
|
||||
)}
|
||||
@@ -467,10 +524,12 @@ export default function DocumentClearanceListPage() {
|
||||
function ClearanceCardGrid({
|
||||
rows,
|
||||
loading,
|
||||
isHistory,
|
||||
onOpen,
|
||||
}: {
|
||||
rows: ClearanceRow[];
|
||||
loading: boolean;
|
||||
isHistory: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
if (loading) {
|
||||
@@ -495,7 +554,12 @@ function ClearanceCardGrid({
|
||||
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)} />
|
||||
<ClearanceCard
|
||||
key={r.id}
|
||||
row={r}
|
||||
isHistory={isHistory}
|
||||
onOpen={() => onOpen(r.id)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
@@ -503,9 +567,11 @@ function ClearanceCardGrid({
|
||||
|
||||
function ClearanceCard({
|
||||
row,
|
||||
isHistory,
|
||||
onOpen,
|
||||
}: {
|
||||
row: ClearanceRow;
|
||||
isHistory: boolean;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -543,9 +609,15 @@ function ClearanceCard({
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
Under review
|
||||
</Badge>
|
||||
{isHistory ? (
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
Cleared
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" variant="light" color="yellow" radius="sm">
|
||||
Under review
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Box
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Center,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Progress,
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { Flag, ListChecks } from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
||||
import { GlActionsPanel } from "@/components/contracts/gl-actions/GlActionsPanel";
|
||||
import {
|
||||
useBookingMilestones,
|
||||
useCompleteMilestone,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { useBookingDetail } from "@/hooks/bookings/useBookings";
|
||||
|
||||
export default function BookingMilestonesPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: booking } = useBookingDetail(id);
|
||||
const { data: milestones, isLoading } = useBookingMilestones(id);
|
||||
const complete = useCompleteMilestone(id ?? "");
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const list = milestones ?? [];
|
||||
const total = list.length;
|
||||
const completed = list.filter((m) => m.status === "COMPLETED").length;
|
||||
const pct = total === 0 ? 0 : Math.round((completed / total) * 100);
|
||||
return { total, completed, pct };
|
||||
}, [milestones]);
|
||||
|
||||
const reference = booking?.reference ?? "Shipment";
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={`${reference} milestones`}
|
||||
subtitle="Track and advance the Global Logistics clearance milestones for this shipment."
|
||||
backTo={id ? `/dashboard/booking-requests/${id}` : undefined}
|
||||
breadcrumbs={[
|
||||
{ label: "Booking requests", href: "/dashboard/booking-requests" },
|
||||
{ label: reference },
|
||||
{ label: "Milestones" },
|
||||
]}
|
||||
meta={
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ListChecks size={13} />}
|
||||
>
|
||||
{stats.completed}/{stats.total} done
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Stack gap="lg">
|
||||
<SectionCard icon={Flag} title="Clearance milestones">
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader color="edr-green" size="sm" />
|
||||
</Center>
|
||||
) : (
|
||||
<ClearanceMilestoneTimeline
|
||||
milestones={milestones ?? []}
|
||||
busy={complete.isPending}
|
||||
onComplete={(code, note) =>
|
||||
complete.mutate({ code, note })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{id ? (
|
||||
<GlActionsPanel
|
||||
bookingId={id}
|
||||
milestones={milestones ?? []}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<SectionCard icon={ListChecks} title="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">
|
||||
complete
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Box w="100%">
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
Milestones
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{stats.completed}/{stats.total}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={stats.pct}
|
||||
color="edr-green"
|
||||
radius="xl"
|
||||
size="md"
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
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 { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { useContractDetail } from "@/hooks/contracts/useContracts";
|
||||
|
||||
export default function ContractClearanceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const { data: contract } = useContractDetail(id);
|
||||
const {
|
||||
data: clearance,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
|
||||
queryFn: () => contractsService.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 = contract?.reference ?? "Clearance";
|
||||
// Customs (Path B) hub. The customer always creates the booking in the portal
|
||||
// after GL finalizes clearance — there is no GL "Create booking" action here.
|
||||
const ready = clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING";
|
||||
|
||||
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/contracts/clearance"
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: "Document Clearance",
|
||||
href: "/dashboard/contracts/clearance",
|
||||
},
|
||||
{ label: "Not found" },
|
||||
]}
|
||||
/>
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||
We couldn’t load this contract’s clearance.
|
||||
</Alert>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={reference}
|
||||
backTo="/dashboard/contracts/clearance"
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: "Document Clearance",
|
||||
href: "/dashboard/contracts/clearance",
|
||||
},
|
||||
{ label: reference },
|
||||
]}
|
||||
meta={
|
||||
ready ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
>
|
||||
Ready — customer books
|
||||
</Badge>
|
||||
) : 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 contract={contract} stats={stats} />
|
||||
|
||||
{ready ? (
|
||||
<Alert
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
icon={<PackageCheck size={16} />}
|
||||
title="Clearance finalized"
|
||||
>
|
||||
Customs clearance is complete. The customer can now create the
|
||||
shipment booking from the portal — no further action is needed here.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly={ready}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<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({
|
||||
contract,
|
||||
stats,
|
||||
}: {
|
||||
contract: ReturnType<typeof useContractDetail>["data"];
|
||||
stats: { pct: number; approved: number; total: number };
|
||||
}) {
|
||||
const direction = contract?.tradeDirection ?? "—";
|
||||
const serviceName = contract?.serviceType?.serviceName ?? null;
|
||||
const customs =
|
||||
contract?.serviceType?.includesCustoms ??
|
||||
contract?.customsClearingEnabled ??
|
||||
false;
|
||||
const routes = [...(contract?.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
);
|
||||
const origin =
|
||||
routes[0]?.originYard?.label ?? routes[0]?.originYard?.code ?? "Origin";
|
||||
const last = routes[routes.length - 1] ?? routes[0];
|
||||
const destination =
|
||||
last?.destinationYard?.label ??
|
||||
last?.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>
|
||||
{contract?.reference ?? "Clearance"}
|
||||
</Text>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={direction === "IMPORT" ? "edr-green" : "gray"}
|
||||
radius="sm"
|
||||
>
|
||||
{direction}
|
||||
</Badge>
|
||||
{customs ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={12} />}
|
||||
>
|
||||
Customs
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" variant="light" color="gray" radius="sm">
|
||||
No customs
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{serviceName && (
|
||||
<Text size="sm" fw={600} c="edr-text" mt={6} truncate maw={280}>
|
||||
{serviceName}
|
||||
</Text>
|
||||
)}
|
||||
<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,615 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
PackageCheck,
|
||||
PackagePlus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
ShipWheel,
|
||||
Table as TableIcon,
|
||||
Truck,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
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 { useContractClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
|
||||
type ViewMode = "table" | "cards";
|
||||
|
||||
interface ClearanceRow {
|
||||
id: string;
|
||||
reference: string;
|
||||
customerLabel: string;
|
||||
tradeDirection: string;
|
||||
freightType: string;
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
contractKind: string;
|
||||
serviceTypeName: string;
|
||||
customs: boolean;
|
||||
status: string;
|
||||
/** true once GL has finalized clearance — customer now books in the portal. */
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
function yardLabel(
|
||||
yard?: { label?: string; code?: string; name?: string } | null,
|
||||
fallback = "—",
|
||||
): string {
|
||||
if (!yard) return fallback;
|
||||
return yard.label ?? yard.name ?? yard.code ?? fallback;
|
||||
}
|
||||
|
||||
function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
||||
const routes = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
);
|
||||
const first = routes[0];
|
||||
const last = routes[routes.length - 1] ?? first;
|
||||
return {
|
||||
id: contract.id,
|
||||
reference: contract.reference,
|
||||
customerLabel: contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
: (contract.companyId ?? "—"),
|
||||
tradeDirection: contract.tradeDirection ?? "—",
|
||||
freightType: contract.freightType ?? "—",
|
||||
originLabel: yardLabel(first?.originYard),
|
||||
destinationLabel: yardLabel(last?.destinationYard),
|
||||
contractKind: contract.contractKind,
|
||||
serviceTypeName: contract.serviceType?.serviceName ?? "—",
|
||||
customs:
|
||||
contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled,
|
||||
status: contract.status,
|
||||
ready: contract.status === "CLEARANCE_READY_FOR_BOOKING",
|
||||
};
|
||||
}
|
||||
|
||||
function CustomsBadge({ customs }: { customs: boolean }) {
|
||||
return customs ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={11} />}
|
||||
>
|
||||
Customs
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
No customs
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ row }: { row: ClearanceRow }) {
|
||||
if (row.ready) {
|
||||
return (
|
||||
<Tooltip
|
||||
label="Clearance finalized — the customer creates the booking in the portal"
|
||||
withArrow
|
||||
>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<PackageCheck size={12} />}
|
||||
>
|
||||
Clearance finalized
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="yellow" radius="sm">
|
||||
Under review
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Document Clearance hub. Lists every customs (Path B) contract that still needs
|
||||
* customs clearance — awaiting documents, under GL review, or finalized and
|
||||
* waiting for the customer to create the booking in the portal. A single list,
|
||||
* no queue/history/direction tabs.
|
||||
*/
|
||||
export default function ContractClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
const [view, setView] = useState<ViewMode>("table");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
const { data, isLoading, isError, isFetching, refetch } =
|
||||
useContractClearanceQueue(true);
|
||||
|
||||
const allRows = useMemo(
|
||||
() => (data?.items ?? []).map(toClearanceRow),
|
||||
[data?.items],
|
||||
);
|
||||
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
all: allRows.length,
|
||||
ready: allRows.filter((r) => r.ready).length,
|
||||
review: allRows.filter((r) => !r.ready).length,
|
||||
}),
|
||||
[allRows],
|
||||
);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return allRows;
|
||||
return allRows.filter(
|
||||
(r) =>
|
||||
r.reference.toLowerCase().includes(q) ||
|
||||
r.customerLabel.toLowerCase().includes(q) ||
|
||||
r.originLabel.toLowerCase().includes(q) ||
|
||||
r.destinationLabel.toLowerCase().includes(q),
|
||||
);
|
||||
}, [allRows, 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/contracts/clearance/${id}`),
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const columns: ColumnDef<ClearanceRow>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "contract",
|
||||
header: () => <span className={bookingTable.headerCell}>Contract</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: "kind",
|
||||
header: () => <span className={bookingTable.headerCell}>Kind</span>,
|
||||
cell: ({ row }) => (
|
||||
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
|
||||
{row.original.contractKind === "GENERAL" ? "General" : "One-time"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "service",
|
||||
header: () => <span className={bookingTable.headerCell}>Service</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Stack gap={4} py={2} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate maw={160}>
|
||||
{r.serviceTypeName}
|
||||
</Text>
|
||||
<CustomsBadge customs={r.customs} />
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => <StatusBadge row={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "go",
|
||||
size: 150,
|
||||
cell: ({ row }) =>
|
||||
row.original.ready ? (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<PackagePlus size={14} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(
|
||||
`/dashboard/contracts/${row.original.id}/create-booking`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Create booking
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<ChevronRight size={16} className="text-muted-foreground" />
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Document Clearance"
|
||||
subtitle="Review pre-booking customs documents on contracts and finalize clearance. Once finalized, the customer creates the shipment booking in the portal."
|
||||
meta={
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={13} />}
|
||||
>
|
||||
{counts.all} need clearance
|
||||
</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: "Need clearance",
|
||||
value: counts.all,
|
||||
icon: Inbox,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Awaiting review",
|
||||
value: counts.review,
|
||||
icon: ShieldCheck,
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
label: "Ready — customer books",
|
||||
value: counts.ready,
|
||||
icon: PackageCheck,
|
||||
color: "edr-green",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<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 contracts need customs clearance.</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
px="md"
|
||||
pb="md"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
||||
gap: "var(--mantine-spacing-md)",
|
||||
}}
|
||||
>
|
||||
{rows.map((r) => (
|
||||
<ClearanceCard key={r.id} row={r} onOpen={() => onOpen(r.id)} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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}>
|
||||
<FileText 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>
|
||||
<StatusBadge row={row} />
|
||||
</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>
|
||||
</Group>
|
||||
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
|
||||
{row.contractKind === "GENERAL" ? "General" : "One-time"}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" mt={8} wrap="nowrap" gap={8}>
|
||||
<Text size="xs" c="dimmed" truncate maw={150}>
|
||||
{row.serviceTypeName}
|
||||
</Text>
|
||||
<CustomsBadge customs={row.customs} />
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Box as BoxIcon,
|
||||
Building2,
|
||||
Calendar,
|
||||
CalendarClock,
|
||||
FileText,
|
||||
Flame,
|
||||
Package,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Route as RouteIcon,
|
||||
ShieldCheck,
|
||||
Snowflake,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
|
||||
import "@/components/overview/overview.css";
|
||||
import { PageContainer } from "@/components/page";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
|
||||
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
||||
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
|
||||
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
||||
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { getContractStatusMeta } from "@/features/contracts/contract-status.config";
|
||||
import {
|
||||
useContractDetail,
|
||||
useContractMutations,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
|
||||
// Clearance phase — staff can still ACT (approve / query / finalize).
|
||||
const CLEARANCE_ACTIVE_STATUSES = [
|
||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||
"CLEARANCE_UNDER_REVIEW",
|
||||
"CLEARANCE_READY_FOR_BOOKING",
|
||||
];
|
||||
|
||||
// Clearance is done — the tab stays visible but READ-ONLY so staff/customer can
|
||||
// see which documents were approved, by whom, and when.
|
||||
const CLEARANCE_DONE_STATUSES = [
|
||||
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||
"FULLY_EXECUTED",
|
||||
"CONTRACT_ACTIVE",
|
||||
"CONTRACT_CLOSED",
|
||||
"EXPIRED",
|
||||
];
|
||||
|
||||
// Show the Clearance Review tab in either phase (active or done).
|
||||
const CLEARANCE_REVIEW_STATUSES = [
|
||||
...CLEARANCE_ACTIVE_STATUSES,
|
||||
...CLEARANCE_DONE_STATUSES,
|
||||
];
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export default function ContractRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
data: contract,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useContractDetail(id);
|
||||
const mutations = useContractMutations(id ?? "");
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab = searchParams.get("tab") === "clearance" ? "clearance" : "details";
|
||||
const setTab = (tab: string) =>
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (tab === "details") next.delete("tab");
|
||||
else next.set("tab", tab);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Center mih="60vh">
|
||||
<Stack align="center" gap="md">
|
||||
<Loader color="gray" />
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
Loading contract…
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !contract) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Container size="sm" py="xl">
|
||||
<Paper radius="md" withBorder p="xl" ta="center" style={detailStyles.card}>
|
||||
<Center>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 16,
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
color: "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
>
|
||||
<FileText size={32} />
|
||||
</Box>
|
||||
</Center>
|
||||
<Text fw={700} size="lg" mt="lg">
|
||||
Contract not found
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
This request may have been removed or the link is invalid.
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
mt="lg"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/dashboard/contract-requests")}
|
||||
>
|
||||
Back to contract requests
|
||||
</Button>
|
||||
</Paper>
|
||||
</Container>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const statusMeta = getContractStatusMeta(contract.status);
|
||||
const routes = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
);
|
||||
const showApprovalCard =
|
||||
contract.status === "PENDING_APPROVAL" ||
|
||||
contract.status === "APPROVED" ||
|
||||
contract.status === "APPROVED_PENDING_SIGNATURE";
|
||||
|
||||
const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
|
||||
// Once clearance is finalized the tab is informational only — no approve/query.
|
||||
const clearanceReadOnly = CLEARANCE_DONE_STATUSES.includes(contract.status);
|
||||
// Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
|
||||
const selfClear = !contract.customsClearingEnabled;
|
||||
// If the tab param points at clearance but the contract isn't in a clearance
|
||||
// phase, fall back to details so we never show an empty tab.
|
||||
const currentTab = activeTab === "clearance" && showClearanceTab ? "clearance" : "details";
|
||||
|
||||
const customerLabel = contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
: (contract.companyId ?? "—");
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Contract requests", href: "/dashboard/contract-requests" },
|
||||
{ label: contract.reference },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Stack gap="lg">
|
||||
{/* Hero */}
|
||||
<Paper radius="xl" p="xl" style={{ position: "relative", overflow: "hidden" }}>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/dashboard/contract-requests")}
|
||||
>
|
||||
Back to list
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<RefreshCw size={15} />}
|
||||
loading={isFetching}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 1, color: "#B26C09" }}
|
||||
>
|
||||
Contract reference
|
||||
</Text>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
|
||||
{contract.reference}
|
||||
</Title>
|
||||
<ContractStatusBadge
|
||||
status={contract.status}
|
||||
isRenewal={Boolean(contract.renewalOfId)}
|
||||
/>
|
||||
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
||||
{contract.contractKind === "GENERAL" ? "General" : "One-time"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap="lg" mt={4}>
|
||||
<MetaItem icon={Building2} text={customerLabel} />
|
||||
<MetaItem
|
||||
icon={Calendar}
|
||||
text={`Created ${formatDate(contract.createdAt)}`}
|
||||
/>
|
||||
{contract.contractValidUntil ? (
|
||||
<MetaItem
|
||||
icon={CalendarClock}
|
||||
text={`Valid until ${formatDate(contract.contractValidUntil)}`}
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<ContractWorkflowStepper
|
||||
status={contract.status}
|
||||
title={statusMeta.title}
|
||||
description={statusMeta.description}
|
||||
/>
|
||||
|
||||
{showClearanceTab && (
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
onChange={(v) => setTab(v ?? "details")}
|
||||
variant="pills"
|
||||
color="edr-green"
|
||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="details" leftSection={<FileText size={16} />}>
|
||||
Details
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="clearance"
|
||||
leftSection={<ShieldCheck size={16} />}
|
||||
>
|
||||
Clearance Review
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — primary content */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
{currentTab === "clearance" ? (
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
selfClear={selfClear}
|
||||
readOnly={clearanceReadOnly}
|
||||
onChanged={() => refetch()}
|
||||
/>
|
||||
) : (
|
||||
<Stack gap="lg">
|
||||
<SectionCard icon={RouteIcon} title="Routes">
|
||||
{routes.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No routes on this contract.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{routes.map((r) => (
|
||||
<Group
|
||||
key={r.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px="sm"
|
||||
py="xs"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate maw={160}>
|
||||
{r.originYard?.label ??
|
||||
r.originYard?.code ??
|
||||
"Origin"}
|
||||
</Text>
|
||||
<ArrowRight
|
||||
size={15}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<Text size="sm" fw={600} truncate maw={160}>
|
||||
{r.destinationYard?.label ??
|
||||
r.destinationYard?.code ??
|
||||
"Destination"}
|
||||
</Text>
|
||||
</Group>
|
||||
{r.km != null ? (
|
||||
<Badge variant="light" color="gray" radius="sm">
|
||||
{r.km} km
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={Package} title="Cargo scope">
|
||||
<Group gap="sm" mb="md">
|
||||
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
||||
{contract.tradeDirection}
|
||||
</Badge>
|
||||
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
||||
{contract.freightType}
|
||||
</Badge>
|
||||
{contract.isHazardous ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="sm"
|
||||
leftSection={<Flame size={12} />}
|
||||
>
|
||||
Hazardous
|
||||
</Badge>
|
||||
) : null}
|
||||
{contract.isReefer ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="cyan"
|
||||
radius="sm"
|
||||
leftSection={<Snowflake size={12} />}
|
||||
>
|
||||
Reefer
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
{(contract.cargoScope ?? []).length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No cargo scope lines.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{(contract.cargoScope ?? []).map((s) => (
|
||||
<Group key={s.id} gap={8} wrap="nowrap">
|
||||
<BoxIcon
|
||||
size={15}
|
||||
color="var(--mantine-color-edr-green-6)"
|
||||
/>
|
||||
<Text size="sm">
|
||||
{s.containerSize ??
|
||||
s.cargoFreeText ??
|
||||
s.cargoTypeId ??
|
||||
"Cargo"}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{contract.pricingBreakdown?.lineItems?.length ? (
|
||||
<SectionCard icon={Receipt} title="Unit rates">
|
||||
<Stack gap="xs">
|
||||
{contract.pricingBreakdown.lineItems.map((li) => (
|
||||
<Group
|
||||
key={li.code}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Text size="sm" truncate>
|
||||
{li.label}
|
||||
{li.containerSize ? ` · ${li.containerSize}` : ""}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{contract.pricingBreakdown?.currency} {li.unitPrice} /{" "}
|
||||
{li.unit}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
{contract.contractSummary ? (
|
||||
<SectionCard icon={FileText} title="Contract summary">
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{contract.contractSummary}
|
||||
</Text>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
</Stack>
|
||||
)}
|
||||
</Grid.Col>
|
||||
|
||||
{/* RIGHT — sticky action rail */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<Stack gap="lg">
|
||||
<ContractActionsToolbar
|
||||
contract={contract}
|
||||
mutations={mutations}
|
||||
onReviewClearance={
|
||||
showClearanceTab ? () => setTab("clearance") : undefined
|
||||
}
|
||||
/>
|
||||
{showApprovalCard && (
|
||||
<ContractApprovalStepsCard
|
||||
contract={contract}
|
||||
mutations={mutations}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaItem({
|
||||
icon: Icon,
|
||||
text,
|
||||
}: {
|
||||
icon: typeof Building2;
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Icon size={14} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm" fw={600} c="dark">
|
||||
{text}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FileText,
|
||||
Inbox,
|
||||
LayoutList,
|
||||
RefreshCw,
|
||||
Repeat,
|
||||
Search,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ContractApprovalProgressCell } from "@/components/contracts/ContractApprovalProgressCell";
|
||||
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
||||
import {
|
||||
ContractStatusTabs,
|
||||
type ContractStatusTabKey,
|
||||
} from "@/components/contracts/ContractStatusTabs";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { CONTRACT_LIST_TABS } from "@/features/contracts/contract-status.config";
|
||||
import {
|
||||
getStaffRowAction,
|
||||
toContractListRow,
|
||||
type ContractListRow,
|
||||
} from "@/features/contracts/mapContractListRow";
|
||||
import {
|
||||
useContractList,
|
||||
useContractListSummary,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import type { ContractListFilter } from "@/services/contracts.service";
|
||||
import {
|
||||
Badge,
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
function getStatusesForTab(tab: ContractStatusTabKey): string | undefined {
|
||||
const match = CONTRACT_LIST_TABS.find((t) => t.key === tab);
|
||||
if (!match?.statuses?.length) return undefined;
|
||||
return match.statuses.join(",");
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export default function ContractRequestsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all");
|
||||
|
||||
const tabStatuses = getStatusesForTab(activeTab);
|
||||
|
||||
const filter: ContractListFilter = useMemo(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
tab: activeTab,
|
||||
...(tabStatuses ? { statuses: tabStatuses } : {}),
|
||||
}),
|
||||
[pagination.pageIndex, pagination.pageSize, activeTab, tabStatuses],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } =
|
||||
useContractList(filter);
|
||||
const {
|
||||
data: summary,
|
||||
isLoading: summaryLoading,
|
||||
refetch: refetchSummary,
|
||||
} = useContractListSummary(filter);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const items = (data?.items ?? []).map(toContractListRow);
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return items;
|
||||
return items.filter(
|
||||
(c) =>
|
||||
c.reference.toLowerCase().includes(q) ||
|
||||
c.customerLabel.toLowerCase().includes(q),
|
||||
);
|
||||
}, [data?.items, query]);
|
||||
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const showEmpty = !isLoading && !isError && rows.length === 0;
|
||||
|
||||
const metrics = summary?.metrics;
|
||||
const tabCounts = summary?.tabs;
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refetch();
|
||||
void refetchSummary();
|
||||
}, [refetch, refetchSummary]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: ContractListRow) => {
|
||||
navigate(`/dashboard/contract-requests/${row.id}`);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const columns: ColumnDef<ContractListRow>[] = [
|
||||
{
|
||||
id: "contract",
|
||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<FileText className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{c.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" />
|
||||
{c.customerLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
return (
|
||||
<div className="space-y-1 py-1">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<span className="max-w-[8rem] truncate">{c.originLabel}</span>
|
||||
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="max-w-[8rem] truncate">
|
||||
{c.destinationLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
|
||||
>
|
||||
{c.tradeDirection}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
|
||||
>
|
||||
{c.freightType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
size: 200,
|
||||
minSize: 180,
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="py-1">
|
||||
<ContractStatusBadge
|
||||
status={row.original.status}
|
||||
isRenewal={row.original.isRenewal}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerClassName: "min-w-[11rem]",
|
||||
cellClassName: "min-w-[11rem]",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "approval",
|
||||
header: () => <span className={bookingTable.headerCell}>Approval</span>,
|
||||
cell: ({ row }) => <ContractApprovalProgressCell row={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "validity",
|
||||
header: () => <span className={bookingTable.headerCell}>Validity</span>,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<CalendarClock className="size-3.5" />
|
||||
{c.validUntil
|
||||
? `Until ${formatDate(c.validUntil)}`
|
||||
: c.validityDays
|
||||
? `${c.validityDays} days`
|
||||
: "—"}
|
||||
</span>
|
||||
{c.validFrom ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
From {formatDate(c.validFrom)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
header: () => <span className={bookingTable.headerCell}>Kind</span>,
|
||||
cell: ({ row }) => {
|
||||
const isGeneral = row.original.contractKind === "GENERAL";
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase"
|
||||
>
|
||||
{isGeneral ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Repeat className="size-3" /> General
|
||||
</span>
|
||||
) : (
|
||||
"One-time"
|
||||
)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className={bookingTable.headerCell}>Action</span>,
|
||||
cell: ({ row }) => {
|
||||
const action = getStaffRowAction(row.original);
|
||||
if (!action) return null;
|
||||
return (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
variant={action.variant === "filled" ? "filled" : action.variant}
|
||||
color="edr-green"
|
||||
onClick={(e) => {
|
||||
// Don't let the row-click navigation fire as well.
|
||||
e.stopPropagation();
|
||||
navigate(action.to(row.original.id));
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Contract requests"
|
||||
subtitle="Review, approve, and execute freight contract requests."
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
loading={isFetching}
|
||||
onClick={handleRefresh}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStrip
|
||||
loading={summaryLoading}
|
||||
items={[
|
||||
{
|
||||
label: "In queue",
|
||||
value: metrics?.inQueue ?? 0,
|
||||
icon: LayoutList,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Needs action",
|
||||
value: metrics?.needsAction ?? 0,
|
||||
icon: Clock,
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
label: "Urgent",
|
||||
value: metrics?.urgent ?? 0,
|
||||
icon: AlertTriangle,
|
||||
color: "red",
|
||||
},
|
||||
{
|
||||
label: "Closed",
|
||||
value: tabCounts?.closed ?? 0,
|
||||
icon: CheckCircle2,
|
||||
color: "edr-green",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<ContractStatusTabs
|
||||
active={activeTab}
|
||||
onChange={(tab) => {
|
||||
setActiveTab(tab);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
counts={tabCounts}
|
||||
/>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search reference or customer…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
rightSection={
|
||||
query && (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
style={{ flex: 1, minWidth: "200px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{showEmpty ? (
|
||||
<Stack align="center" gap={8} py={48}>
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||
<Inbox size={22} />
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed">No contracts match this view.</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={
|
||||
isLoading ? "loading" : isError ? "error" : "success"
|
||||
}
|
||||
onRowClick={handleRowClick}
|
||||
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>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { ArrowLeft, FileSignature, Printer } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
|
||||
/**
|
||||
* Staff contract preview + sign. Staff must open and read the generated
|
||||
* contract here before signing — there is no sign action on the detail page or
|
||||
* the list table. Signing as STAFF is only possible once the contract has been
|
||||
* generated and is in CONTRACT_READY / SIGNED_CUSTOMER.
|
||||
*/
|
||||
export default function ContractViewPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const [signOpen, setSignOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
// Offer the staff member's saved signature first; they can draw a fresh one.
|
||||
const [drawNew, setDrawNew] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: [...QUERY_KEYS.CONTRACTS.byId(id ?? ""), "contract-view"],
|
||||
queryFn: () => contractsService.getContractView(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const savedSignatureImage = data?.savedSignature?.signatureImageUrl ?? null;
|
||||
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
|
||||
|
||||
const signMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
contractsService.signContract(id!, {
|
||||
role: "STAFF",
|
||||
signatureImageBase64: usingSaved
|
||||
? (savedSignatureImage as string)
|
||||
: (signatureData ?? ""),
|
||||
signerDisplayName: signerName.trim(),
|
||||
consentText: "I confirm this contract on behalf of EDR.",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success("Contract signed");
|
||||
setSignOpen(false);
|
||||
void refetch();
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.byId(id!) });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.ROOT });
|
||||
},
|
||||
onError: () => toast.error("Failed to sign contract"),
|
||||
});
|
||||
|
||||
const handlePrint = () => iframeRef.current?.contentWindow?.print();
|
||||
|
||||
const openSign = () => {
|
||||
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
|
||||
setSignatureData(null);
|
||||
setDrawNew(false);
|
||||
setSignOpen(true);
|
||||
};
|
||||
|
||||
const confirmSign = () => {
|
||||
if (!signerName.trim()) return;
|
||||
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||
if (!image) return;
|
||||
signMutation.mutate();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Group justify="center" mih="40vh" align="center">
|
||||
<Loader color="edr-green" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<Box p="xl">
|
||||
<Text c="dimmed">Could not load contract.</Text>
|
||||
<Button variant="default" mt="md" onClick={() => navigate(-1)}>
|
||||
Go back
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={{ base: "md", md: "xl" }}>
|
||||
<Box maw={920} mx="auto">
|
||||
<Group justify="space-between" wrap="wrap" gap="sm" mb="md">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/contract-requests/${data.contractId}`)
|
||||
}
|
||||
>
|
||||
Back to contract
|
||||
</Button>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<Printer size={16} />}
|
||||
onClick={handlePrint}
|
||||
>
|
||||
Print
|
||||
</Button>
|
||||
{data.canSignStaff && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
onClick={openSign}
|
||||
>
|
||||
{usingSaved ? "Approve & sign" : "Sign as staff"}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="lg" p={0} style={{ overflow: "hidden" }}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={data.html}
|
||||
title="Contract document"
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: "80vh",
|
||||
border: "none",
|
||||
background: "white",
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Modal
|
||||
opened={signOpen}
|
||||
onClose={() => setSignOpen(false)}
|
||||
title="Sign contract as staff"
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{data.reference} — your signature is stored securely on the
|
||||
contract.
|
||||
</Text>
|
||||
<TextInput
|
||||
label="Full name"
|
||||
value={signerName}
|
||||
onChange={(e) => setSignerName(e.currentTarget.value)}
|
||||
/>
|
||||
{usingSaved ? (
|
||||
<Stack gap="xs">
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p="xs"
|
||||
style={{ borderStyle: "dashed" }}
|
||||
>
|
||||
<Image
|
||||
src={savedSignatureImage ?? undefined}
|
||||
alt="Saved signature"
|
||||
fit="contain"
|
||||
h={140}
|
||||
/>
|
||||
</Paper>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
color="edr-green"
|
||||
onClick={() => {
|
||||
setDrawNew(true);
|
||||
setSignatureData(null);
|
||||
}}
|
||||
>
|
||||
Draw a new signature instead
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setSignOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={signMutation.isPending}
|
||||
disabled={
|
||||
signMutation.isPending ||
|
||||
!signerName.trim() ||
|
||||
(!usingSaved && !signatureData)
|
||||
}
|
||||
onClick={confirmSign}
|
||||
>
|
||||
{usingSaved ? "Approve & sign" : "Confirm signature"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
CalendarDays,
|
||||
PackagePlus,
|
||||
XCircle,
|
||||
} 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/SectionCard";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
|
||||
const fmtDate = (iso?: string | null) =>
|
||||
iso
|
||||
? new Intl.DateTimeFormat("en-GB", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}).format(new Date(iso))
|
||||
: "—";
|
||||
|
||||
function lineRows(lines: Freight.RequestedShipmentLines) {
|
||||
if (lines.containers?.length) {
|
||||
return lines.containers.map(
|
||||
(c) =>
|
||||
`${c.quantity} × ${c.containerSize}` +
|
||||
(c.hazardousQuantity ? ` · ${c.hazardousQuantity} hazardous` : "") +
|
||||
(c.reeferQuantity ? ` · ${c.reeferQuantity} reefer` : ""),
|
||||
);
|
||||
}
|
||||
if (lines.bulk) {
|
||||
const b = lines.bulk;
|
||||
const parts: string[] = [];
|
||||
if (b.cargoWeightTons) parts.push(`${b.cargoWeightTons} tons`);
|
||||
if (b.itemCount) parts.push(`${b.itemCount} items`);
|
||||
if (b.hazardousQuantity) parts.push(`${b.hazardousQuantity} hazardous`);
|
||||
return [parts.join(" · ") || "Bulk cargo"];
|
||||
}
|
||||
return ["—"];
|
||||
}
|
||||
|
||||
export default function ShipmentRequestDetailPage() {
|
||||
const { id: reqId } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectNote, setRejectNote] = useState("");
|
||||
|
||||
const { data: request, isLoading } = useQuery({
|
||||
queryKey: ["shipment-request", reqId],
|
||||
queryFn: () => contractsService.getBookingRequest(reqId!),
|
||||
enabled: Boolean(reqId),
|
||||
});
|
||||
|
||||
const reject = useMutation({
|
||||
mutationFn: () => contractsService.rejectBookingRequest(reqId!, rejectNote),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["shipment-request-queue"] });
|
||||
navigate("/dashboard/shipment-requests");
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Group justify="center" py={80}>
|
||||
<Loader color="edr-green" />
|
||||
</Group>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!request) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader title="Request not found" backTo="/dashboard/shipment-requests" />
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||
We couldn't load this shipment request.
|
||||
</Alert>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const isPending = request.status === "PENDING";
|
||||
const contractRef = request.contract?.reference ?? request.contractId;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={`Shipment request ${request.reference}`}
|
||||
subtitle={`On contract ${contractRef}`}
|
||||
backTo="/dashboard/shipment-requests"
|
||||
breadcrumbs={[
|
||||
{ label: "Shipment Requests", href: "/dashboard/shipment-requests" },
|
||||
{ label: request.reference },
|
||||
]}
|
||||
meta={
|
||||
<Badge
|
||||
variant="light"
|
||||
radius="sm"
|
||||
color={
|
||||
request.status === "PENDING"
|
||||
? "edr-green"
|
||||
: request.status === "ACCEPTED"
|
||||
? "blue"
|
||||
: "gray"
|
||||
}
|
||||
>
|
||||
{request.status}
|
||||
</Badge>
|
||||
}
|
||||
action={
|
||||
isPending ? (
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<XCircle size={16} />}
|
||||
onClick={() => setRejectOpen(true)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<PackagePlus size={16} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/dashboard/contracts/${request.contractId}/create-booking?requestId=${request.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Accept & create booking
|
||||
</Button>
|
||||
</Group>
|
||||
) : request.status === "ACCEPTED" && request.createdBookingId ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/clearance/${request.createdBookingId}`)
|
||||
}
|
||||
>
|
||||
View booking clearance
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<SectionCard icon={CalendarDays} title="Requested shipment">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Preferred date (informational)
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{fmtDate(request.scheduledDate)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box>
|
||||
<Text size="sm" c="dimmed" mb={6}>
|
||||
Quantities
|
||||
</Text>
|
||||
<Stack gap={4}>
|
||||
{lineRows(request.requestedLines ?? {}).map((l, i) => (
|
||||
<Badge
|
||||
key={i}
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
size="lg"
|
||||
>
|
||||
{l}
|
||||
</Badge>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
{request.notes ? (
|
||||
<Box>
|
||||
<Text size="sm" c="dimmed" mb={4}>
|
||||
Customer note
|
||||
</Text>
|
||||
<Text size="sm">{request.notes}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{request.reviewNote ? (
|
||||
<Alert color="red" variant="light" radius="md" mt="sm">
|
||||
Rejected: {request.reviewNote}
|
||||
</Alert>
|
||||
) : null}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={rejectOpen}
|
||||
onClose={() => setRejectOpen(false)}
|
||||
centered
|
||||
radius="md"
|
||||
title="Reject shipment request"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Textarea
|
||||
label="Reason"
|
||||
placeholder="Tell the customer why this request can't proceed…"
|
||||
autosize
|
||||
minRows={3}
|
||||
value={rejectNote}
|
||||
onChange={(e) => setRejectNote(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setRejectOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={reject.isPending}
|
||||
onClick={() => reject.mutate()}
|
||||
>
|
||||
Reject request
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { ChevronRight, Inbox, PackageSearch, RefreshCw, Search } from "lucide-react";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
|
||||
const cellMeta = {
|
||||
headerClassName: ruleEngineTable.headerCell,
|
||||
cellClassName: ruleEngineTable.bodyCell,
|
||||
};
|
||||
|
||||
const fmtDate = (iso?: string | null) =>
|
||||
iso
|
||||
? new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}).format(new Date(iso))
|
||||
: "—";
|
||||
|
||||
/** Summarize requested quantities for the list row. */
|
||||
function summarizeLines(lines: Freight.RequestedShipmentLines): string {
|
||||
if (lines.containers?.length) {
|
||||
return lines.containers
|
||||
.map((c) => `${c.quantity}× ${c.containerSize}`)
|
||||
.join(", ");
|
||||
}
|
||||
if (lines.bulk) {
|
||||
const b = lines.bulk;
|
||||
if (b.cargoWeightTons) return `${b.cargoWeightTons} t bulk`;
|
||||
if (b.itemCount) return `${b.itemCount} items`;
|
||||
return "Bulk";
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
interface RequestRow {
|
||||
id: string;
|
||||
reference: string;
|
||||
contractReference: string;
|
||||
scheduledDate?: string | null;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export default function ShipmentRequestsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
||||
queryKey: ["shipment-request-queue"],
|
||||
queryFn: () => contractsService.getBookingRequestQueue(),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const rows = useMemo<RequestRow[]>(() => {
|
||||
const all = (data ?? []).map((r) => ({
|
||||
id: r.id,
|
||||
reference: r.reference || r.id.slice(0, 8),
|
||||
contractReference: r.contract?.reference ?? r.contractId,
|
||||
scheduledDate: r.scheduledDate,
|
||||
summary: summarizeLines(r.requestedLines ?? {}),
|
||||
}));
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return all;
|
||||
return all.filter(
|
||||
(r) =>
|
||||
r.reference.toLowerCase().includes(q) ||
|
||||
r.contractReference.toLowerCase().includes(q) ||
|
||||
r.summary.toLowerCase().includes(q),
|
||||
);
|
||||
}, [data, query]);
|
||||
|
||||
const columns = useMemo<ColumnDef<RequestRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "reference",
|
||||
header: "Request",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={700} c="dark.5">
|
||||
{row.original.reference}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "contract",
|
||||
header: "Contract",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="gray.7">
|
||||
{row.original.contractReference}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "summary",
|
||||
header: "Requested",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="light" color="edr-green" radius="sm">
|
||||
{row.original.summary}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "date",
|
||||
header: "Preferred date",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{fmtDate(row.original.scheduledDate)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
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="Shipment Requests"
|
||||
subtitle="Customer requests to ship under general customs contracts. Accept one to create the booking and start its clearance."
|
||||
meta={
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<PackageSearch size={13} />}
|
||||
>
|
||||
{rows.length} pending
|
||||
</Badge>
|
||||
}
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
onClick={() => refetch()}
|
||||
loading={isFetching}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
radius="md"
|
||||
maw={360}
|
||||
placeholder="Search request, contract, cargo…"
|
||||
leftSection={<Search size={15} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
{rows.length === 0 && !isLoading ? (
|
||||
<Box
|
||||
py={56}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: "1px dashed var(--mantine-color-gray-3)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Inbox size={26} className="text-muted-foreground" />
|
||||
<Text c="dimmed" mt="sm">
|
||||
No pending shipment requests.
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={(row) =>
|
||||
navigate(`/dashboard/shipment-requests/${row.id}`)
|
||||
}
|
||||
containerClassName="overflow-x-auto rounded-lg border border-edr-border"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
humanize,
|
||||
} from "@/components/customers";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
CompanyProfile,
|
||||
@@ -286,7 +287,7 @@ export default function CustomerDetailPage() {
|
||||
cell: ({ row }) => (
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={row.original.url ?? "#"}
|
||||
href={fileViewUrl(row.original.id, true)}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Banknote,
|
||||
FileSignature,
|
||||
FileText,
|
||||
Train,
|
||||
UserCheck,
|
||||
@@ -31,7 +32,13 @@ const TAB_ITEMS: Array<{
|
||||
value: OverviewTabKey;
|
||||
label: string;
|
||||
icon: typeof FileText;
|
||||
kpiKey: "bookings" | "billing" | "operations" | "customers" | "staff";
|
||||
kpiKey:
|
||||
| "bookings"
|
||||
| "contracts"
|
||||
| "billing"
|
||||
| "operations"
|
||||
| "customers"
|
||||
| "staff";
|
||||
metricKey: string;
|
||||
}> = [
|
||||
{
|
||||
@@ -41,6 +48,13 @@ const TAB_ITEMS: Array<{
|
||||
kpiKey: "bookings",
|
||||
metricKey: "totalActive",
|
||||
},
|
||||
{
|
||||
value: "contracts",
|
||||
label: "Contracts",
|
||||
icon: FileSignature,
|
||||
kpiKey: "contracts",
|
||||
metricKey: "totalActive",
|
||||
},
|
||||
{
|
||||
value: "billing",
|
||||
label: "Billing",
|
||||
|
||||
@@ -246,7 +246,6 @@ const FleetResourcePage = () => {
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => {
|
||||
const value = (row.original as unknown as Record<string, unknown>)[col.accessorKey];
|
||||
console.log(`${col.accessorKey}:`, value, 'format:', col.format);
|
||||
return formatFleetCell(value, col.format, col.accessorKey);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -147,8 +147,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{ id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" },
|
||||
{ id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" },
|
||||
],
|
||||
// Code is auto-generated server-side (LOCO-NNN) — omitted from the form.
|
||||
formFields: [
|
||||
{ name: "code", label: "Code", type: "text", required: true },
|
||||
{ name: "name", label: "Name", type: "text" },
|
||||
{ name: "locomotiveType", label: "Locomotive type", type: "select", required: true, options: LOCOMOTIVE_TYPE_OPTIONS },
|
||||
{ name: "status", label: "Status", type: "select", required: true, options: LOCOMOTIVE_STATUS_OPTIONS },
|
||||
@@ -160,12 +160,11 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{ name: "maxSpeedKmh", label: "Max speed (km/h)", type: "number" },
|
||||
],
|
||||
emptyValues: {
|
||||
code: "",
|
||||
name: "",
|
||||
locomotiveType: "DIESEL",
|
||||
status: "AVAILABLE",
|
||||
currentYardId: "",
|
||||
maxPullWeightTons: 0,
|
||||
maxPullWeightTons: 2500,
|
||||
maxTrainLengthMeters: 760,
|
||||
powerKw: "",
|
||||
tractionForceKn: "",
|
||||
|
||||
@@ -183,7 +183,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
slug: "container-types",
|
||||
label: "Container Types",
|
||||
category: "configuration",
|
||||
subtitle: "Configure container sizes and wagon capacity",
|
||||
subtitle: "Configure container sizes",
|
||||
searchPlaceholder: "Search container types...",
|
||||
orderConfig: { field: "displayOrder", label: "Display order" },
|
||||
columns: [
|
||||
@@ -191,14 +191,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ id: "label", header: "Label", accessorKey: "label" },
|
||||
{ id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" },
|
||||
{ id: "sizeFt", header: "Size (ft)", accessorKey: "sizeFt", format: "number" },
|
||||
{ id: "wagonsPerUnit", header: "Wagons / unit", accessorKey: "wagonsPerUnit", format: "number" },
|
||||
activeColumn,
|
||||
],
|
||||
formFields: [
|
||||
{ name: "label", label: "Label", type: "text", required: true },
|
||||
{ name: "sizeFt", label: "Size (ft)", type: "number", required: true },
|
||||
{ name: "wagonsPerUnit", label: "Wagons per unit", type: "number", required: true },
|
||||
{ name: "isReefer", label: "Reefer", type: "boolean" },
|
||||
{ name: "isOpenTop", label: "Open top", type: "boolean" },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
WindowStatusPill,
|
||||
} from "@/components/trainScheduling/batchVisuals";
|
||||
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { BookingsManager } from "./BookingsManager";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -482,18 +483,32 @@ export default function BatchScheduleDetailPage() {
|
||||
}),
|
||||
);
|
||||
|
||||
// Batch bookings by state for the composition side panel (payment / expired lists).
|
||||
const batchBookings = useMemo(() => {
|
||||
if (!data) return { awaitingPayment: [], expired: [] };
|
||||
const all = [
|
||||
// Every booking on this schedule, flattened across windows + pending-contract,
|
||||
// de-duplicated (a booking only appears once). Feeds the management table.
|
||||
const allBookings = useMemo(() => {
|
||||
if (!data) return [] as BatchBoardBookingDetail[];
|
||||
const merged = [
|
||||
...data.windows.flatMap((w) => w.bookings),
|
||||
...data.pendingContract.bookings,
|
||||
];
|
||||
const byId = new Map<string, BatchBoardBookingDetail>();
|
||||
for (const b of merged) if (!byId.has(b.id)) byId.set(b.id, b);
|
||||
return [...byId.values()];
|
||||
}, [data]);
|
||||
|
||||
// Batch bookings by state for the composition side panel (payment / expired lists).
|
||||
const batchBookings = useMemo(() => {
|
||||
const all = allBookings;
|
||||
return {
|
||||
awaitingPayment: all.filter((b) => b.state === "SELECTED_FOR_BATCH"),
|
||||
expired: all.filter((b) => b.state === "EXPIRED"),
|
||||
};
|
||||
}, [data]);
|
||||
}, [allBookings]);
|
||||
|
||||
const bookingsReadOnly = useMemo(
|
||||
() => ["DISPATCHED", "ARRIVED"].includes(data?.status ?? ""),
|
||||
[data?.status],
|
||||
);
|
||||
|
||||
// Group the flat window list into per-day sections (one per EAT calendar date).
|
||||
const dayGroups = useMemo(() => {
|
||||
@@ -822,6 +837,40 @@ export default function BatchScheduleDetailPage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{/* Manage bookings — search, filter, remove / re-assign (bulk too) */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
withBorder
|
||||
p="lg"
|
||||
mt="lg"
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Group gap="sm" mb="md" wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon
|
||||
size={38}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="#F2A516"
|
||||
>
|
||||
<Package size={19} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Title order={4}>Manage bookings</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Search and filter every booking on this train. Remove an
|
||||
allocated booking to free its wagons, or re-assign one that
|
||||
is not yet allocated — individually or in bulk.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<BookingsManager
|
||||
scheduleId={scheduleId ?? ""}
|
||||
bookings={allBookings}
|
||||
onChanged={() => void refetch()}
|
||||
readOnly={bookingsReadOnly}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
{/* Batch windows */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Menu,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
MoreVertical,
|
||||
PackagePlus,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
BatchBoardBookingDetail,
|
||||
BatchBoardBookingState,
|
||||
BookingAllocationStatus,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
const cellMeta = {
|
||||
headerClassName: ruleEngineTable.headerCell,
|
||||
cellClassName: ruleEngineTable.bodyCell,
|
||||
};
|
||||
|
||||
const fmtTons = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
|
||||
|
||||
const fmtDateTime = (iso: string | null) =>
|
||||
iso
|
||||
? new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
}).format(new Date(iso))
|
||||
: "—";
|
||||
|
||||
const initials = (name: string) =>
|
||||
name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase() || "?";
|
||||
|
||||
const STATE_META: Record<
|
||||
BatchBoardBookingState,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
ALLOCATED: { label: "Allocated", color: "edr-green" },
|
||||
SELECTED_FOR_BATCH: { label: "Selected for batch", color: "orange" },
|
||||
READY: { label: "Ready for batch", color: "teal" },
|
||||
WAITING: { label: "Paid · waiting", color: "blue" },
|
||||
PENDING_CONTRACT: { label: "Pending contract", color: "gray" },
|
||||
EXPIRED: { label: "Expired", color: "red" },
|
||||
};
|
||||
|
||||
const ALLOC_META: Record<
|
||||
BookingAllocationStatus,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
ASSIGNED: { label: "Wagons assigned", color: "edr-green" },
|
||||
NOT_ATTEMPTED: { label: "Not allocated", color: "gray" },
|
||||
DEFERRED: { label: "Deferred", color: "orange" },
|
||||
FAILED: { label: "Allocation failed", color: "red" },
|
||||
};
|
||||
|
||||
const STATE_FILTERS = [
|
||||
{ value: "ALL", label: "All states" },
|
||||
...Object.entries(STATE_META).map(([value, m]) => ({
|
||||
value,
|
||||
label: m.label,
|
||||
})),
|
||||
];
|
||||
|
||||
const ALLOC_FILTERS = [
|
||||
{ value: "ALL", label: "All allocations" },
|
||||
...Object.entries(ALLOC_META).map(([value, m]) => ({
|
||||
value,
|
||||
label: m.label,
|
||||
})),
|
||||
];
|
||||
|
||||
export interface BookingsManagerProps {
|
||||
scheduleId: string;
|
||||
bookings: BatchBoardBookingDetail[];
|
||||
/** Re-pull the batch-board detail after a remove / re-assign mutation. */
|
||||
onChanged: () => void;
|
||||
/** Read-only when the schedule can no longer be edited (dispatched / arrived). */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable, filterable, bulk-manageable booking table for the batch board.
|
||||
* Staff can search by reference / customer, filter by batch state and wagon
|
||||
* allocation status, and remove or re-assign bookings individually or in bulk.
|
||||
* Wraps the shared DataTable; selection + actions are handled locally so the
|
||||
* surrounding accordion / tab layout stays untouched.
|
||||
*/
|
||||
export function BookingsManager({
|
||||
scheduleId,
|
||||
bookings,
|
||||
onChanged,
|
||||
readOnly = false,
|
||||
}: BookingsManagerProps) {
|
||||
const { toast } = useToast();
|
||||
const [query, setQuery] = useState("");
|
||||
const [stateFilter, setStateFilter] = useState<string>("ALL");
|
||||
const [allocFilter, setAllocFilter] = useState<string>("ALL");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [confirm, setConfirm] = useState<
|
||||
| { kind: "remove"; ids: string[]; label: string }
|
||||
| { kind: "reassign"; ids: string[]; label: string }
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const unassign = useMutation(
|
||||
api.trainScheduling.unassignBooking.mutationOptions(),
|
||||
);
|
||||
const reassign = useMutation(
|
||||
api.trainScheduling.assignUnassignedBooking.mutationOptions(),
|
||||
);
|
||||
const busy = unassign.isPending || reassign.isPending;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return bookings.filter((b) => {
|
||||
if (stateFilter !== "ALL" && b.state !== stateFilter) return false;
|
||||
if (allocFilter !== "ALL" && b.allocationStatus !== allocFilter)
|
||||
return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
b.reference.toLowerCase().includes(q) ||
|
||||
b.company.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [bookings, query, stateFilter, allocFilter]);
|
||||
|
||||
// Selection is bounded to whatever is currently visible (filtered) to avoid
|
||||
// acting on rows the user can't see.
|
||||
const visibleIds = useMemo(() => filtered.map((b) => b.id), [filtered]);
|
||||
const selectedVisible = useMemo(
|
||||
() => visibleIds.filter((id) => selected.has(id)),
|
||||
[visibleIds, selected],
|
||||
);
|
||||
const allVisibleSelected =
|
||||
visibleIds.length > 0 && selectedVisible.length === visibleIds.length;
|
||||
const someVisibleSelected =
|
||||
selectedVisible.length > 0 && !allVisibleSelected;
|
||||
|
||||
const toggleAll = () =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (allVisibleSelected) {
|
||||
visibleIds.forEach((id) => next.delete(id));
|
||||
} else {
|
||||
visibleIds.forEach((id) => next.add(id));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const toggleOne = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const clearSelection = () => setSelected(new Set());
|
||||
|
||||
const runRemove = async (ids: string[]) => {
|
||||
let ok = 0;
|
||||
let failed = 0;
|
||||
// Sequential — each unassign mutates the schedule graph; parallel would race.
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await unassign.mutateAsync({ id: scheduleId, bookingId: id });
|
||||
ok += 1;
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
toast({
|
||||
title: "Bookings removed",
|
||||
description: `${ok} removed${failed ? ` · ${failed} failed` : ""}`,
|
||||
variant: failed ? "destructive" : "default",
|
||||
});
|
||||
clearSelection();
|
||||
onChanged();
|
||||
};
|
||||
|
||||
const runReassign = async (ids: string[]) => {
|
||||
let ok = 0;
|
||||
let failed = 0;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await reassign.mutateAsync({ id: scheduleId, bookingId: id });
|
||||
ok += 1;
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
toast({
|
||||
title: "Re-assignment run",
|
||||
description: `${ok} re-assigned${failed ? ` · ${failed} failed` : ""}`,
|
||||
variant: failed ? "destructive" : "default",
|
||||
});
|
||||
clearSelection();
|
||||
onChanged();
|
||||
};
|
||||
|
||||
const confirmAction = async () => {
|
||||
if (!confirm) return;
|
||||
const ids = confirm.ids;
|
||||
setConfirm(null);
|
||||
if (confirm.kind === "remove") await runRemove(ids);
|
||||
else await runReassign(ids);
|
||||
};
|
||||
|
||||
const columns = useMemo<ColumnDef<BatchBoardBookingDetail>[]>(() => {
|
||||
const cols: ColumnDef<BatchBoardBookingDetail>[] = [];
|
||||
|
||||
if (!readOnly) {
|
||||
cols.push({
|
||||
id: "select",
|
||||
meta: cellMeta,
|
||||
header: () => (
|
||||
<Checkbox
|
||||
size="xs"
|
||||
aria-label="Select all"
|
||||
checked={allVisibleSelected}
|
||||
indeterminate={someVisibleSelected}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
size="xs"
|
||||
aria-label={`Select ${row.original.reference}`}
|
||||
checked={selected.has(row.original.id)}
|
||||
onChange={() => toggleOne(row.original.id)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
cols.push(
|
||||
{
|
||||
id: "reference",
|
||||
header: "Reference",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={700} c="dark.5">
|
||||
{b.reference}
|
||||
</Text>
|
||||
{b.isGovernment ? (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
Gov
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "customer",
|
||||
header: "Customer",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
return (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 8,
|
||||
flexShrink: 0,
|
||||
background: "#FEF1D5",
|
||||
border: "1px solid #FBD171",
|
||||
}}
|
||||
>
|
||||
<Text size="10px" fw={800} style={{ color: "#B26C09" }}>
|
||||
{initials(b.company)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text size="sm" c="gray.7" truncate>
|
||||
{b.company}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "selectedForBatch",
|
||||
header: "Selected for batch",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
if (!b.selectedForBatchAt)
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{fmtDateTime(b.selectedForBatchAt)} EAT
|
||||
</Text>
|
||||
{b.paymentDeadline ? (
|
||||
<Text size="xs" c="orange.7" fw={600}>
|
||||
Pay by {fmtDateTime(b.paymentDeadline)} EAT
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "capacity",
|
||||
header: "Capacity",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge variant="default" radius="sm" size="sm">
|
||||
{b.wagons}w
|
||||
</Badge>
|
||||
<Badge variant="default" radius="sm" size="sm">
|
||||
{fmtTons(b.weightTons)}
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
header: "Batch state",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => {
|
||||
const m = STATE_META[row.original.state];
|
||||
return (
|
||||
<Badge variant="light" color={m.color} radius="sm">
|
||||
{m.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "allocation",
|
||||
header: "Wagon allocation",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const m = ALLOC_META[b.allocationStatus];
|
||||
const badge = (
|
||||
<Badge variant="light" color={m.color} radius="sm">
|
||||
{m.label}
|
||||
</Badge>
|
||||
);
|
||||
if (!b.allocationIssue) return badge;
|
||||
return (
|
||||
<Tooltip label={b.allocationIssue} multiline maw={320} withArrow>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{badge}
|
||||
<AlertTriangle size={14} color="var(--mantine-color-red-6)" />
|
||||
</Group>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!readOnly) {
|
||||
cols.push({
|
||||
id: "actions",
|
||||
header: "",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const isAssigned = b.allocationStatus === "ASSIGNED";
|
||||
return (
|
||||
<Group justify="flex-end" gap={4} wrap="nowrap">
|
||||
<Menu position="bottom-end" withinPortal shadow="md">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" aria-label="Actions">
|
||||
<MoreVertical size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<PackagePlus size={14} />}
|
||||
disabled={isAssigned || busy}
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
kind: "reassign",
|
||||
ids: [b.id],
|
||||
label: b.reference,
|
||||
})
|
||||
}
|
||||
>
|
||||
Re-assign to wagons
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<Trash2 size={14} />}
|
||||
disabled={!isAssigned || busy}
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
kind: "remove",
|
||||
ids: [b.id],
|
||||
label: b.reference,
|
||||
})
|
||||
}
|
||||
>
|
||||
Remove from train
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return cols;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
readOnly,
|
||||
selected,
|
||||
allVisibleSelected,
|
||||
someVisibleSelected,
|
||||
visibleIds,
|
||||
busy,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{/* Toolbar: search + filters */}
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
flex={1}
|
||||
miw={220}
|
||||
radius="md"
|
||||
placeholder="Search reference or customer…"
|
||||
leftSection={<Search size={15} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => setQuery("")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
radius="md"
|
||||
w={170}
|
||||
data={STATE_FILTERS}
|
||||
value={stateFilter}
|
||||
onChange={(v) => setStateFilter(v ?? "ALL")}
|
||||
aria-label="Filter by batch state"
|
||||
/>
|
||||
<Select
|
||||
radius="md"
|
||||
w={180}
|
||||
data={ALLOC_FILTERS}
|
||||
value={allocFilter}
|
||||
onChange={(v) => setAllocFilter(v ?? "ALL")}
|
||||
aria-label="Filter by allocation"
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{filtered.length} of {bookings.length}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* Bulk action bar */}
|
||||
{!readOnly && selectedVisible.length > 0 ? (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
px="md"
|
||||
py="xs"
|
||||
style={{
|
||||
background: "var(--mantine-color-edr-green-0)",
|
||||
borderColor: "var(--mantine-color-edr-green-2)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Badge color="edr-green" radius="sm">
|
||||
{selectedVisible.length} selected
|
||||
</Badge>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={clearSelection}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<PackagePlus size={14} />}
|
||||
loading={reassign.isPending}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
kind: "reassign",
|
||||
ids: selectedVisible,
|
||||
label: `${selectedVisible.length} booking(s)`,
|
||||
})
|
||||
}
|
||||
>
|
||||
Re-assign
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<Trash2 size={14} />}
|
||||
loading={unassign.isPending}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
kind: "remove",
|
||||
ids: selectedVisible,
|
||||
label: `${selectedVisible.length} booking(s)`,
|
||||
})
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filtered}
|
||||
status="success"
|
||||
emptyMessage={
|
||||
bookings.length
|
||||
? "No bookings match the current search / filters."
|
||||
: "No bookings in this batch window."
|
||||
}
|
||||
containerClassName="overflow-x-auto rounded-lg border border-edr-border"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(confirm)}
|
||||
onClose={() => setConfirm(null)}
|
||||
centered
|
||||
radius="md"
|
||||
title={
|
||||
confirm?.kind === "remove"
|
||||
? "Remove from train"
|
||||
: "Re-assign to wagons"
|
||||
}
|
||||
>
|
||||
<Text size="sm" mb="lg">
|
||||
{confirm?.kind === "remove"
|
||||
? `Remove ${confirm?.label} from this train? Their wagon allocation will be released.`
|
||||
: `Re-assign ${confirm?.label} to available wagons on this train?`}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setConfirm(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color={confirm?.kind === "remove" ? "red" : "edr-green"}
|
||||
loading={busy}
|
||||
onClick={confirmAction}
|
||||
>
|
||||
{confirm?.kind === "remove" ? "Remove" : "Re-assign"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default BookingsManager;
|
||||
@@ -53,7 +53,6 @@ import {
|
||||
PreviewSummary,
|
||||
ScheduleWarningsAlert,
|
||||
} from "@/components/trainScheduling/ScheduleWarningsAlert";
|
||||
import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/schedulingContainerStep.util";
|
||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
|
||||
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
|
||||
@@ -146,26 +145,10 @@ export default function TrainScheduleV2DetailPage() {
|
||||
|
||||
const containerUnits = previewResult?.containerUnits ?? [];
|
||||
const containerSlots = previewResult?.containerSlotSequenceNos ?? [];
|
||||
const hasContainerStep = useMemo(
|
||||
() =>
|
||||
shouldShowContainerPlacementStep({
|
||||
containerUnitCount: containerUnits.length,
|
||||
scheduleFreightType: freightType,
|
||||
bookingFreightTypes: [
|
||||
...(schedule?.bookings ?? []).map((b) => b.freightType),
|
||||
...(eligibleQuery.data?.items ?? [])
|
||||
.filter((item) => allSelectedIds.includes(item.id))
|
||||
.map((item) => item.freightType),
|
||||
],
|
||||
}),
|
||||
[
|
||||
allSelectedIds,
|
||||
containerUnits.length,
|
||||
eligibleQuery.data?.items,
|
||||
freightType,
|
||||
schedule?.bookings,
|
||||
],
|
||||
);
|
||||
// Container-number placement step removed — the customer enters container
|
||||
// numbers when booking, so scheduling skips straight from the wagon plan to
|
||||
// finalize. Steps: select bookings → review wagons → finalize.
|
||||
const hasContainerStep = false;
|
||||
|
||||
const displayWagonPlan = useMemo(() => {
|
||||
const savedWagons = schedule?.trainSet?.wagons ?? [];
|
||||
|
||||
Reference in New Issue
Block a user