mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 15:25:45 +00:00
Merge pull request #982 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -95,10 +95,10 @@ export default function DocumentClearanceDetailPage() {
|
||||
}, [clearance]);
|
||||
|
||||
const reference = booking?.reference ?? "Clearance";
|
||||
// Phased customs clearance runs on every contract booking now — ONE_TIME and
|
||||
// GENERAL alike; the persisted phase is what marks the workflow as running.
|
||||
const isPhasedGeneral =
|
||||
Boolean(booking?.customsClearingEnabled) &&
|
||||
booking?.contractKind === "GENERAL" &&
|
||||
Boolean(clearance?.phase);
|
||||
Boolean(booking?.customsClearingEnabled) && Boolean(clearance?.phase);
|
||||
|
||||
// Bare initiated instance whose clearance is done: GL completes the booking
|
||||
// (container numbers, VGM, shipment day) via the completion form.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { directionLabel } from "@/lib/utils";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
@@ -6,36 +5,20 @@ import {
|
||||
Group,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
FileText,
|
||||
Inbox,
|
||||
RefreshCw,
|
||||
Repeat,
|
||||
Search,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { FileText, Inbox, RefreshCw, Search, User, X } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import {
|
||||
toContractListRow,
|
||||
type ContractListRow,
|
||||
} from "@/features/contracts/mapContractListRow";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import {
|
||||
Badge,
|
||||
@@ -46,45 +29,16 @@ import {
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Operations "Clearance Documents" hub — worklist for clearance-document
|
||||
* review on contracts WITHOUT customs clearing (self-clearance):
|
||||
* Contracts tab = contract-level review (one-time flow), General tab =
|
||||
* per-booking review under GENERAL non-customs contracts. Rows deep-link to
|
||||
* the existing review detail pages; search / status filter / pagination are
|
||||
* all server-side.
|
||||
* Operations "Clearance Documents" hub — the worklist for self-clearance
|
||||
* (non-customs) document review. Clearance is always per SHIPMENT: the customer
|
||||
* uploads his documents on the booking he initiated, whatever kind of contract
|
||||
* it draws on, so this hub lists bookings only. Rows deep-link to the booking
|
||||
* clearance review page; search / status filter / pagination are server-side.
|
||||
*/
|
||||
|
||||
type HubTab = "contracts" | "general";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
/** Status filter options for the Contracts tab (values = `statuses` param). */
|
||||
const CONTRACT_STATUS_OPTIONS = [
|
||||
{
|
||||
value: [
|
||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||
"CLEARANCE_UNDER_REVIEW",
|
||||
"CLEARANCE_READY_FOR_BOOKING",
|
||||
"FULLY_EXECUTED",
|
||||
"CONTRACT_ACTIVE",
|
||||
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||
"CONTRACT_CLOSED",
|
||||
"CANCELLED",
|
||||
].join(","),
|
||||
label: "All statuses",
|
||||
},
|
||||
{ value: "AWAITING_CLEARANCE_DOCUMENTS", label: "Awaiting documents" },
|
||||
{ value: "CLEARANCE_UNDER_REVIEW", label: "Under review" },
|
||||
{ value: "CLEARANCE_READY_FOR_BOOKING", label: "Ready for booking" },
|
||||
{ value: "FULLY_EXECUTED,CONTRACT_ACTIVE", label: "Finalized" },
|
||||
{
|
||||
value: "ACTIVE_SHIPMENT_IN_PROGRESS,CONTRACT_CLOSED",
|
||||
label: "In progress / closed",
|
||||
},
|
||||
{ value: "CANCELLED", label: "Cancelled" },
|
||||
];
|
||||
|
||||
/** Status filter options for the General (per-booking) tab. */
|
||||
/** Status filter options (values = `statuses` param). */
|
||||
const BOOKING_STATUS_OPTIONS = [
|
||||
{
|
||||
value: "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY",
|
||||
@@ -97,12 +51,8 @@ const BOOKING_STATUS_OPTIONS = [
|
||||
|
||||
export default function ClearanceDocumentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [hubTab, setHubTab] = useState<HubTab>("contracts");
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
const [contractStatuses, setContractStatuses] = useState(
|
||||
CONTRACT_STATUS_OPTIONS[0].value,
|
||||
);
|
||||
const [bookingStatuses, setBookingStatuses] = useState(
|
||||
BOOKING_STATUS_OPTIONS[0].value,
|
||||
);
|
||||
@@ -116,34 +66,12 @@ export default function ClearanceDocumentsPage() {
|
||||
|
||||
const page = pagination.pageIndex + 1;
|
||||
|
||||
const contractsQuery = useQuery({
|
||||
queryKey: [
|
||||
"clearance-documents",
|
||||
"contracts",
|
||||
contractStatuses,
|
||||
page,
|
||||
search,
|
||||
],
|
||||
const bookingsQuery = useQuery({
|
||||
queryKey: ["clearance-documents", "bookings", bookingStatuses, page, search],
|
||||
queryFn: () =>
|
||||
contractsService.getOpsClearanceQueue({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
statuses: contractStatuses,
|
||||
search,
|
||||
}),
|
||||
enabled: hubTab === "contracts",
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const generalQuery = useQuery({
|
||||
queryKey: ["clearance-documents", "general", bookingStatuses, page, search],
|
||||
queryFn: () =>
|
||||
// Per-booking self-clearance instances are drawdowns under GENERAL
|
||||
// non-customs contracts: they carry bookingType=ONE_TIME (each shipment
|
||||
// is one-time) with contractKind=GENERAL, so filtering on
|
||||
// bookingType=GENERAL_CONTRACT returned nothing. customsClearingEnabled
|
||||
// =false + the three per-booking clearance statuses already isolate
|
||||
// exactly this worklist — the same set the old booking-request tab showed.
|
||||
// Self-clearance instances carry bookingType=ONE_TIME whatever their
|
||||
// contract kind, so customsClearingEnabled=false + the three per-booking
|
||||
// clearance statuses are what isolate exactly this worklist.
|
||||
bookingsService.list({
|
||||
statuses: bookingStatuses,
|
||||
customsClearingEnabled: "false",
|
||||
@@ -151,112 +79,10 @@ export default function ClearanceDocumentsPage() {
|
||||
pageSize: PAGE_SIZE,
|
||||
search,
|
||||
}),
|
||||
enabled: hubTab === "general",
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const contractRows = useMemo(
|
||||
() => (contractsQuery.data?.items ?? []).map(toContractListRow),
|
||||
[contractsQuery.data?.items],
|
||||
);
|
||||
const bookingRows = generalQuery.data?.items ?? [];
|
||||
|
||||
const contractColumns: ColumnDef<ContractListRow>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "contract",
|
||||
header: () => <span className={bookingTable.headerCell}>Customer</span>,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<User className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{c.customerLabel}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<FileText className="size-3 shrink-0 opacity-70" />
|
||||
{c.reference}
|
||||
</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"
|
||||
>
|
||||
{directionLabel(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: "kind",
|
||||
header: () => <span className={bookingTable.headerCell}>Kind</span>,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase"
|
||||
>
|
||||
{row.original.contractKind === "GENERAL" ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Repeat className="size-3" /> General
|
||||
</span>
|
||||
) : (
|
||||
"One-time"
|
||||
)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
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]",
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
const bookingRows = bookingsQuery.data?.items ?? [];
|
||||
|
||||
const bookingColumns: ColumnDef<BookingDetail>[] = useMemo(
|
||||
() => [
|
||||
@@ -335,39 +161,29 @@ export default function ClearanceDocumentsPage() {
|
||||
[],
|
||||
);
|
||||
|
||||
const isContracts = hubTab === "contracts";
|
||||
const activeQuery = isContracts ? contractsQuery : generalQuery;
|
||||
const total = activeQuery.data?.total ?? 0;
|
||||
const total = bookingsQuery.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const showEmpty =
|
||||
!activeQuery.isLoading &&
|
||||
!activeQuery.isError &&
|
||||
(isContracts ? contractRows.length : bookingRows.length) === 0;
|
||||
const tableStatus = activeQuery.isLoading
|
||||
!bookingsQuery.isLoading && !bookingsQuery.isError && bookingRows.length === 0;
|
||||
const tableStatus = bookingsQuery.isLoading
|
||||
? "loading"
|
||||
: activeQuery.isError
|
||||
: bookingsQuery.isError
|
||||
? "error"
|
||||
: "success";
|
||||
|
||||
const statusOptions = isContracts
|
||||
? CONTRACT_STATUS_OPTIONS
|
||||
: BOOKING_STATUS_OPTIONS;
|
||||
const statusValue = isContracts ? contractStatuses : bookingStatuses;
|
||||
const setStatusValue = isContracts ? setContractStatuses : setBookingStatuses;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Clearance Documents"
|
||||
subtitle="Operations review of customer clearance documents for contracts without customs clearing."
|
||||
subtitle="Operations review of the clearance documents customers upload on their shipments (services without customs clearing)."
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
loading={activeQuery.isFetching}
|
||||
onClick={() => void activeQuery.refetch()}
|
||||
loading={bookingsQuery.isFetching}
|
||||
onClick={() => void bookingsQuery.refetch()}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
@@ -375,29 +191,12 @@ export default function ClearanceDocumentsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs
|
||||
value={hubTab}
|
||||
onChange={(v) => {
|
||||
setHubTab((v as HubTab) ?? "contracts");
|
||||
resetPage();
|
||||
}}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="contracts">Contracts</Tabs.Tab>
|
||||
<Tabs.Tab value="general">General</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
<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={
|
||||
isContracts
|
||||
? "Search reference or customer…"
|
||||
: "Search booking, contract or customer…"
|
||||
}
|
||||
placeholder="Search booking, contract or customer…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
@@ -424,10 +223,10 @@ export default function ClearanceDocumentsPage() {
|
||||
radius="lg"
|
||||
/>
|
||||
<Select
|
||||
data={statusOptions}
|
||||
value={statusValue}
|
||||
data={BOOKING_STATUS_OPTIONS}
|
||||
value={bookingStatuses}
|
||||
onChange={(v) => {
|
||||
setStatusValue(v ?? statusOptions[0].value);
|
||||
setBookingStatuses(v ?? BOOKING_STATUS_OPTIONS[0].value);
|
||||
resetPage();
|
||||
}}
|
||||
allowDeselect={false}
|
||||
@@ -446,61 +245,30 @@ export default function ClearanceDocumentsPage() {
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||
<Inbox size={22} />
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed">
|
||||
No {isContracts ? "contracts" : "bookings"} match this view.
|
||||
</Text>
|
||||
<Text c="dimmed">No shipments match this view.</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
{isContracts ? (
|
||||
<DataTable
|
||||
columns={contractColumns}
|
||||
data={contractRows}
|
||||
status={tableStatus}
|
||||
onRowClick={(row) =>
|
||||
navigate(
|
||||
`/dashboard/contracts/clearance-documents/${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}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={bookingColumns}
|
||||
data={bookingRows}
|
||||
status={tableStatus}
|
||||
onRowClick={(row) =>
|
||||
navigate(`/dashboard/clearance/${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}
|
||||
/>
|
||||
)}
|
||||
<DataTable
|
||||
columns={bookingColumns}
|
||||
data={bookingRows}
|
||||
status={tableStatus}
|
||||
onRowClick={(row) => navigate(`/dashboard/clearance/${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>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -21,14 +20,11 @@ import {
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
ChevronRight,
|
||||
FileSignature,
|
||||
FileText,
|
||||
Inbox,
|
||||
PackageCheck,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Ship,
|
||||
ShipWheel,
|
||||
Truck,
|
||||
User,
|
||||
@@ -41,18 +37,14 @@ import {
|
||||
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 { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
|
||||
type QueueTab = "contracts" | "shipments";
|
||||
|
||||
const prettyStatus = (s?: string | null) =>
|
||||
(s ?? "")
|
||||
.toLowerCase()
|
||||
@@ -198,52 +190,6 @@ function toShipmentRow(b: BookingDetail): ShipmentRow {
|
||||
};
|
||||
}
|
||||
|
||||
interface ContractRow {
|
||||
id: string;
|
||||
reference: string;
|
||||
customerLabel: string;
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
tradeDirection: string;
|
||||
freightType: string;
|
||||
serviceTypeName: string;
|
||||
customs: boolean;
|
||||
status: string;
|
||||
clearanceStatus: string;
|
||||
phase: string | null;
|
||||
cycleNumber: number;
|
||||
validFrom: string | null;
|
||||
validUntil: string | null;
|
||||
validityDays: number | null;
|
||||
estimatedShipmentDate: string | null;
|
||||
}
|
||||
|
||||
function toContractRow(c: Freight.IContract): ContractRow {
|
||||
const routes = [...(c.routes ?? [])].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
const first = routes[0];
|
||||
const last = routes[routes.length - 1] ?? first;
|
||||
return {
|
||||
id: c.id,
|
||||
reference: c.reference,
|
||||
customerLabel: c.isGovernment
|
||||
? (c.governmentInstitution ?? "Government")
|
||||
: (c.company?.name ?? "—"),
|
||||
originLabel: yardLabel(first?.originYard),
|
||||
destinationLabel: yardLabel(last?.destinationYard),
|
||||
tradeDirection: c.tradeDirection ?? "—",
|
||||
freightType: c.freightType ?? "—",
|
||||
serviceTypeName: c.serviceType?.serviceName ?? "—",
|
||||
customs: c.serviceType?.includesCustoms ?? Boolean(c.customsClearingEnabled),
|
||||
status: c.status,
|
||||
clearanceStatus: c.clearanceStatus,
|
||||
phase: (c.clearancePhase as string | null) ?? null,
|
||||
cycleNumber: c.clearanceCycleNumber ?? 1,
|
||||
validFrom: c.contractValidFrom ?? null,
|
||||
validUntil: c.contractValidUntil ?? null,
|
||||
validityDays: c.contractValidityDays ?? null,
|
||||
estimatedShipmentDate: c.estimatedShipmentDate ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Shared cell pieces ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -301,15 +247,13 @@ function RouteCell({
|
||||
// ── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GL Djibouti clearance queues:
|
||||
* - Contracts: ONE_TIME customs contracts in phased clearance (legacy flow).
|
||||
* - Shipments: GENERAL-contract bookings in per-booking clearance awaiting a DJ
|
||||
* action (DO collection after ET finalizes pre-clearance, RO for exports,
|
||||
* loading milestones). Managed like the one-time flow, but per booking.
|
||||
* GL Djibouti clearance queue. Clearance runs per SHIPMENT — every booking in
|
||||
* per-booking clearance awaiting a Djibouti action (DO collection after ET
|
||||
* finalizes pre-clearance, RO for exports, loading milestones), whatever kind
|
||||
* of contract it draws on.
|
||||
*/
|
||||
export default function GlDjiboutiClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState<QueueTab>("shipments");
|
||||
const [query, setQuery] = useState("");
|
||||
const [direction, setDirection] = useState<string | null>(null);
|
||||
const [freight, setFreight] = useState<string | null>(null);
|
||||
@@ -317,13 +261,6 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
const [action, setAction] = useState<string | null>(null);
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
const {
|
||||
data: contractQueue,
|
||||
isLoading: contractsLoading,
|
||||
isError: contractsError,
|
||||
isFetching: contractsFetching,
|
||||
refetch: refetchContracts,
|
||||
} = useDjClearanceQueue();
|
||||
const {
|
||||
data: bookingQueue,
|
||||
isLoading: bookingsLoading,
|
||||
@@ -341,34 +278,26 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
() => (bookingQueue ?? []).map(toShipmentRow),
|
||||
[bookingQueue],
|
||||
);
|
||||
const allContractRows = useMemo(
|
||||
() => (contractQueue?.items ?? []).map(toContractRow),
|
||||
[contractQueue?.items],
|
||||
);
|
||||
|
||||
// KPI metrics span both queues, regardless of active tab or filters.
|
||||
// KPI metrics span the whole queue, regardless of filters.
|
||||
const metrics = useMemo(
|
||||
() => ({
|
||||
shipments: allShipmentRows.length,
|
||||
contracts: allContractRows.length,
|
||||
collectDo: allShipmentRows.filter((r) => r.action.key === "COLLECT_DO")
|
||||
.length,
|
||||
issueRo: allShipmentRows.filter((r) => r.action.key === "ISSUE_RO").length,
|
||||
roHolds: allShipmentRows.filter((r) => r.action.key === "RO_HOLD").length,
|
||||
}),
|
||||
[allShipmentRows, allContractRows],
|
||||
[allShipmentRows],
|
||||
);
|
||||
|
||||
const statusOptions = useMemo(() => {
|
||||
const source =
|
||||
tab === "shipments"
|
||||
? allShipmentRows.map((r) => r.status)
|
||||
: allContractRows.map((r) => r.status);
|
||||
return [...new Set(source)].sort().map((s) => ({
|
||||
value: s,
|
||||
label: prettyStatus(s),
|
||||
}));
|
||||
}, [tab, allShipmentRows, allContractRows]);
|
||||
const statusOptions = useMemo(
|
||||
() =>
|
||||
[...new Set(allShipmentRows.map((r) => r.status))].sort().map((s) => ({
|
||||
value: s,
|
||||
label: prettyStatus(s),
|
||||
})),
|
||||
[allShipmentRows],
|
||||
);
|
||||
|
||||
const matchesShared = useCallback(
|
||||
(
|
||||
@@ -411,16 +340,10 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
[allShipmentRows, action, matchesShared],
|
||||
);
|
||||
|
||||
const contractRows = useMemo(
|
||||
() => allContractRows.filter((r) => matchesShared(r)),
|
||||
[allContractRows, matchesShared],
|
||||
);
|
||||
|
||||
const rows = tab === "shipments" ? shipmentRows : contractRows;
|
||||
const isLoading = tab === "contracts" ? contractsLoading : bookingsLoading;
|
||||
const isError = tab === "contracts" ? contractsError : bookingsError;
|
||||
const isFetching = contractsFetching || bookingsFetching;
|
||||
const total = rows.length;
|
||||
const isLoading = bookingsLoading;
|
||||
const isError = bookingsError;
|
||||
const isFetching = bookingsFetching;
|
||||
const total = shipmentRows.length;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const showEmpty = !isLoading && !isError && total === 0;
|
||||
|
||||
@@ -429,11 +352,6 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
return shipmentRows.slice(start, start + pagination.pageSize);
|
||||
}, [shipmentRows, pagination.pageIndex, pagination.pageSize]);
|
||||
|
||||
const pagedContractRows = useMemo(() => {
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
return contractRows.slice(start, start + pagination.pageSize);
|
||||
}, [contractRows, pagination.pageIndex, pagination.pageSize]);
|
||||
|
||||
const hasFilters = Boolean(query || direction || freight || status || action);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
@@ -446,9 +364,8 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
}, [resetPage]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refetchContracts();
|
||||
void refetchBookings();
|
||||
}, [refetchContracts, refetchBookings]);
|
||||
}, [refetchBookings]);
|
||||
|
||||
const openDetail = useCallback(
|
||||
(id: string) => navigate(`/dashboard/gl-djibouti/clearance/${id}`),
|
||||
@@ -599,161 +516,12 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
[],
|
||||
);
|
||||
|
||||
const contractColumns: ColumnDef<ContractRow>[] = 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}>
|
||||
<Ship 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 (
|
||||
<RouteCell
|
||||
origin={r.originLabel}
|
||||
destination={r.destinationLabel}
|
||||
direction={r.tradeDirection}
|
||||
freightType={r.freightType}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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>
|
||||
{r.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>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clearance",
|
||||
header: () => <span className={bookingTable.headerCell}>Clearance</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Stack gap={4} py={2}>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={statusColor(r.clearanceStatus)}
|
||||
radius="sm"
|
||||
>
|
||||
{prettyStatus(r.clearanceStatus)}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{phaseLabel(r.phase)}
|
||||
{r.cycleNumber > 1 ? ` · Cycle ${r.cycleNumber}` : ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={statusColor(row.original.status)}
|
||||
radius="sm"
|
||||
>
|
||||
{prettyStatus(row.original.status)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "validity",
|
||||
header: () => <span className={bookingTable.headerCell}>Validity</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Stack gap={2} py={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<CalendarClock
|
||||
size={13}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{r.validUntil
|
||||
? `Until ${formatDate(r.validUntil)}`
|
||||
: r.validityDays
|
||||
? `${r.validityDays} days`
|
||||
: "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
{r.estimatedShipmentDate ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Est. shipment {formatDate(r.estimatedShipmentDate)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "chevron",
|
||||
size: 40,
|
||||
header: "",
|
||||
cell: () => (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<ChevronRight size={16} className="text-muted-foreground" />
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="GL Djibouti — Clearance"
|
||||
subtitle="Customs contracts and shipment bookings handed off to Djibouti GL."
|
||||
subtitle="Customs shipments handed off to Djibouti GL — every clearance step lives on the shipment."
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
@@ -769,7 +537,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
/>
|
||||
|
||||
<KpiStrip
|
||||
loading={contractsLoading || bookingsLoading}
|
||||
loading={bookingsLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Shipments in queue",
|
||||
@@ -777,12 +545,6 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
icon: PackageCheck,
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
label: "Contracts in queue",
|
||||
value: metrics.contracts,
|
||||
icon: FileSignature,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Imports — collect DO",
|
||||
value: metrics.collectDo,
|
||||
@@ -806,46 +568,6 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
|
||||
<Card p={0} withBorder shadow="sm" radius="lg">
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md">
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
value={tab}
|
||||
onChange={(v) => {
|
||||
setTab(v as QueueTab);
|
||||
setStatus(null);
|
||||
setAction(null);
|
||||
resetPage();
|
||||
}}
|
||||
radius="md"
|
||||
data={[
|
||||
{
|
||||
value: "shipments",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<PackageCheck size={15} />
|
||||
<Box visibleFrom="sm">Shipments</Box>
|
||||
<Badge size="sm" radius="sm" variant="light" color="edr-green">
|
||||
{allShipmentRows.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "contracts",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FileSignature size={15} />
|
||||
<Box visibleFrom="sm">Contracts</Box>
|
||||
<Badge size="sm" radius="sm" variant="light" color="gray">
|
||||
{allContractRows.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box px="md" pt="md" pb="sm">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
@@ -917,20 +639,18 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
radius="lg"
|
||||
w={190}
|
||||
/>
|
||||
{tab === "shipments" ? (
|
||||
<Select
|
||||
placeholder="DJ action"
|
||||
data={DJ_ACTION_OPTIONS}
|
||||
value={action}
|
||||
onChange={(v) => {
|
||||
setAction(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
w={180}
|
||||
/>
|
||||
) : null}
|
||||
<Select
|
||||
placeholder="DJ action"
|
||||
data={DJ_ACTION_OPTIONS}
|
||||
value={action}
|
||||
onChange={(v) => {
|
||||
setAction(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
w={180}
|
||||
/>
|
||||
{hasFilters ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
@@ -957,9 +677,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
<Text c="dimmed">
|
||||
{hasFilters
|
||||
? "No records match these filters."
|
||||
: tab === "contracts"
|
||||
? "No Djibouti customs contracts yet."
|
||||
: "No shipment bookings awaiting a Djibouti action."}
|
||||
: "No shipments awaiting a Djibouti action."}
|
||||
</Text>
|
||||
{hasFilters ? (
|
||||
<Button
|
||||
@@ -975,49 +693,26 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
</Stack>
|
||||
) : (
|
||||
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
||||
{tab === "shipments" ? (
|
||||
<DataTable<ShipmentRow, unknown>
|
||||
columns={shipmentColumns}
|
||||
data={pagedShipmentRows}
|
||||
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}
|
||||
/>
|
||||
) : (
|
||||
<DataTable<ContractRow, unknown>
|
||||
columns={contractColumns}
|
||||
data={pagedContractRows}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
<DataTable<ShipmentRow, unknown>
|
||||
columns={shipmentColumns}
|
||||
data={pagedShipmentRows}
|
||||
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>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -155,9 +155,15 @@ const FleetResourcePage = () => {
|
||||
const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useQuery(
|
||||
api.cargoTypes.list.queryOptions({ staleTime: Infinity }),
|
||||
);
|
||||
const { data: wagons = [], isLoading: wagonsLoading } = useQuery(
|
||||
api.wagons.list.queryOptions({ input: {} }),
|
||||
// Whole-fleet list for the "Wagon" form select — page-walked, so only fetch it
|
||||
// where a form actually offers that select (containers), not on every slug.
|
||||
const needsWagonOptions = Boolean(
|
||||
config?.formFields.some((field) => field.dynamicOptions === "wagons"),
|
||||
);
|
||||
const { data: wagons = [], isLoading: wagonsLoading } = useQuery({
|
||||
...api.wagons.list.queryOptions({ input: {} }),
|
||||
enabled: needsWagonOptions,
|
||||
});
|
||||
const { data: containers = [], isLoading: containersLoading } = useQuery(
|
||||
api.containers.list.queryOptions(),
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Train as TrainIcon,
|
||||
TrainFront,
|
||||
Weight,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
@@ -48,6 +49,7 @@ import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canFleetAction, FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
@@ -78,6 +80,8 @@ export default function TrainBuilderDetailPage() {
|
||||
const [yardModalOpen, setYardModalOpen] = useState(false);
|
||||
const [disbandOpen, setDisbandOpen] = useState(false);
|
||||
const [deactivateOpen, setDeactivateOpen] = useState(false);
|
||||
const [maintenanceTarget, setMaintenanceTarget] =
|
||||
useState<TrainCompositionWagon | null>(null);
|
||||
const { user } = useAuth();
|
||||
const canUpdate = canFleetAction(user, "trains", "update");
|
||||
const canDelete = canFleetAction(user, "trains", "delete");
|
||||
@@ -396,12 +400,7 @@ export default function TrainBuilderDetailPage() {
|
||||
"Could not detach wagon",
|
||||
)
|
||||
}
|
||||
onMaintenance={(wagonId) =>
|
||||
void withToast(
|
||||
() => maintenanceWagon.mutateAsync({ id: composition.id, wagonId }),
|
||||
"Could not send wagon to maintenance",
|
||||
)
|
||||
}
|
||||
onMaintenance={(wagon) => setMaintenanceTarget(wagon)}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
@@ -460,6 +459,54 @@ export default function TrainBuilderDetailPage() {
|
||||
onClose={() => setYardModalOpen(false)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(maintenanceTarget)}
|
||||
onClose={() => setMaintenanceTarget(null)}
|
||||
title={<Text fw={600}>Send wagon to maintenance?</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Wagon{" "}
|
||||
<Text span fw={700} ff="monospace" c="dark">
|
||||
{maintenanceTarget?.wagonNumber}
|
||||
</Text>{" "}
|
||||
is detached from train{" "}
|
||||
<Text span fw={700} c="dark">
|
||||
{composition.code}
|
||||
</Text>{" "}
|
||||
and set to MAINTENANCE — it stays out of the available pool until it
|
||||
clears. The detach is stamped with the time and this train number in
|
||||
the wagon's history.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setMaintenanceTarget(null)}>
|
||||
Keep in consist
|
||||
</Button>
|
||||
<Button
|
||||
color="orange"
|
||||
leftSection={<Wrench size={16} />}
|
||||
loading={maintenanceWagon.isPending}
|
||||
onClick={() =>
|
||||
void withToast(async () => {
|
||||
await maintenanceWagon.mutateAsync({
|
||||
id: composition.id,
|
||||
wagonId: maintenanceTarget!.id,
|
||||
});
|
||||
toast({
|
||||
title: `Wagon ${maintenanceTarget!.wagonNumber} sent to maintenance`,
|
||||
});
|
||||
setMaintenanceTarget(null);
|
||||
}, "Could not send wagon to maintenance")
|
||||
}
|
||||
>
|
||||
Send to maintenance
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={deactivateOpen}
|
||||
onClose={() => setDeactivateOpen(false)}
|
||||
|
||||
Reference in New Issue
Block a user