wagon work space, container validation

This commit is contained in:
Marshal
2026-07-11 04:28:22 +00:00
parent 3f03581d8c
commit eb3fc40f42
2 changed files with 242 additions and 91 deletions

View File

@@ -42,7 +42,7 @@ import { contractsService } from "@/services/contracts.service";
import { downloadBookingFile } from "@/services/files.service"; import { downloadBookingFile } from "@/services/files.service";
import { useBookingDetail } from "@/hooks/bookings/useBookings"; import { useBookingDetail } from "@/hooks/bookings/useBookings";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions";
import { RequestedCargoChips } from "@/features/clearance/requestedCargo"; import { RequestedCargoChips } from "@/features/clearance/requestedCargo";
export default function DocumentClearanceDetailPage() { export default function DocumentClearanceDetailPage() {
@@ -101,12 +101,14 @@ export default function DocumentClearanceDetailPage() {
// Bare initiated instance whose clearance is done: GL completes the booking // Bare initiated instance whose clearance is done: GL completes the booking
// (container numbers, VGM, shipment day) via the completion form. // (container numbers, VGM, shipment day) via the completion form.
// Creating the booking is a GL Ethiopia action — never available to Djibouti GL.
const canCompleteBooking = const canCompleteBooking =
booking?.status === "CLEARANCE_READY" && booking?.status === "CLEARANCE_READY" &&
Boolean(booking?.contractId) && Boolean(booking?.contractId) &&
Boolean(booking?.customsClearingEnabled) && Boolean(booking?.customsClearingEnabled) &&
!(Number(booking?.totalAmount ?? 0) > 0) && !(Number(booking?.totalAmount ?? 0) > 0) &&
hasPermission(user, FREIGHT_PERMS.contracts.createBooking); hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user);
const docsPhaseComplete = const docsPhaseComplete =
clearance?.milestones?.some( clearance?.milestones?.some(
@@ -190,7 +192,7 @@ export default function DocumentClearanceDetailPage() {
) )
} }
> >
Complete booking Create booking
</Button> </Button>
) : undefined ) : undefined
} }

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState, type ReactNode } from "react"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
ActionIcon, ActionIcon,
@@ -7,6 +7,7 @@ import {
Button, Button,
Card, Card,
Group, Group,
Menu,
SegmentedControl, SegmentedControl,
Stack, Stack,
Text, Text,
@@ -16,16 +17,18 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { import {
ArrowRight, ArrowRight,
Calendar,
ChevronRight, ChevronRight,
ExternalLink,
Eye,
FileText, FileText,
Flag,
Inbox, Inbox,
LayoutGrid, LayoutGrid,
MoreHorizontal,
PackageCheck, PackageCheck,
PackagePlus, PackagePlus,
RefreshCw, RefreshCw,
Search, Search,
Send,
ShieldCheck, ShieldCheck,
ShipWheel, ShipWheel,
Table as TableIcon, Table as TableIcon,
@@ -46,13 +49,10 @@ import { PageHeader } from "@/components/page/PageHeader";
import { KpiStrip } from "@/components/page/KpiStrip"; import { KpiStrip } from "@/components/page/KpiStrip";
import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
import { import { useContractClearanceQueue } from "@/hooks/contracts/useContracts";
useContractClearanceQueue,
useEtClearanceQueue,
} from "@/hooks/contracts/useContracts";
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings"; import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions";
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection"; import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
import { import {
RequestedCargoChips, RequestedCargoChips,
@@ -62,7 +62,10 @@ import { contractsService } from "@/services/contracts.service";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
type ViewMode = "table" | "cards"; type ViewMode = "table" | "cards";
type QueueTab = "all" | "et" | "shipments"; type QueueTab = "all" | "shipments";
/** Persist the selected queue tab so returning from a detail keeps it. */
const QUEUE_TAB_STORAGE_KEY = "edr.clearance.queueTab";
interface ClearanceRow { interface ClearanceRow {
id: string; id: string;
@@ -203,21 +206,35 @@ export default function ContractClearanceListPage() {
const { user } = useAuth(); const { user } = useAuth();
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview); const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions); const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
const canCreateBooking = hasPermission( // Creating a booking under a cleared contract is a GL Ethiopia action — never
user, // available to Djibouti GL.
FREIGHT_PERMS.contracts.createBooking, const canCreateBooking =
); hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user);
const defaultQueue: QueueTab = canReview ? "all" : "et"; const defaultQueue: QueueTab = canReview ? "all" : "shipments";
const [queueTab, setQueueTab] = useState<QueueTab>(defaultQueue); const [queueTab, setQueueTab] = useState<QueueTab>(() => {
const stored =
typeof window !== "undefined"
? window.localStorage.getItem(QUEUE_TAB_STORAGE_KEY)
: null;
return stored === "all" || stored === "shipments" ? stored : defaultQueue;
});
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [view, setView] = useState<ViewMode>("table"); const [view, setView] = useState<ViewMode>("table");
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const selectQueueTab = useCallback((tab: QueueTab) => {
setQueueTab(tab);
if (typeof window !== "undefined") {
window.localStorage.setItem(QUEUE_TAB_STORAGE_KEY, tab);
}
}, []);
// Contract clearance rows feed both the Contracts tab and the header KPIs, so
// they load regardless of the active tab.
const { data: allData, isLoading: allLoading, isError: allError, isFetching: allFetching, refetch: refetchAll } = const { data: allData, isLoading: allLoading, isError: allError, isFetching: allFetching, refetch: refetchAll } =
useContractClearanceQueue(queueTab === "all" || queueTab === "shipments"); useContractClearanceQueue(true);
const { data: etData, isLoading: etLoading, isError: etError, isFetching: etFetching, refetch: refetchEt } =
useEtClearanceQueue(queueTab === "et");
const { const {
data: bookingQueue, data: bookingQueue,
isLoading: bookingsLoading, isLoading: bookingsLoading,
@@ -226,18 +243,12 @@ export default function ContractClearanceListPage() {
refetch: refetchBookings, refetch: refetchBookings,
} = useBookingEtClearanceQueue(queueTab === "shipments"); } = useBookingEtClearanceQueue(queueTab === "shipments");
const data = queueTab === "et" ? etData : allData; const data = allData;
const isLoading = queueTab === "et" ? etLoading : allLoading; const isLoading = queueTab === "shipments" ? bookingsLoading : allLoading;
const isError = queueTab === "et" ? etError : allError; const isError = queueTab === "shipments" ? bookingsError : allError;
const isFetching = const isFetching = queueTab === "shipments" ? bookingsFetching : allFetching;
queueTab === "et"
? etFetching
: queueTab === "shipments"
? bookingsFetching
: allFetching;
const refetch = () => { const refetch = () => {
if (queueTab === "et") void refetchEt(); if (queueTab === "shipments") void refetchBookings();
else if (queueTab === "shipments") void refetchBookings();
else void refetchAll(); else void refetchAll();
}; };
@@ -248,19 +259,8 @@ export default function ContractClearanceListPage() {
value: "all", value: "all",
label: ( label: (
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
<ShieldCheck size={15} /> <FileText size={15} />
<Box visibleFrom="sm">All</Box> <Box visibleFrom="sm">Contracts</Box>
</Group>
),
});
}
if (canEt) {
opts.push({
value: "et",
label: (
<Group gap={6} wrap="nowrap">
<Flag size={15} />
<Box visibleFrom="sm">ET queue</Box>
</Group> </Group>
), ),
}); });
@@ -279,6 +279,17 @@ export default function ContractClearanceListPage() {
return opts; return opts;
}, [canReview, canEt]); }, [canReview, canEt]);
// If a persisted/default tab isn't available for this user, fall back to the
// first permitted tab.
useEffect(() => {
if (
queueTabOptions.length > 0 &&
!queueTabOptions.some((o) => o.value === queueTab)
) {
selectQueueTab(queueTabOptions[0].value);
}
}, [queueTabOptions, queueTab, selectQueueTab]);
// Shipment requests carry the requested quantities (per container type, or // Shipment requests carry the requested quantities (per container type, or
// bulk weight/items). Map them onto the booking rows by createdBookingId so // bulk weight/items). Map them onto the booking rows by createdBookingId so
// the queue shows what each shipment was requested for. // the queue shows what each shipment was requested for.
@@ -295,9 +306,9 @@ export default function ContractClearanceListPage() {
return map; return map;
}, [requestQueue]); }, [requestQueue]);
// GENERAL-contract shipment bookings in per-booking clearance (ET queue). // GENERAL-contract shipment bookings in per-booking clearance.
const bookingRows = useMemo(() => { const bookingRows = useMemo(() => {
const rows = (bookingQueue ?? []).map((b: BookingDetail) => ({ const rows: ShipmentBookingRow[] = (bookingQueue ?? []).map((b: BookingDetail) => ({
id: b.id, id: b.id,
reference: b.reference, reference: b.reference,
customerLabel: b.company?.name ?? b.governmentInstitution ?? "—", customerLabel: b.company?.name ?? b.governmentInstitution ?? "—",
@@ -307,6 +318,14 @@ export default function ContractClearanceListPage() {
freightType: b.freightType ?? "—", freightType: b.freightType ?? "—",
status: b.status, status: b.status,
requested: requestedByBooking.get(b.id) ?? null, requested: requestedByBooking.get(b.id) ?? null,
contractId: b.contractId ?? null,
contractReference: b.contractReference ?? null,
contractKind: b.contractKind ?? null,
customs: b.serviceType?.includesCustoms ?? Boolean(b.customsClearingEnabled),
createdAt: b.createdAt ?? null,
// A bare initiated instance has no cargo/price yet — GL still has to create
// (complete) the booking.
bookingCreated: Number(b.totalAmount ?? 0) > 0,
})); }));
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
if (!q) return rows; if (!q) return rows;
@@ -314,6 +333,7 @@ export default function ContractClearanceListPage() {
(r) => (r) =>
r.reference.toLowerCase().includes(q) || r.reference.toLowerCase().includes(q) ||
r.customerLabel.toLowerCase().includes(q) || r.customerLabel.toLowerCase().includes(q) ||
(r.contractReference ?? "").toLowerCase().includes(q) ||
r.originLabel.toLowerCase().includes(q) || r.originLabel.toLowerCase().includes(q) ||
r.destinationLabel.toLowerCase().includes(q) || r.destinationLabel.toLowerCase().includes(q) ||
summarizeRequestedCargo(r.requested).toLowerCase().includes(q), summarizeRequestedCargo(r.requested).toLowerCase().includes(q),
@@ -444,7 +464,7 @@ export default function ContractClearanceListPage() {
id: "go", id: "go",
size: 150, size: 150,
cell: ({ row }) => cell: ({ row }) =>
row.original.ready ? ( row.original.ready && canCreateBooking ? (
<Group justify="flex-end" pr="xs"> <Group justify="flex-end" pr="xs">
<Button <Button
size="compact-sm" size="compact-sm"
@@ -468,7 +488,7 @@ export default function ContractClearanceListPage() {
), ),
}, },
], ],
[navigate], [navigate, canCreateBooking],
); );
return ( return (
@@ -488,29 +508,16 @@ export default function ContractClearanceListPage() {
</Badge> </Badge>
} }
action={ action={
<Group gap="sm" wrap="nowrap"> <ActionIcon
{canCreateBooking ? ( variant="default"
<Button size="lg"
variant="filled" radius="md"
color="edr-green" onClick={() => refetch()}
radius="md" loading={isFetching}
leftSection={<Send size={15} />} aria-label="Refresh"
onClick={() => navigate("/dashboard/shipment-requests")} >
> <RefreshCw size={16} />
Shipment requests </ActionIcon>
</Button>
) : null}
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={() => refetch()}
loading={isFetching}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
} }
/> />
@@ -549,7 +556,7 @@ export default function ContractClearanceListPage() {
radius="md" radius="md"
value={queueTab} value={queueTab}
onChange={(v) => { onChange={(v) => {
setQueueTab(v as QueueTab); selectQueueTab(v as QueueTab);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}} }}
data={queueTabOptions} data={queueTabOptions}
@@ -624,7 +631,16 @@ export default function ContractClearanceListPage() {
rows={bookingRows} rows={bookingRows}
loading={bookingsLoading} loading={bookingsLoading}
error={bookingsError} error={bookingsError}
canCreateBooking={canCreateBooking}
onOpen={(id) => navigate(`/dashboard/clearance/${id}`)} onOpen={(id) => navigate(`/dashboard/clearance/${id}`)}
onCreateBooking={(row) =>
navigate(
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
)
}
onViewContract={(contractId) =>
navigate(`/dashboard/contracts/clearance/${contractId}`)
}
/> />
) : view === "table" ? ( ) : view === "table" ? (
<Box style={{ overflowX: "auto" }} px="xs" pb="xs"> <Box style={{ overflowX: "auto" }} px="xs" pb="xs">
@@ -676,8 +692,24 @@ interface ShipmentBookingRow {
status: string; status: string;
/** Requested quantities from the originating shipment request. */ /** Requested quantities from the originating shipment request. */
requested: Freight.RequestedShipmentLines | null; requested: Freight.RequestedShipmentLines | null;
/** Contract this shipment booking was created under. */
contractId: string | null;
contractReference: string | null;
contractKind: "ONE_TIME" | "GENERAL" | null;
customs: boolean;
createdAt: string | null;
/** true once GL has actually created (completed) the booking. */
bookingCreated: boolean;
} }
const formatDate = (iso: string | null) => {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, { day: "2-digit", month: "short", year: "numeric" });
};
const prettyStatus = (s: string) => const prettyStatus = (s: string) =>
s s
.toLowerCase() .toLowerCase()
@@ -696,13 +728,26 @@ function ShipmentBookingsTable({
rows, rows,
loading, loading,
error, error,
canCreateBooking,
onOpen, onOpen,
onCreateBooking,
onViewContract,
}: { }: {
rows: ShipmentBookingRow[]; rows: ShipmentBookingRow[];
loading: boolean; loading: boolean;
error: boolean; error: boolean;
canCreateBooking: boolean;
onOpen: (id: string) => void; onOpen: (id: string) => void;
onCreateBooking: (row: ShipmentBookingRow) => void;
onViewContract: (contractId: string) => void;
}) { }) {
// A bare initiated instance that has cleared but not yet been created by GL.
const isBookable = (r: ShipmentBookingRow) =>
canCreateBooking &&
Boolean(r.contractId) &&
!r.bookingCreated &&
r.status === "CLEARANCE_READY";
const columns = useMemo<ColumnDef<ShipmentBookingRow>[]>( const columns = useMemo<ColumnDef<ShipmentBookingRow>[]>(
() => [ () => [
{ {
@@ -725,6 +770,28 @@ function ShipmentBookingsTable({
</div> </div>
), ),
}, },
{
id: "contract",
header: () => <span className={bookingTable.headerCell}>Contract</span>,
cell: ({ row }) => {
const r = row.original;
return (
<Stack gap={4} py={2}>
<Group gap={6} wrap="nowrap">
<FileText size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={500} truncate maw={150}>
{r.contractReference ?? "—"}
</Text>
</Group>
{r.contractKind ? (
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
{r.contractKind === "GENERAL" ? "General" : "One-time"}
</Badge>
) : null}
</Stack>
);
},
},
{ {
id: "route", id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>, header: () => <span className={bookingTable.headerCell}>Route</span>,
@@ -751,6 +818,7 @@ function ShipmentBookingsTable({
<Badge variant="outline" color="gray" radius="sm"> <Badge variant="outline" color="gray" radius="sm">
{prettyStatus(row.original.freightType)} {prettyStatus(row.original.freightType)}
</Badge> </Badge>
<CustomsBadge customs={row.original.customs} />
</Group> </Group>
), ),
}, },
@@ -764,29 +832,110 @@ function ShipmentBookingsTable({
), ),
}, },
{ {
id: "status", id: "created",
header: () => <span className={bookingTable.headerCell}>Status</span>, header: () => <span className={bookingTable.headerCell}>Created</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<Badge <Group gap={6} wrap="nowrap">
variant="light" <Calendar size={13} className="shrink-0 text-muted-foreground" />
color={shipmentStatusColor(row.original.status)} <Text size="sm" c="dimmed">
radius="sm" {formatDate(row.original.createdAt)}
> </Text>
{prettyStatus(row.original.status)}
</Badge>
),
},
{
id: "chevron",
header: "",
cell: () => (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />
</Group> </Group>
), ),
}, },
{
id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Badge
variant="light"
color={shipmentStatusColor(row.original.status)}
radius="sm"
>
{prettyStatus(row.original.status)}
</Badge>
{row.original.bookingCreated ? (
<Tooltip label="Booking created by GL Ethiopia" withArrow>
<Badge
variant="light"
color="blue"
radius="sm"
leftSection={<PackagePlus size={11} />}
>
Booked
</Badge>
</Tooltip>
) : null}
</Group>
),
},
{
id: "actions",
header: "",
size: 200,
cell: ({ row }) => {
const r = row.original;
const bookable = isBookable(r);
return (
<Group
justify="flex-end"
gap={6}
pr="xs"
wrap="nowrap"
onClick={(e) => e.stopPropagation()}
>
{bookable ? (
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<PackagePlus size={14} />}
onClick={() => onCreateBooking(r)}
>
Create booking
</Button>
) : null}
<Menu shadow="md" radius="md" position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
radius="md"
aria-label="Row actions"
>
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<Eye size={14} />} onClick={() => onOpen(r.id)}>
Open booking
</Menu.Item>
{bookable ? (
<Menu.Item
leftSection={<PackagePlus size={14} />}
onClick={() => onCreateBooking(r)}
>
Create booking
</Menu.Item>
) : null}
{r.contractId ? (
<Menu.Item
leftSection={<ExternalLink size={14} />}
onClick={() => onViewContract(r.contractId!)}
>
View contract
</Menu.Item>
) : null}
</Menu.Dropdown>
</Menu>
</Group>
);
},
},
], ],
[], // eslint-disable-next-line react-hooks/exhaustive-deps
[canCreateBooking, onOpen, onCreateBooking, onViewContract],
); );
if (!loading && !error && rows.length === 0) { if (!loading && !error && rows.length === 0) {