mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
wagon work space, container validation
This commit is contained in:
@@ -42,7 +42,7 @@ import { contractsService } from "@/services/contracts.service";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import { useBookingDetail } from "@/hooks/bookings/useBookings";
|
||||
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";
|
||||
|
||||
export default function DocumentClearanceDetailPage() {
|
||||
@@ -101,12 +101,14 @@ export default function DocumentClearanceDetailPage() {
|
||||
|
||||
// Bare initiated instance whose clearance is done: GL completes the booking
|
||||
// (container numbers, VGM, shipment day) via the completion form.
|
||||
// Creating the booking is a GL Ethiopia action — never available to Djibouti GL.
|
||||
const canCompleteBooking =
|
||||
booking?.status === "CLEARANCE_READY" &&
|
||||
Boolean(booking?.contractId) &&
|
||||
Boolean(booking?.customsClearingEnabled) &&
|
||||
!(Number(booking?.totalAmount ?? 0) > 0) &&
|
||||
hasPermission(user, FREIGHT_PERMS.contracts.createBooking);
|
||||
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
|
||||
!isDjiboutiGl(user);
|
||||
|
||||
const docsPhaseComplete =
|
||||
clearance?.milestones?.some(
|
||||
@@ -190,7 +192,7 @@ export default function DocumentClearanceDetailPage() {
|
||||
)
|
||||
}
|
||||
>
|
||||
Complete booking
|
||||
Create booking
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
ActionIcon,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Menu,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -16,16 +17,18 @@ import {
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
FileText,
|
||||
Flag,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
MoreHorizontal,
|
||||
PackageCheck,
|
||||
PackagePlus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
ShipWheel,
|
||||
Table as TableIcon,
|
||||
@@ -46,13 +49,10 @@ import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import {
|
||||
useContractClearanceQueue,
|
||||
useEtClearanceQueue,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { useContractClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||
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 {
|
||||
RequestedCargoChips,
|
||||
@@ -62,7 +62,10 @@ import { contractsService } from "@/services/contracts.service";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
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 {
|
||||
id: string;
|
||||
@@ -203,21 +206,35 @@ export default function ContractClearanceListPage() {
|
||||
const { user } = useAuth();
|
||||
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
||||
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
|
||||
const canCreateBooking = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.contracts.createBooking,
|
||||
);
|
||||
// Creating a booking under a cleared contract is a GL Ethiopia action — never
|
||||
// available to Djibouti GL.
|
||||
const canCreateBooking =
|
||||
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
|
||||
!isDjiboutiGl(user);
|
||||
|
||||
const defaultQueue: QueueTab = canReview ? "all" : "et";
|
||||
const [queueTab, setQueueTab] = useState<QueueTab>(defaultQueue);
|
||||
const defaultQueue: QueueTab = canReview ? "all" : "shipments";
|
||||
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 [view, setView] = useState<ViewMode>("table");
|
||||
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 } =
|
||||
useContractClearanceQueue(queueTab === "all" || queueTab === "shipments");
|
||||
const { data: etData, isLoading: etLoading, isError: etError, isFetching: etFetching, refetch: refetchEt } =
|
||||
useEtClearanceQueue(queueTab === "et");
|
||||
useContractClearanceQueue(true);
|
||||
const {
|
||||
data: bookingQueue,
|
||||
isLoading: bookingsLoading,
|
||||
@@ -226,18 +243,12 @@ export default function ContractClearanceListPage() {
|
||||
refetch: refetchBookings,
|
||||
} = useBookingEtClearanceQueue(queueTab === "shipments");
|
||||
|
||||
const data = queueTab === "et" ? etData : allData;
|
||||
const isLoading = queueTab === "et" ? etLoading : allLoading;
|
||||
const isError = queueTab === "et" ? etError : allError;
|
||||
const isFetching =
|
||||
queueTab === "et"
|
||||
? etFetching
|
||||
: queueTab === "shipments"
|
||||
? bookingsFetching
|
||||
: allFetching;
|
||||
const data = allData;
|
||||
const isLoading = queueTab === "shipments" ? bookingsLoading : allLoading;
|
||||
const isError = queueTab === "shipments" ? bookingsError : allError;
|
||||
const isFetching = queueTab === "shipments" ? bookingsFetching : allFetching;
|
||||
const refetch = () => {
|
||||
if (queueTab === "et") void refetchEt();
|
||||
else if (queueTab === "shipments") void refetchBookings();
|
||||
if (queueTab === "shipments") void refetchBookings();
|
||||
else void refetchAll();
|
||||
};
|
||||
|
||||
@@ -248,19 +259,8 @@ export default function ContractClearanceListPage() {
|
||||
value: "all",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ShieldCheck size={15} />
|
||||
<Box visibleFrom="sm">All</Box>
|
||||
</Group>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (canEt) {
|
||||
opts.push({
|
||||
value: "et",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Flag size={15} />
|
||||
<Box visibleFrom="sm">ET queue</Box>
|
||||
<FileText size={15} />
|
||||
<Box visibleFrom="sm">Contracts</Box>
|
||||
</Group>
|
||||
),
|
||||
});
|
||||
@@ -279,6 +279,17 @@ export default function ContractClearanceListPage() {
|
||||
return opts;
|
||||
}, [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
|
||||
// bulk weight/items). Map them onto the booking rows by createdBookingId so
|
||||
// the queue shows what each shipment was requested for.
|
||||
@@ -295,9 +306,9 @@ export default function ContractClearanceListPage() {
|
||||
return map;
|
||||
}, [requestQueue]);
|
||||
|
||||
// GENERAL-contract shipment bookings in per-booking clearance (ET queue).
|
||||
// GENERAL-contract shipment bookings in per-booking clearance.
|
||||
const bookingRows = useMemo(() => {
|
||||
const rows = (bookingQueue ?? []).map((b: BookingDetail) => ({
|
||||
const rows: ShipmentBookingRow[] = (bookingQueue ?? []).map((b: BookingDetail) => ({
|
||||
id: b.id,
|
||||
reference: b.reference,
|
||||
customerLabel: b.company?.name ?? b.governmentInstitution ?? "—",
|
||||
@@ -307,6 +318,14 @@ export default function ContractClearanceListPage() {
|
||||
freightType: b.freightType ?? "—",
|
||||
status: b.status,
|
||||
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();
|
||||
if (!q) return rows;
|
||||
@@ -314,6 +333,7 @@ export default function ContractClearanceListPage() {
|
||||
(r) =>
|
||||
r.reference.toLowerCase().includes(q) ||
|
||||
r.customerLabel.toLowerCase().includes(q) ||
|
||||
(r.contractReference ?? "").toLowerCase().includes(q) ||
|
||||
r.originLabel.toLowerCase().includes(q) ||
|
||||
r.destinationLabel.toLowerCase().includes(q) ||
|
||||
summarizeRequestedCargo(r.requested).toLowerCase().includes(q),
|
||||
@@ -444,7 +464,7 @@ export default function ContractClearanceListPage() {
|
||||
id: "go",
|
||||
size: 150,
|
||||
cell: ({ row }) =>
|
||||
row.original.ready ? (
|
||||
row.original.ready && canCreateBooking ? (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
@@ -468,7 +488,7 @@ export default function ContractClearanceListPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[navigate],
|
||||
[navigate, canCreateBooking],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -488,29 +508,16 @@ export default function ContractClearanceListPage() {
|
||||
</Badge>
|
||||
}
|
||||
action={
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{canCreateBooking ? (
|
||||
<Button
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Send size={15} />}
|
||||
onClick={() => navigate("/dashboard/shipment-requests")}
|
||||
>
|
||||
Shipment requests
|
||||
</Button>
|
||||
) : null}
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
onClick={() => refetch()}
|
||||
loading={isFetching}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
onClick={() => refetch()}
|
||||
loading={isFetching}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -549,7 +556,7 @@ export default function ContractClearanceListPage() {
|
||||
radius="md"
|
||||
value={queueTab}
|
||||
onChange={(v) => {
|
||||
setQueueTab(v as QueueTab);
|
||||
selectQueueTab(v as QueueTab);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
data={queueTabOptions}
|
||||
@@ -624,7 +631,16 @@ export default function ContractClearanceListPage() {
|
||||
rows={bookingRows}
|
||||
loading={bookingsLoading}
|
||||
error={bookingsError}
|
||||
canCreateBooking={canCreateBooking}
|
||||
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" ? (
|
||||
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
||||
@@ -676,8 +692,24 @@ interface ShipmentBookingRow {
|
||||
status: string;
|
||||
/** Requested quantities from the originating shipment request. */
|
||||
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) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
@@ -696,13 +728,26 @@ function ShipmentBookingsTable({
|
||||
rows,
|
||||
loading,
|
||||
error,
|
||||
canCreateBooking,
|
||||
onOpen,
|
||||
onCreateBooking,
|
||||
onViewContract,
|
||||
}: {
|
||||
rows: ShipmentBookingRow[];
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
canCreateBooking: boolean;
|
||||
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>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -725,6 +770,28 @@ function ShipmentBookingsTable({
|
||||
</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",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
@@ -751,6 +818,7 @@ function ShipmentBookingsTable({
|
||||
<Badge variant="outline" color="gray" radius="sm">
|
||||
{prettyStatus(row.original.freightType)}
|
||||
</Badge>
|
||||
<CustomsBadge customs={row.original.customs} />
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
@@ -764,29 +832,110 @@ function ShipmentBookingsTable({
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
id: "created",
|
||||
header: () => <span className={bookingTable.headerCell}>Created</span>,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={shipmentStatusColor(row.original.status)}
|
||||
radius="sm"
|
||||
>
|
||||
{prettyStatus(row.original.status)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "chevron",
|
||||
header: "",
|
||||
cell: () => (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<ChevronRight size={16} className="text-muted-foreground" />
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Calendar size={13} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatDate(row.original.createdAt)}
|
||||
</Text>
|
||||
</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) {
|
||||
|
||||
Reference in New Issue
Block a user