diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx index c9a0ecdb8..bb271e205 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx @@ -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 ) : undefined } diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx index 453be05c2..142173298 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx @@ -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(defaultQueue); + const defaultQueue: QueueTab = canReview ? "all" : "shipments"; + const [queueTab, setQueueTab] = useState(() => { + 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("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: ( - - All - - ), - }); - } - if (canEt) { - opts.push({ - value: "et", - label: ( - - - ET queue + + Contracts ), }); @@ -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 ? ( - ) : null} - refetch()} - loading={isFetching} - aria-label="Refresh" - > - - - + refetch()} + loading={isFetching} + aria-label="Refresh" + > + + } /> @@ -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" ? ( @@ -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[]>( () => [ { @@ -725,6 +770,28 @@ function ShipmentBookingsTable({ ), }, + { + id: "contract", + header: () => Contract, + cell: ({ row }) => { + const r = row.original; + return ( + + + + + {r.contractReference ?? "—"} + + + {r.contractKind ? ( + + {r.contractKind === "GENERAL" ? "General" : "One-time"} + + ) : null} + + ); + }, + }, { id: "route", header: () => Route, @@ -751,6 +818,7 @@ function ShipmentBookingsTable({ {prettyStatus(row.original.freightType)} + ), }, @@ -764,29 +832,110 @@ function ShipmentBookingsTable({ ), }, { - id: "status", - header: () => Status, + id: "created", + header: () => Created, cell: ({ row }) => ( - - {prettyStatus(row.original.status)} - - ), - }, - { - id: "chevron", - header: "", - cell: () => ( - - + + + + {formatDate(row.original.createdAt)} + ), }, + { + id: "status", + header: () => Status, + cell: ({ row }) => ( + + + {prettyStatus(row.original.status)} + + {row.original.bookingCreated ? ( + + } + > + Booked + + + ) : null} + + ), + }, + { + id: "actions", + header: "", + size: 200, + cell: ({ row }) => { + const r = row.original; + const bookable = isBookable(r); + return ( + e.stopPropagation()} + > + {bookable ? ( + + ) : null} + + + + + + + + } onClick={() => onOpen(r.id)}> + Open booking + + {bookable ? ( + } + onClick={() => onCreateBooking(r)} + > + Create booking + + ) : null} + {r.contractId ? ( + } + onClick={() => onViewContract(r.contractId!)} + > + View contract + + ) : null} + + + + ); + }, + }, ], - [], + // eslint-disable-next-line react-hooks/exhaustive-deps + [canCreateBooking, onOpen, onCreateBooking, onViewContract], ); if (!loading && !error && rows.length === 0) {