mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 15:25:45 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
Milestone,
|
||||
MoreHorizontal,
|
||||
Package,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Ship,
|
||||
Truck,
|
||||
@@ -64,6 +65,7 @@ import {
|
||||
ContractOrdersPanel,
|
||||
} from "@/components/bookings/detail";
|
||||
import { WarehouseInfoCard } from "@/components/warehouses";
|
||||
import { AdditionalPaymentsTab } from "@/components/bookings/AdditionalPaymentsTab";
|
||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import { formatDateTime, formatMoney } from "@/lib/format";
|
||||
@@ -74,7 +76,10 @@ import {
|
||||
useBookingMutations,
|
||||
} from "@/hooks/bookings/useBookings";
|
||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
|
||||
|
||||
export default function BookingRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -82,6 +87,12 @@ export default function BookingRequestDetailPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Deep-link from a warehouse fee invoice → this booking's warehouse section.
|
||||
useScrollToHash();
|
||||
const { view, viewer } = useFileViewer();
|
||||
const { user } = useAuth();
|
||||
const canSeeAdditionalCharges = hasFreightPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.additionalCharges.view,
|
||||
);
|
||||
|
||||
// Consolidated pair: `?booking=<partnerId>` swaps the WHOLE page over to the
|
||||
// other half of the shared wagon. Everything below — KPIs, stepper, the
|
||||
@@ -206,7 +217,9 @@ export default function BookingRequestDetailPage() {
|
||||
? "documents"
|
||||
: requestedTab === "trucks"
|
||||
? "trucks"
|
||||
: "overview";
|
||||
: requestedTab === "additional-charges"
|
||||
? "additional-charges"
|
||||
: "overview";
|
||||
const setActiveTab = (tab: string | null) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (tab && tab !== "overview") next.set("tab", tab);
|
||||
@@ -509,6 +522,14 @@ export default function BookingRequestDetailPage() {
|
||||
<Tabs.Tab value="trucks" leftSection={<Truck size={16} />}>
|
||||
Trucks
|
||||
</Tabs.Tab>
|
||||
{canSeeAdditionalCharges && (
|
||||
<Tabs.Tab
|
||||
value="additional-charges"
|
||||
leftSection={<Receipt size={16} />}
|
||||
>
|
||||
Additional payments
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview">
|
||||
@@ -528,6 +549,11 @@ export default function BookingRequestDetailPage() {
|
||||
<Tabs.Panel value="trucks">
|
||||
<BookingTrucksPanel bookingId={booking.id} />
|
||||
</Tabs.Panel>
|
||||
{canSeeAdditionalCharges && (
|
||||
<Tabs.Panel value="additional-charges">
|
||||
<AdditionalPaymentsTab bookingId={booking.id} onViewFile={view} />
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
</Tabs>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -562,6 +588,7 @@ export default function BookingRequestDetailPage() {
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
{viewer}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { Box, Button, Card, Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FileText,
|
||||
LayoutList,
|
||||
Link2,
|
||||
Package,
|
||||
@@ -23,13 +16,19 @@ import {
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||
import { ExportButton } from "@/components/export/ExportButton";
|
||||
import { formatDate, humanize } from "@/lib/format";
|
||||
import { FilterBar, dateRangeParams, routeParams, useFilters, type FilterDef } from "@/components/filters";
|
||||
import {
|
||||
FilterBar,
|
||||
dateRangeParams,
|
||||
routeParams,
|
||||
useFilters,
|
||||
type FilterDef,
|
||||
} from "@/components/filters";
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
|
||||
@@ -150,36 +149,97 @@ export default function BookingRequestsPage() {
|
||||
// split), so a deep link can never land behind "More filters" unseen.
|
||||
const bookingFilterDefs: FilterDef[] = useMemo(
|
||||
() => [
|
||||
{ key: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_OPTIONS },
|
||||
{ key: "bookingType", label: "Kind", type: "enum", multiple: false, options: BOOKING_KIND_OPTIONS },
|
||||
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
|
||||
{
|
||||
key: "tradeDirection", label: "Direction", type: "enum", multiple: false,
|
||||
key: "customerKind",
|
||||
label: "Booked by",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: CUSTOMER_KIND_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "bookingType",
|
||||
label: "Kind",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: BOOKING_KIND_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "statuses",
|
||||
label: "Status",
|
||||
type: "enum",
|
||||
options: STATUS_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "tradeDirection",
|
||||
label: "Direction",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: filterOptions(TRADE_DIRECTION_OPTIONS),
|
||||
},
|
||||
{ key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS },
|
||||
{ key: "serviceTypeId", label: "Service", type: "enum", multiple: false, options: serviceTypeOptions },
|
||||
{ key: "paymentStatus", label: "Payment", type: "enum", multiple: false, options: PAYMENT_STATUS_OPTIONS, secondary: true },
|
||||
{
|
||||
key: "freightType",
|
||||
label: "Freight",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: FREIGHT_TYPE_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "serviceTypeId",
|
||||
label: "Service",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: serviceTypeOptions,
|
||||
},
|
||||
{
|
||||
key: "paymentStatus",
|
||||
label: "Payment",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: PAYMENT_STATUS_OPTIONS,
|
||||
secondary: true,
|
||||
},
|
||||
{
|
||||
// Wins over the `paymentStatus` filter above — the queue is by
|
||||
// definition PAID — because it's later in this array: toApiParams
|
||||
// merges defs in order, so a later toParams overwrites an earlier one.
|
||||
key: "paidUnallocated", label: "Allocation", type: "boolean", secondary: true,
|
||||
key: "paidUnallocated",
|
||||
label: "Allocation",
|
||||
type: "boolean",
|
||||
secondary: true,
|
||||
trueLabel: "Paid, not allocated",
|
||||
toParams: (v) => (v.v[0] === "true" ? { paymentStatus: "PAID", assignedToSchedule: "false" } : {}),
|
||||
toParams: (v) =>
|
||||
v.v[0] === "true"
|
||||
? { paymentStatus: "PAID", assignedToSchedule: "false" }
|
||||
: {},
|
||||
},
|
||||
{ key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS, secondary: true },
|
||||
{
|
||||
key: "route", label: "Route", type: "route", options: yardOptions,
|
||||
key: "isGovernment",
|
||||
label: "Ownership",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: OWNERSHIP_OPTIONS,
|
||||
secondary: true,
|
||||
},
|
||||
{
|
||||
key: "route",
|
||||
label: "Route",
|
||||
type: "route",
|
||||
options: yardOptions,
|
||||
toParams: routeParams("originYardId", "destinationYardId"),
|
||||
},
|
||||
{
|
||||
key: "created", label: "Created", type: "date", secondary: true,
|
||||
key: "created",
|
||||
label: "Created",
|
||||
type: "date",
|
||||
secondary: true,
|
||||
operators: ["between", "before", "after"],
|
||||
toParams: dateRangeParams("createdFrom", "createdTo"),
|
||||
},
|
||||
{
|
||||
key: "scheduled", label: "Scheduled", type: "date", secondary: true,
|
||||
key: "scheduled",
|
||||
label: "Scheduled",
|
||||
type: "date",
|
||||
secondary: true,
|
||||
operators: ["between", "before", "after"],
|
||||
toParams: dateRangeParams("scheduledFrom", "scheduledTo"),
|
||||
},
|
||||
@@ -187,19 +247,24 @@ export default function BookingRequestsPage() {
|
||||
[filterOptions, yardOptions, serviceTypeOptions],
|
||||
);
|
||||
|
||||
const controls = useFilters(bookingFilterDefs, { defaultSort: "createdAt:DESC", pageSize: 10 });
|
||||
const controls = useFilters(bookingFilterDefs, {
|
||||
defaultSort: "createdAt:DESC",
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const filter: BookingListFilter = useMemo(
|
||||
() => ({
|
||||
...(controls.params as unknown as BookingListFilter),
|
||||
// React Query cache key per kind selection ("ALL" when unfiltered) —
|
||||
// kept as a param the API ignores, matching the pre-migration cache key.
|
||||
tab: (controls.values.bookingType?.v[0] as BookingKind | undefined) ?? "ALL",
|
||||
tab:
|
||||
(controls.values.bookingType?.v[0] as BookingKind | undefined) ?? "ALL",
|
||||
}),
|
||||
[controls.params, controls.values.bookingType],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
|
||||
const { data, isLoading, isError, refetch, isFetching } =
|
||||
useBookingList(filter);
|
||||
const primaryAllocateId = allocateIds[0];
|
||||
const { data: allocateBooking } = useBookingDetail(
|
||||
allocateOpen ? primaryAllocateId : undefined,
|
||||
@@ -262,8 +327,9 @@ export default function BookingRequestsPage() {
|
||||
async (row: BookingListRow) => {
|
||||
setAllocatingId(row.id);
|
||||
try {
|
||||
const candidates =
|
||||
await trainSchedulingService.getAllocationCandidates(row.id);
|
||||
const candidates = await trainSchedulingService.getAllocationCandidates(
|
||||
row.id,
|
||||
);
|
||||
if (candidates.sameDay.length > 0) {
|
||||
const target = candidates.sameDay[0];
|
||||
await trainSchedulingService.allocatePaidBooking(row.id, target.id);
|
||||
@@ -330,7 +396,9 @@ export default function BookingRequestsPage() {
|
||||
</div>
|
||||
<div className="min-w-0 max-w-[220px]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="truncate font-medium text-foreground">{b.reference}</p>
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{b.reference}
|
||||
</p>
|
||||
<Badge
|
||||
variant={isGeneral ? "secondary" : "outline"}
|
||||
className="h-5 shrink-0 px-1.5 text-[10px] font-medium"
|
||||
@@ -338,6 +406,26 @@ export default function BookingRequestsPage() {
|
||||
{isGeneral ? "General" : "One-time"}
|
||||
</Badge>
|
||||
</div>
|
||||
{b.contractReference ? (
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs">
|
||||
<FileText className="size-3 shrink-0 text-muted-foreground opacity-70" />
|
||||
{b.contractId ? (
|
||||
<Link
|
||||
to={`/dashboard/contract-requests/${b.contractId}/view`}
|
||||
// The row itself opens the booking — without this the
|
||||
// contract link would never win the click.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="truncate text-blue-600 hover:underline"
|
||||
>
|
||||
{b.contractReference}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="truncate text-muted-foreground">
|
||||
{b.contractReference}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
{b.isShippingLine ? (
|
||||
<Ship className="size-3 shrink-0 opacity-70" />
|
||||
@@ -346,7 +434,10 @@ export default function BookingRequestsPage() {
|
||||
)}
|
||||
{b.customerLabel}
|
||||
{b.isShippingLine ? (
|
||||
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[9px] font-medium">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 shrink-0 px-1 text-[9px] font-medium"
|
||||
>
|
||||
Shipping line
|
||||
</Badge>
|
||||
) : null}
|
||||
@@ -382,7 +473,9 @@ export default function BookingRequestsPage() {
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<span className="max-w-[8rem] truncate">{b.originLabel}</span>
|
||||
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="max-w-[8rem] truncate">{b.destinationLabel}</span>
|
||||
<span className="max-w-[8rem] truncate">
|
||||
{b.destinationLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Badge
|
||||
@@ -443,7 +536,8 @@ export default function BookingRequestsPage() {
|
||||
size: 140,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const needsAllocation = b.paymentStatus === "PAID" && !b.trainScheduleId;
|
||||
const needsAllocation =
|
||||
b.paymentStatus === "PAID" && !b.trainScheduleId;
|
||||
return (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{needsAllocation ? (
|
||||
@@ -474,61 +568,61 @@ export default function BookingRequestsPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Booking requests"
|
||||
subtitle="Review, approve, and schedule freight booking requests."
|
||||
action={
|
||||
<>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={18} />}
|
||||
onClick={() => navigate("/dashboard/booking-requests/new")}
|
||||
>
|
||||
Create booking
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<RefreshCw size={16} />}
|
||||
loading={isFetching}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<PageHeader
|
||||
title="Booking requests"
|
||||
subtitle="Review, approve, and schedule freight booking requests."
|
||||
action={
|
||||
<>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={18} />}
|
||||
onClick={() => navigate("/dashboard/booking-requests/new")}
|
||||
>
|
||||
Create booking
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<RefreshCw size={16} />}
|
||||
loading={isFetching}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<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: "Completed",
|
||||
value: tabCounts?.completed ?? 0,
|
||||
icon: CheckCircle2,
|
||||
color: "edr-green",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<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: "Completed",
|
||||
value: tabCounts?.completed ?? 0,
|
||||
icon: CheckCircle2,
|
||||
color: "edr-green",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Status tabs replaced by booking-kind tabs (one-time / general). The
|
||||
{/* Status tabs replaced by booking-kind tabs (one-time / general). The
|
||||
old BookingStatusTabs is commented out — status is now a filter select.
|
||||
<BookingStatusTabs
|
||||
active={activeTab}
|
||||
@@ -540,92 +634,95 @@ export default function BookingRequestsPage() {
|
||||
/>
|
||||
*/}
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="sm" pb="xs" w="100%">
|
||||
<FilterBar
|
||||
defs={bookingFilterDefs}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search booking, contract, customer or shipping line…"
|
||||
viewId="booking-requests"
|
||||
>
|
||||
<ExportButton datasetKey="bookings" params={controls.params} />
|
||||
</FilterBar>
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="sm" pb="xs" w="100%">
|
||||
<FilterBar
|
||||
defs={bookingFilterDefs}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search booking, contract, customer or shipping line…"
|
||||
viewId="booking-requests"
|
||||
>
|
||||
<ExportButton datasetKey="bookings" params={controls.params} />
|
||||
</FilterBar>
|
||||
</Box>
|
||||
|
||||
{showEmpty ? (
|
||||
<Box px="md" pb="md">
|
||||
<BookingTableEmpty
|
||||
isError={isError}
|
||||
hasSearch={hasSearch}
|
||||
onRetry={handleRefresh}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{showEmpty ? (
|
||||
<Box px="md" pb="md">
|
||||
<BookingTableEmpty
|
||||
isError={isError}
|
||||
hasSearch={hasSearch}
|
||||
onRetry={handleRefresh}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={handleRowClick}
|
||||
{...controls.tableProps(total)}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={otherDayModal !== null}
|
||||
onClose={() => setOtherDayModal(null)}
|
||||
title="Allocate to another date"
|
||||
centered
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
No train on {otherDayModal ? formatDate(otherDayModal.booking.scheduledDate) : "the booking's day"}{" "}
|
||||
fits booking {otherDayModal?.booking.reference}. These trains on
|
||||
other dates do — the customer will be notified of the date change.
|
||||
</Text>
|
||||
{otherDayModal?.candidates.map((c) => (
|
||||
<Group key={c.id} justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{c.reference ?? "Train"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Departs {formatDate(c.scheduledDepartureDate)}
|
||||
{c.direction ? ` · ${c.direction}` : ""}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
loading={allocatingId === otherDayModal.booking.id}
|
||||
onClick={() => void handleAllocateOtherDay(c)}
|
||||
>
|
||||
Allocate
|
||||
</Button>
|
||||
</Group>
|
||||
))}
|
||||
) : (
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={handleRowClick}
|
||||
{...controls.tableProps(total)}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
{allocateBooking ? (
|
||||
<AllocateBookingWizard
|
||||
booking={allocateBooking}
|
||||
opened={allocateOpen}
|
||||
onClose={() => {
|
||||
setAllocateOpen(false);
|
||||
setAllocateIds([]);
|
||||
void refetch();
|
||||
}}
|
||||
initialBookingIds={allocateIds}
|
||||
/>
|
||||
) : null}
|
||||
<Modal
|
||||
opened={otherDayModal !== null}
|
||||
onClose={() => setOtherDayModal(null)}
|
||||
title="Allocate to another date"
|
||||
centered
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
No train on{" "}
|
||||
{otherDayModal
|
||||
? formatDate(otherDayModal.booking.scheduledDate)
|
||||
: "the booking's day"}{" "}
|
||||
fits booking {otherDayModal?.booking.reference}. These trains on
|
||||
other dates do — the customer will be notified of the date change.
|
||||
</Text>
|
||||
{otherDayModal?.candidates.map((c) => (
|
||||
<Group key={c.id} justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{c.reference ?? "Train"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Departs {formatDate(c.scheduledDepartureDate)}
|
||||
{c.direction ? ` · ${c.direction}` : ""}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
loading={allocatingId === otherDayModal.booking.id}
|
||||
onClick={() => void handleAllocateOtherDay(c)}
|
||||
>
|
||||
Allocate
|
||||
</Button>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{allocateBooking ? (
|
||||
<AllocateBookingWizard
|
||||
booking={allocateBooking}
|
||||
opened={allocateOpen}
|
||||
onClose={() => {
|
||||
setAllocateOpen(false);
|
||||
setAllocateIds([]);
|
||||
void refetch();
|
||||
}}
|
||||
initialBookingIds={allocateIds}
|
||||
/>
|
||||
) : null}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,13 +10,23 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Pagination,
|
||||
Paper,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle, Check, Clock, Link2, X } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
Clock,
|
||||
FileText,
|
||||
Link2,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
@@ -28,6 +38,39 @@ import { formatDateTime } from "@/lib/format";
|
||||
import { extractErrorMessage } from "@/utils/errorExtractor";
|
||||
|
||||
const QUEUE_KEY = ["consolidation-approvals", "queue"];
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
type Status = ConsolidationApprovalRow["status"];
|
||||
|
||||
const TABS: { value: Status; label: string }[] = [
|
||||
{ value: "PENDING", label: "Awaiting approval" },
|
||||
{ value: "APPROVED", label: "Approved" },
|
||||
{ value: "REJECTED", label: "Rejected" },
|
||||
];
|
||||
|
||||
const STATUS_COLOR: Record<Status, string> = {
|
||||
PENDING: "yellow",
|
||||
APPROVED: "green",
|
||||
REJECTED: "red",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<Status, string> = {
|
||||
PENDING: "Awaiting approval",
|
||||
APPROVED: "Approved",
|
||||
REJECTED: "Rejected",
|
||||
};
|
||||
|
||||
const STATUS_VERB: Record<Status, string> = {
|
||||
PENDING: "",
|
||||
APPROVED: "Approved by",
|
||||
REJECTED: "Rejected by",
|
||||
};
|
||||
|
||||
const EMPTY_TEXT: Record<Status, string> = {
|
||||
PENDING: "Nothing waiting for approval.",
|
||||
APPROVED: "No shared wagon has been approved yet.",
|
||||
REJECTED: "No shared wagon has been rejected.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Review queue for shared-wagon pairings.
|
||||
@@ -37,6 +80,11 @@ const QUEUE_KEY = ["consolidation-approvals", "queue"];
|
||||
* under two separate invoices, so a person signs off on the pairing first.
|
||||
* Approving releases BOTH bookings to Operations; rejecting sends BOTH back to
|
||||
* GL with the reason.
|
||||
*
|
||||
* Decided pairings stay on the page rather than vanishing: the decided tabs are
|
||||
* the record of who signed off on which wagon and why. A rejection is not final
|
||||
* either — a rejected pairing can still be approved from here once whatever
|
||||
* blocked it is settled.
|
||||
*/
|
||||
export default function ConsolidationApprovalsPage() {
|
||||
const qc = useQueryClient();
|
||||
@@ -45,16 +93,31 @@ export default function ConsolidationApprovalsPage() {
|
||||
kind: "approve" | "reject";
|
||||
} | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
const [tab, setTab] = useState<Status>("PENDING");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const {
|
||||
data: rows,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: QUEUE_KEY,
|
||||
queryFn: () => bookingsService.consolidationApprovalQueue(),
|
||||
const { data, isLoading, isError, isFetching } = useQuery({
|
||||
queryKey: [...QUEUE_KEY, tab, page],
|
||||
queryFn: () =>
|
||||
bookingsService.consolidationApprovalQueue({
|
||||
status: tab,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
}),
|
||||
// Keeping the last page on screen while the next one loads stops the list
|
||||
// from collapsing to a spinner on every page or tab click.
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
|
||||
const shown = data?.items ?? [];
|
||||
const pageCount = Math.max(1, data?.meta.totalPages ?? 1);
|
||||
const countOf = (status: Status) => data?.counts?.[status] ?? 0;
|
||||
|
||||
const goToTab = (next: Status) => {
|
||||
setTab(next);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setDecision(null);
|
||||
setNote("");
|
||||
@@ -64,7 +127,10 @@ export default function ConsolidationApprovalsPage() {
|
||||
mutationFn: () => {
|
||||
if (!decision) throw new Error("No pairing selected");
|
||||
return decision.kind === "approve"
|
||||
? bookingsService.approveConsolidation(decision.row.id, note.trim() || undefined)
|
||||
? bookingsService.approveConsolidation(
|
||||
decision.row.id,
|
||||
note.trim() || undefined,
|
||||
)
|
||||
: bookingsService.rejectConsolidation(decision.row.id, note.trim());
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -73,6 +139,7 @@ export default function ConsolidationApprovalsPage() {
|
||||
? "Shared wagon approved — both bookings sent to Operations"
|
||||
: "Shared wagon rejected — both bookings returned to GL",
|
||||
);
|
||||
goToTab(decision?.kind === "approve" ? "APPROVED" : "REJECTED");
|
||||
void qc.invalidateQueries({ queryKey: QUEUE_KEY });
|
||||
close();
|
||||
},
|
||||
@@ -99,90 +166,194 @@ export default function ConsolidationApprovalsPage() {
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||
Could not load the approval queue.
|
||||
</Alert>
|
||||
) : !rows?.length ? (
|
||||
<Alert color="gray" radius="md" icon={<Check size={16} />}>
|
||||
Nothing waiting for approval.
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{rows.map((row) => (
|
||||
<Paper
|
||||
key={row.id}
|
||||
withBorder
|
||||
radius="lg"
|
||||
p="lg"
|
||||
style={{ borderColor: "#E6ECF2" }}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Group gap={8} align="center" mb={10}>
|
||||
<ThemeIcon variant="light" color="blue" radius="md" size={30}>
|
||||
<Link2 size={16} />
|
||||
</ThemeIcon>
|
||||
<Text fw={800} fz={15}>
|
||||
Shared wagon
|
||||
</Text>
|
||||
<Badge color="yellow" variant="light" radius="sm">
|
||||
Awaiting approval
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Group gap="xl" wrap="wrap">
|
||||
<BookingSide
|
||||
id={row.bookingId}
|
||||
reference={row.booking?.reference ?? row.bookingReference}
|
||||
company={row.booking?.company?.name}
|
||||
/>
|
||||
<BookingSide
|
||||
id={row.partnerBookingId}
|
||||
reference={
|
||||
row.partnerBooking?.reference ??
|
||||
row.partnerBookingReference
|
||||
}
|
||||
company={row.partnerBooking?.company?.name}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group gap={6} mt={12} c="dimmed">
|
||||
<Clock size={13} />
|
||||
<Text fz={12}>
|
||||
Requested {formatDateTime(row.requestedAt)}
|
||||
{row.scheduledDate
|
||||
? ` · ships ${formatDateTime(row.scheduledDate)}`
|
||||
: ""}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Check size={15} />}
|
||||
onClick={() => {
|
||||
setDecision({ row, kind: "approve" });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(value) => goToTab((value as Status) ?? "PENDING")}
|
||||
radius="md"
|
||||
>
|
||||
<Tabs.List mb="md">
|
||||
{TABS.map(({ value, label }) => (
|
||||
<Tabs.Tab
|
||||
key={value}
|
||||
value={value}
|
||||
rightSection={
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
radius="md"
|
||||
leftSection={<X size={15} />}
|
||||
onClick={() => {
|
||||
setDecision({ row, kind: "reject" });
|
||||
setNote("");
|
||||
}}
|
||||
color={STATUS_COLOR[value]}
|
||||
radius="sm"
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
{countOf(value)}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
|
||||
{!shown.length ? (
|
||||
<Alert color="gray" radius="md" icon={<Check size={16} />}>
|
||||
{EMPTY_TEXT[tab]}
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{shown.map((row) => (
|
||||
<Paper
|
||||
key={row.id}
|
||||
withBorder
|
||||
radius="lg"
|
||||
p="lg"
|
||||
style={{ borderColor: "#E6ECF2" }}
|
||||
>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="wrap"
|
||||
gap="md"
|
||||
>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Group gap={8} align="center" mb={10}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="md"
|
||||
size={30}
|
||||
>
|
||||
<Link2 size={16} />
|
||||
</ThemeIcon>
|
||||
<Text fw={800} fz={15}>
|
||||
Shared wagon
|
||||
</Text>
|
||||
<Badge
|
||||
color={STATUS_COLOR[row.status]}
|
||||
variant="light"
|
||||
radius="sm"
|
||||
>
|
||||
{STATUS_LABEL[row.status]}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Group gap="xl" wrap="wrap">
|
||||
<BookingSide
|
||||
id={row.bookingId}
|
||||
reference={
|
||||
row.booking?.reference ?? row.bookingReference
|
||||
}
|
||||
company={row.booking?.company?.name}
|
||||
contractReference={row.contractReference}
|
||||
contractId={row.booking?.contractId}
|
||||
/>
|
||||
<BookingSide
|
||||
id={row.partnerBookingId}
|
||||
reference={
|
||||
row.partnerBooking?.reference ??
|
||||
row.partnerBookingReference
|
||||
}
|
||||
company={row.partnerBooking?.company?.name}
|
||||
contractReference={row.partnerContractReference}
|
||||
contractId={row.partnerBooking?.contractId}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group gap={6} mt={12} c="dimmed">
|
||||
<Clock size={13} />
|
||||
<Text fz={12}>
|
||||
Requested {formatDateTime(row.requestedAt)}
|
||||
{row.requestedByName
|
||||
? ` by ${row.requestedByName}`
|
||||
: ""}
|
||||
{row.scheduledDate
|
||||
? ` · ships ${formatDateTime(row.scheduledDate)}`
|
||||
: ""}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{row.status !== "PENDING" && (
|
||||
<Group gap={6} mt={6} c="dimmed" align="flex-start">
|
||||
<User size={13} style={{ marginTop: 2 }} />
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={12}>
|
||||
{STATUS_VERB[row.status]}{" "}
|
||||
{row.decidedByName ?? "an unknown user"}
|
||||
{row.decidedAt
|
||||
? ` on ${formatDateTime(row.decidedAt)}`
|
||||
: ""}
|
||||
</Text>
|
||||
{row.decisionNote && (
|
||||
<Text fz={12} fs="italic">
|
||||
“{row.decisionNote}”
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{row.status !== "APPROVED" && (
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Check size={15} />}
|
||||
onClick={() => {
|
||||
setDecision({ row, kind: "approve" });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
{row.status === "REJECTED"
|
||||
? "Approve anyway"
|
||||
: "Approve"}
|
||||
</Button>
|
||||
{row.status === "PENDING" && (
|
||||
<Button
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
leftSection={<X size={15} />}
|
||||
onClick={() => {
|
||||
setDecision({ row, kind: "reject" });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
{pageCount > 1 && (
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
mt={4}
|
||||
wrap="wrap"
|
||||
>
|
||||
<Text fz={12} c="dimmed">
|
||||
Showing {(page - 1) * PAGE_SIZE + 1}–
|
||||
{Math.min(page * PAGE_SIZE, data?.total ?? 0)} of{" "}
|
||||
{data?.total ?? 0}
|
||||
</Text>
|
||||
<Pagination
|
||||
size="sm"
|
||||
radius="md"
|
||||
color="edr-ink"
|
||||
total={pageCount}
|
||||
value={page}
|
||||
onChange={setPage}
|
||||
disabled={isFetching}
|
||||
siblings={1}
|
||||
boundaries={1}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
@@ -194,17 +365,21 @@ export default function ConsolidationApprovalsPage() {
|
||||
radius="lg"
|
||||
title={
|
||||
<Text fw={800} fz={16}>
|
||||
{decision?.kind === "approve"
|
||||
? "Approve this shared wagon?"
|
||||
: "Reject this shared wagon?"}
|
||||
{decision?.kind !== "approve"
|
||||
? "Reject this shared wagon?"
|
||||
: decision.row.status === "REJECTED"
|
||||
? "Approve this rejected shared wagon?"
|
||||
: "Approve this shared wagon?"}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text fz="sm" c="dimmed">
|
||||
{decision?.kind === "approve"
|
||||
? "Both bookings leave the gate together and continue to Operations. Each is still invoiced and paid separately."
|
||||
: "Both bookings go back to GL as “changes requested” with your reason. Neither reaches Operations."}
|
||||
{decision?.kind !== "approve"
|
||||
? "Both bookings go back to GL as “changes requested” with your reason. Neither reaches Operations."
|
||||
: decision.row.status === "REJECTED"
|
||||
? "This pairing was rejected before. Approving it now overrides that decision — both bookings leave the gate together and continue to Operations."
|
||||
: "Both bookings leave the gate together and continue to Operations. Each is still invoiced and paid separately."}
|
||||
</Text>
|
||||
|
||||
<Textarea
|
||||
@@ -254,15 +429,23 @@ export default function ConsolidationApprovalsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** One half of the wagon: its reference (linked) and whose cargo it is. */
|
||||
/**
|
||||
* One half of the wagon: its booking reference, the contract it was raised
|
||||
* under, and whose cargo it is. Both references link out — a reviewer deciding
|
||||
* a pairing usually wants the contract, not just the shipment.
|
||||
*/
|
||||
function BookingSide({
|
||||
id,
|
||||
reference,
|
||||
company,
|
||||
contractReference,
|
||||
contractId,
|
||||
}: {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
company?: string | null;
|
||||
contractReference?: string | null;
|
||||
contractId?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
@@ -276,6 +459,32 @@ function BookingSide({
|
||||
>
|
||||
{reference ?? "—"}
|
||||
</Text>
|
||||
|
||||
{contractReference && (
|
||||
<Group gap={4} wrap="nowrap" mt={2}>
|
||||
<FileText
|
||||
size={11}
|
||||
className="shrink-0"
|
||||
color="var(--mantine-color-dimmed)"
|
||||
/>
|
||||
{contractId ? (
|
||||
<Text
|
||||
component={Link}
|
||||
to={`/dashboard/contract-requests/${contractId}/view`}
|
||||
fz={12}
|
||||
c="blue.7"
|
||||
style={{ textDecoration: "none" }}
|
||||
>
|
||||
{contractReference}
|
||||
</Text>
|
||||
) : (
|
||||
<Text fz={12} c="dimmed">
|
||||
{contractReference}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Text fz={12.5} c="dimmed">
|
||||
{company ?? "—"}
|
||||
</Text>
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
BookingContractCard,
|
||||
} from "@/components/bookings/detail";
|
||||
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
|
||||
import { AdditionalDocsRequestCard } from "@/components/bookings/detail/AdditionalDocsRequestCard";
|
||||
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
||||
@@ -363,6 +364,14 @@ export default function DocumentClearanceDetailPage() {
|
||||
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<Stack gap="lg">
|
||||
{/* Documents stay open until payment, so GL can ask for a
|
||||
missing file at any point in that window. */}
|
||||
<AdditionalDocsRequestCard
|
||||
bookingId={id!}
|
||||
requests={clearance.docRequests ?? []}
|
||||
canRequest={!documentsClosed}
|
||||
onSent={() => void refetch()}
|
||||
/>
|
||||
{booking ? <BookingCompanyCard booking={booking} /> : null}
|
||||
{booking ? <BookingContractCard booking={booking} /> : null}
|
||||
{isPhasedGeneral ? (
|
||||
|
||||
@@ -55,9 +55,31 @@ interface WagonCancellation {
|
||||
reason?: string | null;
|
||||
rebookedAt?: string | null;
|
||||
createdAt: string;
|
||||
booking?: { id: string; reference: string; company?: { name: string } };
|
||||
booking?: {
|
||||
id: string;
|
||||
reference: string;
|
||||
customsClearingEnabled?: boolean;
|
||||
company?: { name: string };
|
||||
};
|
||||
rebookedBooking?: { id: string; reference: string };
|
||||
feeInvoice?: { invoiceNumber: string; status: string };
|
||||
cancelledQuantities?: {
|
||||
bySize?: Record<string, number>;
|
||||
units?: Array<{
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber?: string | null;
|
||||
vgmTons: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
/** Editable rebook unit — prefilled from the cancelled snapshot. */
|
||||
interface RebookUnitDraft {
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber: string;
|
||||
vgmTons: number | "";
|
||||
}
|
||||
|
||||
interface WagonCancellationListResponse {
|
||||
@@ -116,6 +138,49 @@ export default function WagonCancellationsPage() {
|
||||
const [from, setFrom] = useState<Date | null>(null);
|
||||
const [to, setTo] = useState<Date | null>(null);
|
||||
const [voiding, setVoiding] = useState<WagonCancellation | null>(null);
|
||||
// GL rebook of a customs (Path B) credit: pick the day; container number /
|
||||
// seal / VGM may be corrected. Non-customs credits are rebooked by the
|
||||
// customer from the portal.
|
||||
const canRebook = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationRebook,
|
||||
);
|
||||
const [rebooking, setRebooking] = useState<WagonCancellation | null>(null);
|
||||
const [rebookDate, setRebookDate] = useState<Date | null>(null);
|
||||
const [rebookDrafts, setRebookDrafts] = useState<RebookUnitDraft[]>([]);
|
||||
const openRebook = (r: WagonCancellation) => {
|
||||
setRebooking(r);
|
||||
setRebookDate(null);
|
||||
setRebookDrafts(
|
||||
(r.cancelledQuantities?.units ?? []).map((u) => ({
|
||||
containerSize: u.containerSize,
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber ?? "",
|
||||
vgmTons: Number(u.vgmTons) || "",
|
||||
})),
|
||||
);
|
||||
};
|
||||
const rebookContainersPayload = () => {
|
||||
const bySize = new Map<string, RebookUnitDraft[]>();
|
||||
for (const d of rebookDrafts) {
|
||||
bySize.set(d.containerSize, [...(bySize.get(d.containerSize) ?? []), d]);
|
||||
}
|
||||
return [...bySize.entries()].map(([containerSize, units]) => ({
|
||||
containerSize,
|
||||
units: units.map((u) => ({
|
||||
containerNumber: u.containerNumber.trim(),
|
||||
...(u.sealNumber.trim() ? { sealNumber: u.sealNumber.trim() } : {}),
|
||||
...(u.vgmTons !== "" ? { vgmTons: Number(u.vgmTons) } : {}),
|
||||
})),
|
||||
}));
|
||||
};
|
||||
const rebook = useMutation({
|
||||
mutationFn: () =>
|
||||
api.post(`/bookings/wagon-cancellations/${rebooking!.id}/rebook`, {
|
||||
scheduledDate: toDayString(rebookDate!),
|
||||
...(rebookDrafts.length ? { containers: rebookContainersPayload() } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
const resetPage = () =>
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
@@ -234,18 +299,39 @@ export default function WagonCancellationsPage() {
|
||||
header: () => <span />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
if (r.status !== "FEE_PENDING" || !canVoid) return null;
|
||||
const showVoid = r.status === "FEE_PENDING" && canVoid;
|
||||
// Customs credits are GL's to rebook; non-customs ones the customer
|
||||
// rebooks from the portal.
|
||||
const showRebook =
|
||||
r.status === "CREDIT_AVAILABLE" &&
|
||||
canRebook &&
|
||||
Boolean(r.booking?.customsClearingEnabled) &&
|
||||
Number(r.creditAmount) > 0;
|
||||
if (!showVoid && !showRebook) return null;
|
||||
return (
|
||||
<Group justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => setVoiding(r)}
|
||||
>
|
||||
Void
|
||||
</Button>
|
||||
{showRebook && (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="green"
|
||||
onClick={() => openRebook(r)}
|
||||
>
|
||||
Rebook
|
||||
</Button>
|
||||
)}
|
||||
{showVoid && (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => setVoiding(r)}
|
||||
>
|
||||
Void
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
@@ -391,6 +477,117 @@ export default function WagonCancellationsPage() {
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal
|
||||
opened={!!rebooking}
|
||||
onClose={() => setRebooking(null)}
|
||||
title="Rebook cancelled wagons"
|
||||
centered
|
||||
radius="md"
|
||||
>
|
||||
{rebooking && (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">
|
||||
{rebooking.booking?.reference ?? rebooking.bookingId} ·{" "}
|
||||
{rebooking.wagonsCancelled} wagon(s) · credit{" "}
|
||||
{formatMoney(rebooking.creditAmount, rebooking.feeCurrency, 2)}
|
||||
</Text>
|
||||
<DatePickerInput
|
||||
label="Shipment day"
|
||||
placeholder="Pick the day"
|
||||
value={rebookDate}
|
||||
onChange={(v) => setRebookDate(v ? new Date(v) : null)}
|
||||
radius="md"
|
||||
/>
|
||||
{rebookDrafts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Correct the container details if they changed — sizes and
|
||||
quantities stay as cancelled.
|
||||
</Text>
|
||||
{rebookDrafts.map((d, i) => (
|
||||
<Group key={i} gap={8} wrap="nowrap" align="flex-end">
|
||||
<TextInput
|
||||
label={`${d.containerSize} container`}
|
||||
value={d.containerNumber}
|
||||
onChange={(e) => {
|
||||
const v = e.currentTarget.value;
|
||||
setRebookDrafts((prev) =>
|
||||
prev.map((x, idx) =>
|
||||
idx === i ? { ...x, containerNumber: v } : x,
|
||||
),
|
||||
);
|
||||
}}
|
||||
size="xs"
|
||||
radius="md"
|
||||
style={{ flex: 1.4 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Seal no."
|
||||
value={d.sealNumber}
|
||||
onChange={(e) => {
|
||||
const v = e.currentTarget.value;
|
||||
setRebookDrafts((prev) =>
|
||||
prev.map((x, idx) =>
|
||||
idx === i ? { ...x, sealNumber: v } : x,
|
||||
),
|
||||
);
|
||||
}}
|
||||
size="xs"
|
||||
radius="md"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="VGM (t)"
|
||||
type="number"
|
||||
value={d.vgmTons === "" ? "" : String(d.vgmTons)}
|
||||
onChange={(e) => {
|
||||
const raw = e.currentTarget.value;
|
||||
setRebookDrafts((prev) =>
|
||||
prev.map((x, idx) =>
|
||||
idx === i
|
||||
? { ...x, vgmTons: raw === "" ? "" : Number(raw) }
|
||||
: x,
|
||||
),
|
||||
);
|
||||
}}
|
||||
size="xs"
|
||||
radius="md"
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => setRebooking(null)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
disabled={!rebookDate}
|
||||
loading={rebook.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await rebook.mutateAsync();
|
||||
toast.success("Credit rebooked as a new paid booking");
|
||||
setRebooking(null);
|
||||
void refetch();
|
||||
} catch {
|
||||
// interceptor surfaces the reason
|
||||
}
|
||||
}}
|
||||
>
|
||||
Rebook
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,15 +8,20 @@ import {
|
||||
Card,
|
||||
Group,
|
||||
Menu,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { useInterval } from "@mantine/hooks";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
Building2,
|
||||
CalendarClock,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
FileText,
|
||||
@@ -27,26 +32,27 @@ import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
User,
|
||||
ShipWheel,
|
||||
TriangleAlert,
|
||||
Truck,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DataTable,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||
import { TablePager } from "@/components/page/TablePager";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
|
||||
import { CLEARANCE_TABS } from "@/features/clearance/clearance-tabs.config";
|
||||
import {
|
||||
RequestedCargoChips,
|
||||
summarizeRequestedCargo,
|
||||
@@ -62,53 +68,134 @@ function yardLabel(
|
||||
return yard.label ?? yard.name ?? yard.code ?? "—";
|
||||
}
|
||||
|
||||
/**
|
||||
* "Origin → Destination", wrapping past 120px as "Addis Ababa" /
|
||||
* "→ Djibouti": the arrow is glued to the destination with an nbsp, and
|
||||
* text wraps normally (the table's cells are otherwise nowrap) so a long
|
||||
* lane never spills into the next column.
|
||||
*/
|
||||
function RouteLabel({
|
||||
origin,
|
||||
destination,
|
||||
}: {
|
||||
origin: string;
|
||||
destination: string;
|
||||
}) {
|
||||
const prettyStatus = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/^\w/, (c) => c.toUpperCase());
|
||||
|
||||
const shipmentStatusColor = (s: string) => {
|
||||
if (s === "AWAITING_DOCUMENTS") return "yellow";
|
||||
if (s === "DOCUMENTS_UNDER_REVIEW") return "blue";
|
||||
if (s === "CLEARANCE_READY") return "edr-green";
|
||||
if (
|
||||
[
|
||||
"SELECTED_FOR_BATCH",
|
||||
"PNR_GENERATED",
|
||||
"AWAITING_PAYMENT",
|
||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
].includes(s)
|
||||
)
|
||||
return "violet";
|
||||
if (s === "EXPIRED") return "orange";
|
||||
if (s === "CANCELLED" || s === "REJECTED") return "red";
|
||||
return "gray";
|
||||
};
|
||||
|
||||
/** Rows created per day over the last `days` days, oldest → newest. */
|
||||
function perDay(rows: { createdAt: string | null }[], days = 8): number[] {
|
||||
const today = new Date().setHours(0, 0, 0, 0);
|
||||
const out = new Array<number>(days).fill(0);
|
||||
for (const r of rows) {
|
||||
if (!r.createdAt) continue;
|
||||
const age = Math.floor(
|
||||
(today - new Date(r.createdAt).setHours(0, 0, 0, 0)) / 86_400_000,
|
||||
);
|
||||
if (age >= 0 && age < days) out[days - 1 - age] += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Tabs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type TabKey = "all" | "import" | "export" | "review";
|
||||
|
||||
const TABS: { key: TabKey; label: string; icon: LucideIcon }[] = [
|
||||
...CLEARANCE_TABS,
|
||||
{ key: "review", label: "Needs approval", icon: TriangleAlert },
|
||||
];
|
||||
|
||||
// ── Small pieces ─────────────────────────────────────────────────────────────
|
||||
|
||||
function LivePill({ updatedAt }: { updatedAt: number }) {
|
||||
// Re-render every 30s so "Xm ago" keeps ticking between refetches.
|
||||
const [, setTick] = useState(0);
|
||||
useInterval(() => setTick((t) => t + 1), 30_000, { autoInvoke: true });
|
||||
const mins = Math.max(0, Math.round((Date.now() - updatedAt) / 60_000));
|
||||
const label = !updatedAt
|
||||
? "Connecting…"
|
||||
: mins < 1
|
||||
? "Live · updated just now"
|
||||
: `Live · updated ${mins}m ago`;
|
||||
return (
|
||||
<Text
|
||||
size="sm"
|
||||
maw={120}
|
||||
lh={1.35}
|
||||
style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
|
||||
>
|
||||
{origin}{" "}
|
||||
<ArrowRight
|
||||
size={13}
|
||||
className="text-muted-foreground"
|
||||
style={{ display: "inline-block", verticalAlign: "-2px" }}
|
||||
/>
|
||||
{"\u00A0"}
|
||||
{destination}
|
||||
</Text>
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-edr-soft px-2.5 py-1 text-[11px] font-medium text-edr-primary-dark">
|
||||
<span className="size-1.5 rounded-full bg-edr-primary-dark" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomsBadge({ customs }: { customs: boolean }) {
|
||||
return customs ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={11} />}
|
||||
function DirectionPill({ direction }: { direction: string }) {
|
||||
const isImport = direction === "IMPORT";
|
||||
const Icon = isImport ? Truck : ShipWheel;
|
||||
const color = isImport ? "blue" : "teal";
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-[5px] px-1.5 py-[2px] text-[10px] font-medium leading-none"
|
||||
style={{
|
||||
background: `var(--mantine-color-${color}-0)`,
|
||||
color: `var(--mantine-color-${color}-7)`,
|
||||
}}
|
||||
>
|
||||
Customs
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
No customs
|
||||
</Badge>
|
||||
<Icon size={10} />
|
||||
{prettyStatus(direction)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function OutlinePill({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-[5px] border border-edr-border px-1.5 py-[2px] text-[10px] leading-none text-edr-muted">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteCell({
|
||||
origin,
|
||||
destination,
|
||||
direction,
|
||||
freightType,
|
||||
customs,
|
||||
}: {
|
||||
origin: string;
|
||||
destination: string;
|
||||
direction: string;
|
||||
freightType: string;
|
||||
customs: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap={5} py={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text fz={12.5} fw={500} c="edr-text">
|
||||
{origin}
|
||||
</Text>
|
||||
<ArrowRight size={12} className="shrink-0 text-edr-muted" />
|
||||
<Text fz={12.5} fw={500} c="edr-text">
|
||||
{destination}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<DirectionPill direction={direction} />
|
||||
<OutlinePill>{prettyStatus(freightType)}</OutlinePill>
|
||||
{customs ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-[5px] bg-edr-soft px-1.5 py-[2px] text-[10px] font-medium leading-none text-edr-primary-dark">
|
||||
<ShieldCheck size={10} />
|
||||
Customs
|
||||
</span>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,13 +216,21 @@ export default function ContractClearanceListPage() {
|
||||
!isDjiboutiGl(user);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [tab, setTab] = useState<TabKey>("all");
|
||||
const [freight, setFreight] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const resetPage = useCallback(
|
||||
() => setPagination({ pageIndex: 0, pageSize: pagination.pageSize }),
|
||||
[setPagination, pagination.pageSize],
|
||||
);
|
||||
|
||||
const {
|
||||
data: bookingQueue,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetching,
|
||||
dataUpdatedAt,
|
||||
refetch,
|
||||
} = useBookingEtClearanceQueue(true);
|
||||
|
||||
@@ -149,7 +244,8 @@ export default function ContractClearanceListPage() {
|
||||
const requestedByBooking = useMemo(() => {
|
||||
const map = new Map<string, Freight.RequestedShipmentLines>();
|
||||
for (const req of requestQueue ?? []) {
|
||||
if (req.createdBookingId) map.set(req.createdBookingId, req.requestedLines);
|
||||
if (req.createdBookingId)
|
||||
map.set(req.createdBookingId, req.requestedLines);
|
||||
}
|
||||
return map;
|
||||
}, [requestQueue]);
|
||||
@@ -170,7 +266,8 @@ export default function ContractClearanceListPage() {
|
||||
contractId: b.contractId ?? null,
|
||||
contractReference: b.contractReference ?? null,
|
||||
contractKind: b.contractKind ?? null,
|
||||
customs: b.serviceType?.includesCustoms ?? Boolean(b.customsClearingEnabled),
|
||||
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.
|
||||
@@ -178,23 +275,9 @@ export default function ContractClearanceListPage() {
|
||||
})) as ShipmentBookingRow[];
|
||||
}, [bookingQueue, requestedByBooking]);
|
||||
|
||||
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.contractReference ?? "").toLowerCase().includes(q) ||
|
||||
r.originLabel.toLowerCase().includes(q) ||
|
||||
r.destinationLabel.toLowerCase().includes(q) ||
|
||||
summarizeRequestedCargo(r.requested).toLowerCase().includes(q),
|
||||
);
|
||||
}, [allRows, query]);
|
||||
|
||||
const counts = useMemo(
|
||||
// KPI groups span the whole queue, regardless of tab/filters.
|
||||
const groups = useMemo(
|
||||
() => ({
|
||||
all: allRows.length,
|
||||
// Counts anything actually waiting on GL, including a document added
|
||||
// after clearance was finalized (the status stays CLEARANCE_READY).
|
||||
review: allRows.filter(
|
||||
@@ -202,13 +285,72 @@ export default function ContractClearanceListPage() {
|
||||
r.status === "AWAITING_DOCUMENTS" ||
|
||||
r.status === "DOCUMENTS_UNDER_REVIEW" ||
|
||||
r.hasDocumentsAwaitingReview,
|
||||
).length,
|
||||
ready: allRows.filter((r) => r.status === "CLEARANCE_READY" || r.bookingCreated)
|
||||
.length,
|
||||
),
|
||||
approval: allRows.filter((r) => r.hasDocumentsAwaitingReview),
|
||||
ready: allRows.filter(
|
||||
(r) => r.status === "CLEARANCE_READY" || r.bookingCreated,
|
||||
),
|
||||
}),
|
||||
[allRows],
|
||||
);
|
||||
const newToday = perDay(allRows, 1)[0];
|
||||
|
||||
const tabCounts = useMemo<Record<TabKey, number>>(
|
||||
() => ({
|
||||
all: allRows.length,
|
||||
import: allRows.filter((r) => r.tradeDirection === "IMPORT").length,
|
||||
export: allRows.filter((r) => r.tradeDirection === "EXPORT").length,
|
||||
review: groups.approval.length,
|
||||
}),
|
||||
[allRows, groups.approval.length],
|
||||
);
|
||||
|
||||
const statusOptions = useMemo(
|
||||
() =>
|
||||
[...new Set(allRows.map((r) => r.status))].sort().map((s) => ({
|
||||
value: s,
|
||||
label: prettyStatus(s),
|
||||
})),
|
||||
[allRows],
|
||||
);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return allRows.filter((r) => {
|
||||
if (tab === "review" && !r.hasDocumentsAwaitingReview) return false;
|
||||
if (
|
||||
(tab === "import" || tab === "export") &&
|
||||
r.tradeDirection !== tab.toUpperCase()
|
||||
)
|
||||
return false;
|
||||
if (freight && r.freightType !== freight) return false;
|
||||
if (status && r.status !== status) return false;
|
||||
if (!q) return true;
|
||||
return [
|
||||
r.reference,
|
||||
r.customerLabel,
|
||||
r.contractReference ?? "",
|
||||
r.originLabel,
|
||||
r.destinationLabel,
|
||||
summarizeRequestedCargo(r.requested),
|
||||
].some((v) => v.toLowerCase().includes(q));
|
||||
});
|
||||
}, [allRows, tab, freight, status, 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 hasFilters = Boolean(query || freight || status);
|
||||
const clearFilters = useCallback(() => {
|
||||
setQuery("");
|
||||
setFreight(null);
|
||||
setStatus(null);
|
||||
resetPage();
|
||||
}, [resetPage]);
|
||||
|
||||
const openBooking = useCallback(
|
||||
// `from` so the detail page's Back returns to this hub.
|
||||
@@ -223,31 +365,20 @@ export default function ContractClearanceListPage() {
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Document Clearance"
|
||||
title="Clearance queue"
|
||||
subtitle="Every customs shipment in phased clearance — the documents live on the shipment, not on the contract."
|
||||
meta={
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={13} />}
|
||||
>
|
||||
{counts.all} in clearance
|
||||
</Badge>
|
||||
}
|
||||
meta={<LivePill updatedAt={dataUpdatedAt} />}
|
||||
action={
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
onClick={() => void refetch()}
|
||||
loading={isFetching}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="sm"
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -256,65 +387,220 @@ export default function ContractClearanceListPage() {
|
||||
items={[
|
||||
{
|
||||
label: "In clearance",
|
||||
value: counts.all,
|
||||
value: allRows.length,
|
||||
icon: Inbox,
|
||||
color: "edr-green",
|
||||
color: "blue",
|
||||
hint: newToday ? `+${newToday} today` : undefined,
|
||||
spark: perDay(allRows),
|
||||
},
|
||||
{
|
||||
label: "Awaiting review",
|
||||
value: counts.review,
|
||||
value: groups.review.length,
|
||||
icon: ShieldCheck,
|
||||
color: "yellow",
|
||||
spark: perDay(groups.review),
|
||||
},
|
||||
{
|
||||
label: "Needs approval",
|
||||
value: groups.approval.length,
|
||||
icon: TriangleAlert,
|
||||
color: "red",
|
||||
spark: perDay(groups.approval),
|
||||
},
|
||||
{
|
||||
label: "Ready / booked",
|
||||
value: counts.ready,
|
||||
value: groups.ready.length,
|
||||
icon: PackageCheck,
|
||||
color: "edr-green",
|
||||
spark: perDay(groups.ready),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<GlUpcomingWindowsSection />
|
||||
|
||||
<Card p={0} withBorder shadow="sm" radius="lg">
|
||||
<Card
|
||||
p={0}
|
||||
withBorder
|
||||
shadow="sm"
|
||||
radius="lg"
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search shipment, contract, 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("")}
|
||||
{/* ── Tabs ─────────────────────────────────────────────── */}
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="stretch"
|
||||
px="md"
|
||||
h={46}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--mantine-color-edr-border-6)",
|
||||
}}
|
||||
>
|
||||
<Group gap={2} wrap="nowrap" align="stretch">
|
||||
{TABS.map((t) => {
|
||||
const active = tab === t.key;
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={t.key}
|
||||
onClick={() => {
|
||||
setTab(t.key);
|
||||
resetPage();
|
||||
}}
|
||||
px={13}
|
||||
className="flex items-center gap-2 transition-colors"
|
||||
style={{
|
||||
borderBottom: `2px solid ${
|
||||
active
|
||||
? "var(--mantine-color-edr-green-6)"
|
||||
: "transparent"
|
||||
}`,
|
||||
marginBottom: -1,
|
||||
}}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
style={{
|
||||
color: active
|
||||
? "var(--mantine-color-edr-green-6)"
|
||||
: "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
fz={13}
|
||||
fw={active ? 600 : 500}
|
||||
c={active ? "edr-text" : "edr-muted"}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
radius="lg"
|
||||
style={{ flex: 1, minWidth: 220 }}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{rows.length} record{rows.length !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
{t.label}
|
||||
</Text>
|
||||
<span
|
||||
className="rounded-full px-1.5 py-px text-[10.5px] font-semibold leading-[1.4]"
|
||||
style={{
|
||||
background: active
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: "var(--mantine-color-gray-1)",
|
||||
color: active
|
||||
? "var(--mantine-color-edr-green-7)"
|
||||
: "var(--mantine-color-edr-muted-6)",
|
||||
}}
|
||||
>
|
||||
{tabCounts[t.key]}
|
||||
</span>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
<Text
|
||||
fz={12}
|
||||
c="edr-muted"
|
||||
className="self-center whitespace-nowrap"
|
||||
>
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* ── Filter bar ───────────────────────────────────────── */}
|
||||
<Group
|
||||
gap={9}
|
||||
px="md"
|
||||
py={12}
|
||||
wrap="wrap"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--mantine-color-edr-divider-6)",
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
placeholder="Search reference, customer, contract, or route…"
|
||||
leftSection={<Search size={15} />}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.currentTarget.value);
|
||||
resetPage();
|
||||
}}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
resetPage();
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
radius="md"
|
||||
size="sm"
|
||||
styles={{
|
||||
input: { background: "var(--mantine-color-gray-0)" },
|
||||
}}
|
||||
style={{ flex: 1, minWidth: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Freight"
|
||||
data={[
|
||||
{ value: "CONTAINER", label: "Container" },
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
]}
|
||||
value={freight}
|
||||
onChange={(v) => {
|
||||
setFreight(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="md"
|
||||
size="sm"
|
||||
w={124}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
aria-label="Filter by freight type"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
data={statusOptions}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="md"
|
||||
size="sm"
|
||||
w={180}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
aria-label="Filter by status"
|
||||
/>
|
||||
{hasFilters ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<ShipmentBookingsTable
|
||||
rows={rows}
|
||||
rows={pagedRows}
|
||||
total={total}
|
||||
pageCount={pageCount}
|
||||
pagination={pagination}
|
||||
setPagination={setPagination}
|
||||
loading={isLoading}
|
||||
error={isError}
|
||||
hasFilters={hasFilters}
|
||||
onClearFilters={clearFilters}
|
||||
canCreateBooking={canCreateBooking}
|
||||
onOpen={openBooking}
|
||||
onCreateBooking={(row) =>
|
||||
@@ -337,7 +623,6 @@ export default function ContractClearanceListPage() {
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -367,43 +652,19 @@ interface ShipmentBookingRow {
|
||||
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()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/^\w/, (c) => c.toUpperCase());
|
||||
|
||||
const shipmentStatusColor = (s: string) => {
|
||||
if (s === "AWAITING_DOCUMENTS") return "yellow";
|
||||
if (s === "DOCUMENTS_UNDER_REVIEW") return "blue";
|
||||
if (s === "CLEARANCE_READY") return "edr-green";
|
||||
if (
|
||||
[
|
||||
"SELECTED_FOR_BATCH",
|
||||
"PNR_GENERATED",
|
||||
"AWAITING_PAYMENT",
|
||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
].includes(s)
|
||||
)
|
||||
return "violet";
|
||||
if (s === "EXPIRED") return "orange";
|
||||
if (s === "CANCELLED" || s === "REJECTED") return "red";
|
||||
return "gray";
|
||||
};
|
||||
type PaginationState = ReturnType<typeof usePagination>["pagination"];
|
||||
|
||||
/** GENERAL-contract shipment bookings currently in per-booking clearance. */
|
||||
function ShipmentBookingsTable({
|
||||
rows,
|
||||
total,
|
||||
pageCount,
|
||||
pagination,
|
||||
setPagination,
|
||||
loading,
|
||||
error,
|
||||
hasFilters,
|
||||
onClearFilters,
|
||||
canCreateBooking,
|
||||
onOpen,
|
||||
onCreateBooking,
|
||||
@@ -411,8 +672,14 @@ function ShipmentBookingsTable({
|
||||
onViewContract,
|
||||
}: {
|
||||
rows: ShipmentBookingRow[];
|
||||
total: number;
|
||||
pageCount: number;
|
||||
pagination: PaginationState;
|
||||
setPagination: ReturnType<typeof usePagination>["setPagination"];
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
hasFilters: boolean;
|
||||
onClearFilters: () => void;
|
||||
canCreateBooking: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
onCreateBooking: (row: ShipmentBookingRow) => void;
|
||||
@@ -440,18 +707,23 @@ function ShipmentBookingsTable({
|
||||
id: "booking",
|
||||
header: () => <span className={bookingTable.headerCell}>Booking</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<PackageCheck className="size-4" strokeWidth={1.75} />
|
||||
<div className="flex items-center gap-2.5 py-1">
|
||||
<div className="flex size-[30px] shrink-0 items-center justify-center rounded-[9px] bg-edr-divider text-edr-muted">
|
||||
<PackageCheck size={15} strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground">
|
||||
<Text fz={13} fw={600} c="edr-text">
|
||||
{row.original.reference}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{row.original.customerLabel}
|
||||
</p>
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap" align="flex-start">
|
||||
<Building2
|
||||
size={10}
|
||||
className="mt-[3px] shrink-0 text-edr-muted opacity-70"
|
||||
/>
|
||||
<Text fz={11} c="edr-muted" className="cell-wrap">
|
||||
{row.original.customerLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
@@ -462,17 +734,19 @@ function ShipmentBookingsTable({
|
||||
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}>
|
||||
<Stack gap={3} py={2}>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<FileText size={12} className="shrink-0 text-edr-muted" />
|
||||
<Text fz={12.5} c="edr-text">
|
||||
{r.contractReference ?? "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
{r.contractKind ? (
|
||||
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
|
||||
{r.contractKind === "GENERAL" ? "General" : "One-time"}
|
||||
</Badge>
|
||||
<Text fz={10.5} c="edr-muted">
|
||||
{r.contractKind === "GENERAL"
|
||||
? "General contract"
|
||||
: "One-time"}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
@@ -481,44 +755,33 @@ function ShipmentBookingsTable({
|
||||
{
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
cell: ({ row }) => (
|
||||
<RouteLabel
|
||||
origin={row.original.originLabel}
|
||||
destination={row.original.destinationLabel}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
header: () => <span className={bookingTable.headerCell}>Type</span>,
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Badge variant="light" color="gray" radius="sm">
|
||||
{prettyStatus(row.original.tradeDirection)}
|
||||
</Badge>
|
||||
<Badge variant="outline" color="gray" radius="sm">
|
||||
{prettyStatus(row.original.freightType)}
|
||||
</Badge>
|
||||
<CustomsBadge customs={row.original.customs} />
|
||||
</Group>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<RouteCell
|
||||
origin={r.originLabel}
|
||||
destination={r.destinationLabel}
|
||||
direction={r.tradeDirection}
|
||||
freightType={r.freightType}
|
||||
customs={r.customs}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "requested",
|
||||
header: () => (
|
||||
<span className={bookingTable.headerCell}>Requested cargo</span>
|
||||
),
|
||||
header: () => <span className={bookingTable.headerCell}>Cargo</span>,
|
||||
cell: ({ row }) => (
|
||||
<RequestedCargoChips lines={row.original.requested} size="sm" />
|
||||
<RequestedCargoChips lines={row.original.requested} size="xs" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
header: () => <span className={bookingTable.headerCell}>Created</span>,
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Calendar size={13} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" c="dimmed">
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<CalendarClock size={12} className="shrink-0 text-edr-muted" />
|
||||
<Text fz={11.5} c="edr-muted">
|
||||
{formatDate(row.original.createdAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -533,14 +796,14 @@ function ShipmentBookingsTable({
|
||||
a file added after clearance was finalized leaves the status at
|
||||
CLEARANCE_READY, and the row must still call for the review. */}
|
||||
{row.original.hasDocumentsAwaitingReview ? (
|
||||
<Badge variant="filled" color="orange" radius="sm">
|
||||
<Badge variant="filled" color="orange" radius="sm" size="sm">
|
||||
Needs approval
|
||||
</Badge>
|
||||
) : /* All docs approved but not yet finalized: the booking status is
|
||||
still DOCUMENTS_UNDER_REVIEW — show the real review state. */
|
||||
row.original.status === "DOCUMENTS_UNDER_REVIEW" &&
|
||||
row.original.allDocsApproved ? (
|
||||
<Badge variant="light" color="edr-green" radius="sm">
|
||||
<Badge variant="light" color="edr-green" radius="sm" size="sm">
|
||||
Documents approved
|
||||
</Badge>
|
||||
) : (
|
||||
@@ -548,6 +811,7 @@ function ShipmentBookingsTable({
|
||||
variant="light"
|
||||
color={shipmentStatusColor(row.original.status)}
|
||||
radius="sm"
|
||||
size="sm"
|
||||
>
|
||||
{prettyStatus(row.original.status)}
|
||||
</Badge>
|
||||
@@ -558,6 +822,7 @@ function ShipmentBookingsTable({
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="sm"
|
||||
size="sm"
|
||||
leftSection={<PackagePlus size={11} />}
|
||||
>
|
||||
Booked
|
||||
@@ -617,7 +882,10 @@ function ShipmentBookingsTable({
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<Eye size={14} />} onClick={() => onOpen(r.id)}>
|
||||
<Menu.Item
|
||||
leftSection={<Eye size={14} />}
|
||||
onClick={() => onOpen(r.id)}
|
||||
>
|
||||
Open booking
|
||||
</Menu.Item>
|
||||
{bookable ? (
|
||||
@@ -655,25 +923,53 @@ function ShipmentBookingsTable({
|
||||
[canCreateBooking, onOpen, onCreateBooking, onViewContract],
|
||||
);
|
||||
|
||||
if (!loading && !error && rows.length === 0) {
|
||||
if (!loading && !error && total === 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 shipment bookings in clearance.</Text>
|
||||
<Text c="dimmed">
|
||||
{hasFilters
|
||||
? "No shipments match these filters."
|
||||
: "No shipment bookings in clearance."}
|
||||
</Text>
|
||||
{hasFilters ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box w="100%" miw={0} style={{ overflowX: "auto" }}>
|
||||
<Box w="100%" miw={0}>
|
||||
<DataTable<ShipmentBookingRow, unknown>
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={loading ? "loading" : error ? "error" : "success"}
|
||||
onRowClick={(row) => onOpen(row.id)}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
|
||||
footer={(p) => <TablePager {...p} noun="shipments" />}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -15,33 +15,33 @@ import {
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { useInterval } from "@mantine/hooks";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
CalendarClock,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Inbox,
|
||||
Layers,
|
||||
PackageCheck,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShipWheel,
|
||||
Truck,
|
||||
User,
|
||||
Weight,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||
import { TablePager } from "@/components/page/TablePager";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
@@ -106,6 +106,20 @@ function statusColor(status: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Rows created/scheduled per day over the last `days` days, oldest → newest. */
|
||||
function perDay(rows: { scheduledDate: string | null }[], days = 8): number[] {
|
||||
const today = new Date().setHours(0, 0, 0, 0);
|
||||
const out = new Array<number>(days).fill(0);
|
||||
for (const r of rows) {
|
||||
if (!r.scheduledDate) continue;
|
||||
const age = Math.floor(
|
||||
(today - new Date(r.scheduledDate).setHours(0, 0, 0, 0)) / 86_400_000,
|
||||
);
|
||||
if (age >= 0 && age < days) out[days - 1 - age] += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── DJ next action (shipments) ───────────────────────────────────────────────
|
||||
|
||||
type DjActionKey = "RO_HOLD" | "COLLECT_DO" | "ISSUE_RO" | "LOADING" | "REVIEW";
|
||||
@@ -180,28 +194,65 @@ function toShipmentRow(b: BookingDetail): ShipmentRow {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tabs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type TabKey = "all" | "import" | "export" | "hold";
|
||||
|
||||
const TABS: { key: TabKey; label: string; icon: LucideIcon }[] = [
|
||||
{ key: "all", label: "All", icon: Layers },
|
||||
{ key: "import", label: "Import", icon: Truck },
|
||||
{ key: "export", label: "Export", icon: ShipWheel },
|
||||
{ key: "hold", label: "On hold", icon: AlertTriangle },
|
||||
];
|
||||
|
||||
// ── Shared cell pieces ───────────────────────────────────────────────────────
|
||||
|
||||
function DirectionIcon({ direction }: { direction: string }) {
|
||||
function LivePill({ updatedAt }: { updatedAt: number }) {
|
||||
// Re-render every 30s so "Xm ago" keeps ticking between refetches.
|
||||
const [, setTick] = useState(0);
|
||||
useInterval(() => setTick((t) => t + 1), 30_000, { autoInvoke: true });
|
||||
const mins = Math.max(0, Math.round((Date.now() - updatedAt) / 60_000));
|
||||
const label = !updatedAt
|
||||
? "Connecting…"
|
||||
: mins < 1
|
||||
? "Live · updated just now"
|
||||
: `Live · updated ${mins}m ago`;
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-edr-soft px-2.5 py-1 text-[11px] font-medium text-edr-primary-dark">
|
||||
<span className="size-1.5 rounded-full bg-edr-primary-dark" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectionPill({ direction }: { direction: string }) {
|
||||
const isImport = direction === "IMPORT";
|
||||
const Icon = isImport ? Truck : ShipWheel;
|
||||
const label = directionLabel(direction);
|
||||
const color = isImport ? "blue" : "teal";
|
||||
return (
|
||||
<Tooltip label={label} withArrow>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={isImport ? "edr-green" : "gray"}
|
||||
radius="md"
|
||||
size={26}
|
||||
aria-label={label}
|
||||
<Tooltip label={directionLabel(direction)} withArrow>
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-[5px] px-1.5 py-[2px] text-[10px] font-medium leading-none"
|
||||
style={{
|
||||
background: `var(--mantine-color-${color}-0)`,
|
||||
color: `var(--mantine-color-${color}-7)`,
|
||||
}}
|
||||
>
|
||||
<Icon size={14} strokeWidth={1.9} />
|
||||
</ThemeIcon>
|
||||
<Icon size={10} />
|
||||
{prettyStatus(direction)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function OutlinePill({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-[5px] border border-edr-border px-1.5 py-[2px] text-[10px] leading-none text-edr-muted">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteCell({
|
||||
origin,
|
||||
destination,
|
||||
@@ -214,30 +265,19 @@ function RouteCell({
|
||||
freightType: string;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap={4} py={2}>
|
||||
{/* Wraps past 120px as "Addis Ababa" / "→ Djibouti"; text wraps
|
||||
normally (cells are otherwise nowrap) so it never spills over. */}
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
maw={120}
|
||||
lh={1.35}
|
||||
style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
|
||||
>
|
||||
{origin}{" "}
|
||||
<ArrowRight
|
||||
size={13}
|
||||
className="text-muted-foreground"
|
||||
style={{ display: "inline-block", verticalAlign: "-2px" }}
|
||||
/>
|
||||
{"\u00A0"}
|
||||
{destination}
|
||||
</Text>
|
||||
<Group gap={8} align="center">
|
||||
<DirectionIcon direction={direction} />
|
||||
<Badge size="xs" variant="default" radius="sm">
|
||||
{freightType}
|
||||
</Badge>
|
||||
<Stack gap={5} py={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text fz={12.5} fw={500} c="edr-text">
|
||||
{origin}
|
||||
</Text>
|
||||
<ArrowRight size={12} className="shrink-0 text-edr-muted" />
|
||||
<Text fz={12.5} fw={500} c="edr-text">
|
||||
{destination}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<DirectionPill direction={direction} />
|
||||
<OutlinePill>{prettyStatus(freightType)}</OutlinePill>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
@@ -254,7 +294,7 @@ function RouteCell({
|
||||
export default function GlDjiboutiClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
const [direction, setDirection] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<TabKey>("all");
|
||||
const [freight, setFreight] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [action, setAction] = useState<string | null>(null);
|
||||
@@ -265,6 +305,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
isLoading: bookingsLoading,
|
||||
isError: bookingsError,
|
||||
isFetching: bookingsFetching,
|
||||
dataUpdatedAt,
|
||||
refetch: refetchBookings,
|
||||
} = useBookingDjClearanceQueue();
|
||||
|
||||
@@ -277,18 +318,30 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
() => (bookingQueue ?? []).map(toShipmentRow),
|
||||
[bookingQueue],
|
||||
);
|
||||
|
||||
// KPI metrics span the whole queue, regardless of filters.
|
||||
const metrics = useMemo(
|
||||
() => ({
|
||||
shipments: allShipmentRows.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,
|
||||
shipments: allShipmentRows,
|
||||
collectDo: allShipmentRows.filter((r) => r.action.key === "COLLECT_DO"),
|
||||
issueRo: allShipmentRows.filter((r) => r.action.key === "ISSUE_RO"),
|
||||
roHolds: allShipmentRows.filter((r) => r.action.key === "RO_HOLD"),
|
||||
}),
|
||||
[allShipmentRows],
|
||||
);
|
||||
|
||||
const tabCounts = useMemo<Record<TabKey, number>>(
|
||||
() => ({
|
||||
all: allShipmentRows.length,
|
||||
import: allShipmentRows.filter((r) => r.tradeDirection === "IMPORT")
|
||||
.length,
|
||||
export: allShipmentRows.filter((r) => r.tradeDirection === "EXPORT")
|
||||
.length,
|
||||
hold: metrics.roHolds.length,
|
||||
}),
|
||||
[allShipmentRows, metrics.roHolds.length],
|
||||
);
|
||||
|
||||
const statusOptions = useMemo(
|
||||
() =>
|
||||
[...new Set(allShipmentRows.map((r) => r.status))].sort().map((s) => ({
|
||||
@@ -298,64 +351,45 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
[allShipmentRows],
|
||||
);
|
||||
|
||||
const matchesShared = useCallback(
|
||||
(
|
||||
r: {
|
||||
reference: string;
|
||||
customerLabel: string;
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
tradeDirection: string;
|
||||
freightType: string;
|
||||
status: string;
|
||||
},
|
||||
extraSearchFields: string[] = [],
|
||||
) => {
|
||||
if (direction && r.tradeDirection !== direction) return false;
|
||||
const shipmentRows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return allShipmentRows.filter((r) => {
|
||||
if (tab === "hold" && r.action.key !== "RO_HOLD") return false;
|
||||
if (
|
||||
(tab === "import" || tab === "export") &&
|
||||
r.tradeDirection !== tab.toUpperCase()
|
||||
)
|
||||
return false;
|
||||
if (freight && r.freightType !== freight) return false;
|
||||
if (status && r.status !== status) return false;
|
||||
const q = query.trim().toLowerCase();
|
||||
if (action && r.action.key !== action) return false;
|
||||
if (!q) return true;
|
||||
return [
|
||||
r.reference,
|
||||
r.customerLabel,
|
||||
r.contractReference,
|
||||
r.originLabel,
|
||||
r.destinationLabel,
|
||||
prettyStatus(r.status),
|
||||
...extraSearchFields,
|
||||
].some((v) => v.toLowerCase().includes(q));
|
||||
},
|
||||
[direction, freight, status, query],
|
||||
);
|
||||
|
||||
const shipmentRows = useMemo(
|
||||
() =>
|
||||
allShipmentRows.filter(
|
||||
(r) =>
|
||||
(!action || r.action.key === action) &&
|
||||
// Shipments also match the parent contract reference in search.
|
||||
matchesShared(r, [r.contractReference]),
|
||||
),
|
||||
[allShipmentRows, action, matchesShared],
|
||||
);
|
||||
});
|
||||
}, [allShipmentRows, tab, freight, status, action, query]);
|
||||
|
||||
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;
|
||||
|
||||
const pagedShipmentRows = useMemo(() => {
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
return shipmentRows.slice(start, start + pagination.pageSize);
|
||||
}, [shipmentRows, pagination.pageIndex, pagination.pageSize]);
|
||||
|
||||
const hasFilters = Boolean(query || direction || freight || status || action);
|
||||
const hasFilters = Boolean(query || freight || status || action);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setQuery("");
|
||||
setDirection(null);
|
||||
setFreight(null);
|
||||
setStatus(null);
|
||||
setAction(null);
|
||||
@@ -379,18 +413,23 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<PackageCheck className="size-4" strokeWidth={1.75} />
|
||||
<div className="flex items-center gap-2.5 py-1">
|
||||
<div className="flex size-[30px] shrink-0 items-center justify-center rounded-[9px] bg-edr-divider text-edr-muted">
|
||||
<PackageCheck size={15} strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground">
|
||||
<Text fz={13} fw={600} c="edr-text">
|
||||
{r.reference}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{r.customerLabel}
|
||||
</p>
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap" align="flex-start">
|
||||
<Building2
|
||||
size={10}
|
||||
className="mt-[3px] shrink-0 text-edr-muted opacity-70"
|
||||
/>
|
||||
<Text fz={11} c="edr-muted" className="cell-wrap">
|
||||
{r.customerLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -400,9 +439,11 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
id: "contract",
|
||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FileText size={13} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm">{row.original.contractReference}</Text>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<FileText size={12} className="shrink-0 text-edr-muted" />
|
||||
<Text fz={12.5} c="edr-text">
|
||||
{row.original.contractReference}
|
||||
</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
@@ -428,9 +469,11 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Stack gap={4} py={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Weight size={13} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm">{r.weightTons} t</Text>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<Weight size={12} className="shrink-0 text-edr-muted" />
|
||||
<Text fz={12} fw={500} c="edr-text">
|
||||
{r.weightTons} t
|
||||
</Text>
|
||||
</Group>
|
||||
{r.isHazardous ? (
|
||||
<Badge
|
||||
@@ -449,7 +492,9 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
header: () => <span className={bookingTable.headerCell}>DJ action</span>,
|
||||
header: () => (
|
||||
<span className={bookingTable.headerCell}>DJ action</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
const badge = (
|
||||
@@ -466,7 +511,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
) : (
|
||||
badge
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
<Text fz={10.5} c="edr-muted">
|
||||
{phaseLabel(r.phase)}
|
||||
</Text>
|
||||
</Stack>
|
||||
@@ -489,11 +534,13 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
},
|
||||
{
|
||||
id: "scheduled",
|
||||
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
|
||||
header: () => (
|
||||
<span className={bookingTable.headerCell}>Scheduled</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<CalendarClock size={13} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" c="dimmed">
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<CalendarClock size={12} className="shrink-0 text-edr-muted" />
|
||||
<Text fz={11.5} c="edr-muted">
|
||||
{formatDate(row.original.scheduledDate)}
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -505,7 +552,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
header: "",
|
||||
cell: () => (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<ChevronRight size={16} className="text-muted-foreground" />
|
||||
<ChevronRight size={16} className="text-edr-muted" />
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
@@ -519,17 +566,18 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
<PageHeader
|
||||
title="GL Djibouti — Clearance"
|
||||
subtitle="Customs shipments handed off to Djibouti GL — every clearance step lives on the shipment."
|
||||
meta={<LivePill updatedAt={dataUpdatedAt} />}
|
||||
action={
|
||||
<ActionIcon
|
||||
<Button
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
size="sm"
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
loading={isFetching}
|
||||
onClick={handleRefresh}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -538,139 +586,230 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
items={[
|
||||
{
|
||||
label: "Shipments in queue",
|
||||
value: metrics.shipments,
|
||||
value: metrics.shipments.length,
|
||||
icon: PackageCheck,
|
||||
color: "blue",
|
||||
spark: perDay(metrics.shipments),
|
||||
},
|
||||
{
|
||||
label: "Imports — collect DO",
|
||||
value: metrics.collectDo,
|
||||
value: metrics.collectDo.length,
|
||||
icon: Truck,
|
||||
color: "yellow",
|
||||
spark: perDay(metrics.collectDo),
|
||||
},
|
||||
{
|
||||
label: "Exports — issue RO",
|
||||
value: metrics.issueRo,
|
||||
value: metrics.issueRo.length,
|
||||
icon: ShipWheel,
|
||||
color: "blue",
|
||||
spark: perDay(metrics.issueRo),
|
||||
},
|
||||
{
|
||||
label: "RO amendment holds",
|
||||
value: metrics.roHolds,
|
||||
value: metrics.roHolds.length,
|
||||
icon: AlertTriangle,
|
||||
color: "red",
|
||||
spark: perDay(metrics.roHolds),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card p={0} withBorder shadow="sm" radius="lg">
|
||||
<Card
|
||||
p={0}
|
||||
withBorder
|
||||
shadow="sm"
|
||||
radius="lg"
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search reference, customer, route, or status…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.currentTarget.value);
|
||||
resetPage();
|
||||
}}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
resetPage();
|
||||
{/* ── Tabs ─────────────────────────────────────────────── */}
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="stretch"
|
||||
px="md"
|
||||
h={46}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--mantine-color-edr-border-6)",
|
||||
}}
|
||||
>
|
||||
<Group gap={2} wrap="nowrap" align="stretch">
|
||||
{TABS.map((t) => {
|
||||
const active = tab === t.key;
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={t.key}
|
||||
onClick={() => {
|
||||
setTab(t.key);
|
||||
resetPage();
|
||||
}}
|
||||
px={13}
|
||||
className="flex items-center gap-2 transition-colors"
|
||||
style={{
|
||||
borderBottom: `2px solid ${
|
||||
active
|
||||
? "var(--mantine-color-edr-green-6)"
|
||||
: "transparent"
|
||||
}`,
|
||||
marginBottom: -1,
|
||||
}}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
style={{
|
||||
color: active
|
||||
? "var(--mantine-color-edr-green-6)"
|
||||
: "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
fz={13}
|
||||
fw={active ? 600 : 500}
|
||||
c={active ? "edr-text" : "edr-muted"}
|
||||
>
|
||||
{t.label}
|
||||
</Text>
|
||||
<span
|
||||
className="rounded-full px-1.5 py-px text-[10.5px] font-semibold leading-[1.4]"
|
||||
style={{
|
||||
background: active
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: "var(--mantine-color-gray-1)",
|
||||
color: active
|
||||
? "var(--mantine-color-edr-green-7)"
|
||||
: "var(--mantine-color-edr-muted-6)",
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
radius="lg"
|
||||
style={{ flex: 1, minWidth: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Direction"
|
||||
data={[
|
||||
{ value: "IMPORT", label: "Import" },
|
||||
{ value: "EXPORT", label: "Export" },
|
||||
]}
|
||||
value={direction}
|
||||
onChange={(v) => {
|
||||
setDirection(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
w={130}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Freight"
|
||||
data={[
|
||||
{ value: "CONTAINER", label: "Container" },
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
]}
|
||||
value={freight}
|
||||
onChange={(v) => {
|
||||
setFreight(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
w={130}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
data={statusOptions}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
w={190}
|
||||
/>
|
||||
<Select
|
||||
placeholder="DJ action"
|
||||
data={DJ_ACTION_OPTIONS}
|
||||
value={action}
|
||||
onChange={(v) => {
|
||||
setAction(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
w={180}
|
||||
/>
|
||||
{hasFilters ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
{tabCounts[t.key]}
|
||||
</span>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
<Text
|
||||
fz={12}
|
||||
c="edr-muted"
|
||||
className="self-center whitespace-nowrap"
|
||||
>
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{showEmpty ? (
|
||||
{/* ── Filter bar ───────────────────────────────────────── */}
|
||||
<Group
|
||||
gap={9}
|
||||
px="md"
|
||||
py={12}
|
||||
wrap="wrap"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--mantine-color-edr-divider-6)",
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
placeholder="Search reference, customer, route, or status…"
|
||||
leftSection={<Search size={15} />}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.currentTarget.value);
|
||||
resetPage();
|
||||
}}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
resetPage();
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
radius="md"
|
||||
size="sm"
|
||||
styles={{
|
||||
input: { background: "var(--mantine-color-gray-0)" },
|
||||
}}
|
||||
style={{ flex: 1, minWidth: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Freight"
|
||||
data={[
|
||||
{ value: "CONTAINER", label: "Container" },
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
]}
|
||||
value={freight}
|
||||
onChange={(v) => {
|
||||
setFreight(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="md"
|
||||
size="sm"
|
||||
w={124}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
aria-label="Filter by freight type"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
data={statusOptions}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="md"
|
||||
size="sm"
|
||||
w={180}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
aria-label="Filter by status"
|
||||
/>
|
||||
<Select
|
||||
placeholder="DJ action"
|
||||
data={DJ_ACTION_OPTIONS}
|
||||
value={action}
|
||||
onChange={(v) => {
|
||||
setAction(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="md"
|
||||
size="sm"
|
||||
w={170}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
aria-label="Filter by DJ action"
|
||||
/>
|
||||
{hasFilters ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{!isLoading && !isError && total === 0 ? (
|
||||
<Stack align="center" gap={8} py={48}>
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||
<Inbox size={22} />
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed">
|
||||
{hasFilters
|
||||
? "No records match these filters."
|
||||
? "No shipments match these filters."
|
||||
: "No shipments awaiting a Djibouti action."}
|
||||
</Text>
|
||||
{hasFilters ? (
|
||||
@@ -686,7 +825,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
) : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box w="100%" miw={0} style={{ overflowX: "auto" }}>
|
||||
<Box w="100%" miw={0}>
|
||||
<DataTable<ShipmentRow, unknown>
|
||||
columns={shipmentColumns}
|
||||
data={pagedShipmentRows}
|
||||
@@ -705,7 +844,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
footer={(p) => <TablePager {...p} noun="shipments" />}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -32,6 +32,22 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/*
|
||||
* Booking (col 1) and Contract (col 2) carry free-text company/contract names.
|
||||
* Cap those two columns and let their content wrap onto 2+ lines so a very long
|
||||
* name (e.g. "SHAFICI PHARMACEUTICAL MEDICAL SUPPLIES WHOLESALER PARTINERSHIP")
|
||||
* stacks inside its own cell instead of shoving the next column off-screen.
|
||||
* Everything below the header row so the header labels still sit on one line.
|
||||
*/
|
||||
.edr-clearance-table tbody td:not([colspan]):nth-child(1) {
|
||||
max-width: 240px;
|
||||
white-space: normal;
|
||||
}
|
||||
.edr-clearance-table tbody td:not([colspan]):nth-child(2) {
|
||||
max-width: 200px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/*
|
||||
* Mantine Badge caps itself at max-width: 100%; inside an auto-layout table
|
||||
* cell that resolves against min-content and clips the label. Let badges size
|
||||
@@ -41,6 +57,22 @@
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Opt-out for long free text (company/customer names). The blanket nowrap rule
|
||||
* above keeps every cell on one line so columns size to content; a very long
|
||||
* name would otherwise force the column absurdly wide. Mark such text with
|
||||
* `cell-wrap` to cap it and wrap onto 2+ lines instead of pushing the layout.
|
||||
*/
|
||||
.edr-clearance-table .cell-wrap,
|
||||
.edr-clearance-table .mantine-Group-root > .cell-wrap {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/*
|
||||
* Mantine Group's preventGrowOverflow caps every child at 100%/N of the cell.
|
||||
* In an auto-width table cell that resolves against min-content and collapses
|
||||
@@ -70,7 +102,7 @@
|
||||
min-width: 0;
|
||||
position: sticky;
|
||||
right: 0;
|
||||
box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3);
|
||||
box-shadow: -10px 0 14px -8px rgba(16, 32, 47, 0.12);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -78,17 +110,41 @@
|
||||
* background or the columns underneath show through.
|
||||
*/
|
||||
.edr-clearance-table td:last-child:not([colspan]) {
|
||||
background: #f5f8fb;
|
||||
background: var(--mantine-color-body);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Row hover uses the tailwind `hover:bg-accent` class on the <tr>. */
|
||||
.edr-clearance-table tbody tr:hover td:last-child:not([colspan]) {
|
||||
background: var(--accent, #f4fbf8);
|
||||
background: #f7fbf9;
|
||||
}
|
||||
|
||||
/* Header cell is sticky on both axes — it must outrank the body's sticky column. */
|
||||
.edr-clearance-table th:last-child {
|
||||
background: #f4f7fa;
|
||||
background: var(--mantine-color-gray-0);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* ── Design pass: flat head band, 64px rows, hairline dividers ─────────── */
|
||||
.edr-clearance-table thead th {
|
||||
height: 38px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
background: var(--mantine-color-gray-0);
|
||||
border-bottom: 1px solid var(--mantine-color-edr-divider-6);
|
||||
}
|
||||
|
||||
.edr-clearance-table tbody td:not([colspan]) {
|
||||
height: 64px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--mantine-color-edr-divider-6);
|
||||
}
|
||||
|
||||
.edr-clearance-table tbody tr:last-child td:not([colspan]) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.edr-clearance-table tbody tr:hover td {
|
||||
background: #f7fbf9;
|
||||
}
|
||||
|
||||
@@ -95,12 +95,17 @@ function PayWindowCell({ deadline }: { deadline: string | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** `invoices.type` of a wagon-cancellation fee — mirrors the API constant. */
|
||||
const WAGON_CANCEL_FEE_INVOICE_TYPE = "WAGON_CANCEL_FEE";
|
||||
|
||||
/**
|
||||
* "Confirm paid" for one row. Booking invoices are only confirmable while the
|
||||
* booking's pay window is open (the API refuses otherwise): no window yet →
|
||||
* no button; window closed → button disabled with the reason, and it flips
|
||||
* live the second the countdown hits zero. Non-booking invoices (warehouse,
|
||||
* clearance…) have no window and stay confirmable.
|
||||
* clearance…) have no window and stay confirmable — and so do
|
||||
* wagon-cancellation fees, which ride source=booking but are raised on an
|
||||
* already-paid booking whose window has closed.
|
||||
*/
|
||||
function ConfirmCell({
|
||||
row,
|
||||
@@ -109,10 +114,11 @@ function ConfirmCell({
|
||||
row: OfflineUsdInvoice;
|
||||
onConfirm: (row: OfflineUsdInvoice) => void;
|
||||
}) {
|
||||
const deadline = row.booking?.paymentDeadline ?? null;
|
||||
const feeInvoice = row.type === WAGON_CANCEL_FEE_INVOICE_TYPE;
|
||||
const deadline = feeInvoice ? null : (row.booking?.paymentDeadline ?? null);
|
||||
const now = useNow(deadline);
|
||||
|
||||
if (row.booking && !deadline) return null;
|
||||
if (row.booking && !deadline && !feeInvoice) return null;
|
||||
const closed = Boolean(deadline && new Date(deadline).getTime() <= now);
|
||||
|
||||
return (
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
import {
|
||||
DEFAULT_CONFIGURATION_SLUG,
|
||||
DEFAULT_RULES_SLUG,
|
||||
ROUTE_SCOPED_TRIGGERS,
|
||||
RULE_ENGINE_CATEGORY_BASE_PATH,
|
||||
RULE_ENGINE_SELECT_NONE,
|
||||
getRuleEngineResource,
|
||||
@@ -120,9 +121,7 @@ const yardOptionsForLegEnd = (
|
||||
// direction + route, so their yard dropdowns narrow exactly like base
|
||||
// freight.
|
||||
(appliesTo === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(
|
||||
String(values.trigger ?? ""),
|
||||
))
|
||||
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")))
|
||||
) {
|
||||
const direction = String(values.tradeDirection ?? "");
|
||||
// Direction is what decides the countries, so offer nothing until it is set
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface FormFieldDef {
|
||||
disabled?: boolean;
|
||||
/** Editable on create, locked when editing an existing record. */
|
||||
disabledOnEdit?: boolean;
|
||||
/** Lock the field while the predicate accepts the live form values. */
|
||||
disabledIf?: (values: Record<string, unknown>) => boolean;
|
||||
/** Trailing unit label shown inside the input (e.g. "USD" on a rate value). */
|
||||
suffix?: string;
|
||||
/** Hide this field when another field currently equals one of these values. */
|
||||
@@ -227,6 +229,10 @@ const RATE_TRIGGERS = [
|
||||
{ label: "Cancellation (per wagon, per direction + cargo type)", value: "CANCELLATION" },
|
||||
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
||||
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
|
||||
{
|
||||
label: "Ethiopian customs clearance service fee (Ethiopian-side-only services)",
|
||||
value: "ETHIOPIAN_CUSTOMS_CLEARANCE",
|
||||
},
|
||||
{ label: "Fuel (per lane + cargo type)", value: "FUEL" },
|
||||
];
|
||||
|
||||
@@ -285,8 +291,16 @@ const SHIPPING_LINE_CARGO_KINDS = [
|
||||
const isBaseFreightRate = (values: Record<string, unknown>) =>
|
||||
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
|
||||
|
||||
/** Surcharges sold per origin → destination leg (mirrors RatesService.isRouteScoped). */
|
||||
export const ROUTE_SCOPED_TRIGGERS = [
|
||||
"CUSTOMS_CLEARANCE",
|
||||
"ETHIOPIAN_CUSTOMS_CLEARANCE",
|
||||
"WITH_RETURN",
|
||||
"FUEL",
|
||||
];
|
||||
|
||||
/**
|
||||
* Rates priced per leg: base rail freight, plus the customs clearance fee and
|
||||
* Rates priced per leg: base rail freight, plus the customs clearance fees and
|
||||
* the empty-container return surcharge (sold per route + container type).
|
||||
*/
|
||||
const isRouteScopedRate = (values: Record<string, unknown>) =>
|
||||
@@ -295,19 +309,19 @@ const isRouteScopedRate = (values: Record<string, unknown>) =>
|
||||
(isShippingLineRate(values)
|
||||
? hasShippingLine(values) &&
|
||||
(values.shippingLineRateKind === "BASE" ||
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(
|
||||
String(values.trigger ?? ""),
|
||||
))
|
||||
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")))
|
||||
: isBaseFreightRate(values)) ||
|
||||
(String(values.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
|
||||
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")));
|
||||
|
||||
/**
|
||||
* Surcharges sold per cargo kind: the admin says container or bulk, then names
|
||||
* the container type or bulk commodity the fee covers.
|
||||
*/
|
||||
const isCargoKindTrigger = (values: Record<string, unknown>) =>
|
||||
["CUSTOMS_CLEARANCE", "CANCELLATION"].includes(String(values.trigger ?? ""));
|
||||
["CUSTOMS_CLEARANCE", "ETHIOPIAN_CUSTOMS_CLEARANCE", "CANCELLATION"].includes(
|
||||
String(values.trigger ?? ""),
|
||||
);
|
||||
|
||||
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
||||
|
||||
@@ -351,6 +365,7 @@ const unitsForShape = (
|
||||
// Wagon cancellation fee — scales with the cancelled wagons only.
|
||||
return ["PER_WAGON"];
|
||||
case "CUSTOMS_CLEARANCE":
|
||||
case "ETHIOPIAN_CUSTOMS_CLEARANCE":
|
||||
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
|
||||
return cargoKind === "BULK"
|
||||
? ["PER_TON", "PER_WAGON"]
|
||||
@@ -888,7 +903,28 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ name: "canBeBookedAlone", label: "Can be booked alone", type: "boolean" },
|
||||
{ name: "includesFirstMile", label: "Includes first mile", type: "boolean" },
|
||||
{ name: "includesLastMile", label: "Includes last mile", type: "boolean" },
|
||||
{ name: "includesCustoms", label: "Includes customs", type: "boolean" },
|
||||
// Full customs and Ethiopian-only customs are alternatives — turning one
|
||||
// on clears and locks the other (see RuleEngineFormDialog.setField). The
|
||||
// API stores includesCustoms = true for both; the toggle shown here is
|
||||
// "full customs", so an Ethiopian-only record reads it back as off.
|
||||
{
|
||||
name: "includesCustoms",
|
||||
label: "Includes customs",
|
||||
type: "boolean",
|
||||
description:
|
||||
"Full customs clearance bundled with the service. Cannot be combined with Ethiopian customs only.",
|
||||
getInitialValue: (record) =>
|
||||
record.includesCustoms === true && record.includesEthiopianCustomsOnly !== true,
|
||||
disabledIf: (v) => v.includesEthiopianCustomsOnly === true,
|
||||
},
|
||||
{
|
||||
name: "includesEthiopianCustomsOnly",
|
||||
label: "Ethiopian customs only",
|
||||
type: "boolean",
|
||||
description:
|
||||
"EDR clears customs on the Ethiopian side only. Same clearance flow; contracts and bookings price off the Ethiopian customs clearance rate. Cannot be combined with Includes customs.",
|
||||
disabledIf: (v) => v.includesCustoms === true,
|
||||
},
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
},
|
||||
@@ -1094,6 +1130,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
label: "Customs clearance",
|
||||
filters: { trigger: "CUSTOMS_CLEARANCE", isShippingLineRate: "false" },
|
||||
},
|
||||
{
|
||||
key: "ethiopian-customs",
|
||||
label: "Ethiopian customs",
|
||||
filters: { trigger: "ETHIOPIAN_CUSTOMS_CLEARANCE", isShippingLineRate: "false" },
|
||||
},
|
||||
{
|
||||
key: "return",
|
||||
label: "Container return",
|
||||
@@ -1248,6 +1289,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
[
|
||||
"CUSTOMS_CLEARANCE",
|
||||
"ETHIOPIAN_CUSTOMS_CLEARANCE",
|
||||
"CANCELLATION",
|
||||
"WITH_RETURN",
|
||||
"LASHING",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Modal,
|
||||
Progress,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
@@ -17,8 +18,10 @@ import { isAxiosError } from "axios";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CalendarClock,
|
||||
History,
|
||||
MapPin,
|
||||
MoreHorizontal,
|
||||
PackageOpen,
|
||||
Power,
|
||||
PowerOff,
|
||||
Replace,
|
||||
@@ -36,6 +39,8 @@ import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel
|
||||
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
|
||||
import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
|
||||
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
|
||||
import DetachedWagonsPanel from "@/components/trainBuilder/DetachedWagonsPanel";
|
||||
import TrainHistoryPanel from "@/components/trainBuilder/TrainHistoryPanel";
|
||||
import {
|
||||
directionColor,
|
||||
locomotiveStatusColor,
|
||||
@@ -111,6 +116,7 @@ export default function TrainBuilderDetailPage() {
|
||||
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
|
||||
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
|
||||
const setWagonYard = useMutation(api.trainBuilder.setWagonYard.mutationOptions());
|
||||
const setWagonsYard = useMutation(api.trainBuilder.setWagonsYard.mutationOptions());
|
||||
const maintenanceWagon = useMutation(
|
||||
api.trainBuilder.sendWagonToMaintenance.mutationOptions(),
|
||||
);
|
||||
@@ -165,6 +171,7 @@ export default function TrainBuilderDetailPage() {
|
||||
assignWagons.isPending ||
|
||||
removeWagon.isPending ||
|
||||
setWagonYard.isPending ||
|
||||
setWagonsYard.isPending ||
|
||||
maintenanceWagon.isPending ||
|
||||
reorderWagons.isPending;
|
||||
|
||||
@@ -230,6 +237,16 @@ export default function TrainBuilderDetailPage() {
|
||||
},
|
||||
[withToast, setWagonYard.mutateAsync, trainId],
|
||||
);
|
||||
const handleChangeWagonsYard = useCallback(
|
||||
(wagonIds: string[], currentYardId: string, onDone: () => void) => {
|
||||
if (!trainId) return;
|
||||
void withToast(async () => {
|
||||
await setWagonsYard.mutateAsync({ id: trainId, wagonIds, currentYardId });
|
||||
onDone();
|
||||
}, "Could not move the selected wagons");
|
||||
},
|
||||
[withToast, setWagonsYard.mutateAsync, trainId],
|
||||
);
|
||||
const handleMaintenance = useCallback(
|
||||
(wagon: TrainCompositionWagon) => setMaintenanceTarget(wagon),
|
||||
[],
|
||||
@@ -371,7 +388,22 @@ export default function TrainBuilderDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
{composition.wagonYards.length > 1 ? (
|
||||
<Tabs defaultValue="build" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="build" leftSection={<TrainIcon size={14} />}>
|
||||
Build
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="detached" leftSection={<PackageOpen size={14} />}>
|
||||
Detached wagons
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<History size={14} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="build" pt="md">
|
||||
<Stack gap="lg">
|
||||
{composition.wagonYards.length > 1 ? (
|
||||
<Alert color="blue" icon={<MapPin size={16} />}>
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" fw={600}>
|
||||
@@ -515,6 +547,9 @@ export default function TrainBuilderDetailPage() {
|
||||
onChangeYard={
|
||||
composition.editable && canChangeWagonYard ? handleChangeWagonYard : undefined
|
||||
}
|
||||
onChangeYardBulk={
|
||||
composition.editable && canChangeWagonYard ? handleChangeWagonsYard : undefined
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
@@ -560,6 +595,22 @@ export default function TrainBuilderDetailPage() {
|
||||
</Stack>
|
||||
</Card>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="detached" pt="md">
|
||||
<DetachedWagonsPanel
|
||||
trainId={composition.id}
|
||||
canAttach={composition.editable && canAssign}
|
||||
attachPending={assignWagons.isPending}
|
||||
onAttach={handleAssign}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history" pt="md">
|
||||
<TrainHistoryPanel trainId={composition.id} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<ChangeLocomotivesModal
|
||||
composition={composition}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
Train,
|
||||
Weight,
|
||||
Workflow as WorkflowIcon,
|
||||
Warehouse,
|
||||
} from "lucide-react";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -59,6 +60,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
|
||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
||||
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
|
||||
import { ScheduleWagonYardPanel } from "@/components/trainScheduling/ScheduleWagonYardPanel";
|
||||
import { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPanel";
|
||||
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
|
||||
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
|
||||
@@ -145,13 +147,36 @@ export default function TrainScheduleV2DetailPage() {
|
||||
input: { id: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId),
|
||||
// Live phase updates come from the booking-window socket (PHASE pushes
|
||||
// invalidate this query); 60s is the self-heal net for a missed emit so
|
||||
// the workspace countdown never freezes on an expired phase.
|
||||
// invalidate this query). The fast self-heal net is the one-row phase
|
||||
// heartbeat below — this long interval is only the last-resort refresh
|
||||
// for changes the schedule row itself never sees.
|
||||
refetchInterval: 300_000,
|
||||
}),
|
||||
);
|
||||
// One-row 60s heartbeat: refetch the (expensive) full detail only when the
|
||||
// schedule row actually changed — same freshness as polling the detail
|
||||
// itself, at a fraction of the server cost.
|
||||
const phaseQuery = useQuery(
|
||||
api.trainScheduling.schedulePhase.queryOptions({
|
||||
input: { id: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId),
|
||||
refetchInterval: 60_000,
|
||||
}),
|
||||
);
|
||||
const lastPhaseSig = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!phaseQuery.data) return;
|
||||
const sig = JSON.stringify(phaseQuery.data);
|
||||
if (lastPhaseSig.current !== null && lastPhaseSig.current !== sig) {
|
||||
void detailQuery.refetch();
|
||||
}
|
||||
lastPhaseSig.current = sig;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [phaseQuery.data]);
|
||||
useBookingWindowSocket(Boolean(scheduleId));
|
||||
const schedule = detailQuery.data;
|
||||
// Controlled so tab-scoped queries (eligible pool) pause on other tabs.
|
||||
const [activeTab, setActiveTab] = useState<string | null>("workflow");
|
||||
const freightType: FreightType | undefined = schedule?.freightType as FreightType | undefined;
|
||||
const isDjiboutiPort = (value?: string | null) =>
|
||||
["DJIBOUTI", "DORALEH", "DMP", "DCT", "NAGAD"].some((token) =>
|
||||
@@ -219,7 +244,9 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const eligibleQuery = useQuery(
|
||||
api.trainScheduling.eligibleBookings.queryOptions({
|
||||
input: { filters: eligibleFilters, freightType: eligibleFreightType },
|
||||
enabled: Boolean(schedule),
|
||||
// The eligible pool feeds the Workflow tab's bookings step only — don't
|
||||
// fetch (or refetch on invalidation) while another tab is open.
|
||||
enabled: Boolean(schedule) && activeTab === "workflow",
|
||||
}),
|
||||
);
|
||||
const preview = useMutation(api.trainScheduling.preview.mutationOptions());
|
||||
@@ -846,6 +873,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
scheduleDetail={schedule}
|
||||
scheduleId={scheduleId ?? ""}
|
||||
maxWagons={schedule.maxWagons ?? 53}
|
||||
showWagonStat={false}
|
||||
/>
|
||||
) : (
|
||||
<TrainCompositionDiagram
|
||||
@@ -1273,7 +1301,13 @@ export default function TrainScheduleV2DetailPage() {
|
||||
) : null}
|
||||
*/}
|
||||
|
||||
<Tabs defaultValue="workflow" radius="md" color="edr-green" keepMounted={false}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
radius="md"
|
||||
color="edr-green"
|
||||
keepMounted={false}
|
||||
>
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="workflow" leftSection={<WorkflowIcon size={16} />}>
|
||||
Workflow
|
||||
@@ -1287,6 +1321,9 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<Tabs.Tab value="leg-board" leftSection={<Grid3x3 size={16} />}>
|
||||
Leg board
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="wagon-yards" leftSection={<Warehouse size={16} />}>
|
||||
Schedule yards
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<HistoryIcon size={16} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
@@ -1382,6 +1419,15 @@ export default function TrainScheduleV2DetailPage() {
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="wagon-yards">
|
||||
{scheduleId ? (
|
||||
<ScheduleWagonYardPanel
|
||||
scheduleId={scheduleId}
|
||||
canEdit={hasPermission(authUser, FREIGHT_PERMS.trainScheduling.update)}
|
||||
/>
|
||||
) : null}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history">
|
||||
{scheduleId ? <ScheduleHistoryPanel scheduleId={scheduleId} /> : null}
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -330,17 +330,6 @@ export default function TrainScheduleV2ListPage() {
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Stack gap={6}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600} lh={1.2}>
|
||||
{row.original.routeName ?? "—"}
|
||||
</Text>
|
||||
{row.original.direction ? (
|
||||
<Badge size="xs" variant="light" color={directionColor(row.original.direction)}>
|
||||
{row.original.direction}
|
||||
</Badge>
|
||||
) : null}
|
||||
<ShippingLineBadge schedule={row.original} />
|
||||
</Group>
|
||||
<Box maw={260}>
|
||||
<RouteCorridor
|
||||
origin={row.original.origin}
|
||||
@@ -349,6 +338,14 @@ export default function TrainScheduleV2ListPage() {
|
||||
orientation="vertical"
|
||||
/>
|
||||
</Box>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{row.original.direction ? (
|
||||
<Badge size="xs" variant="light" color={directionColor(row.original.direction)}>
|
||||
{row.original.direction}
|
||||
</Badge>
|
||||
) : null}
|
||||
<ShippingLineBadge schedule={row.original} />
|
||||
</Group>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
@@ -362,13 +359,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
id: "metrics",
|
||||
header: "Load",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<MetricChip value={row.original.bookingsCount} label="bkg" />
|
||||
<WagonChips schedule={row.original} />
|
||||
<MetricChip value={`${row.original.totalWeightTons}T`} label="" subtle />
|
||||
</Group>
|
||||
),
|
||||
cell: ({ row }) => <MetricChip value={row.original.bookingsCount} label="bkg" />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
@@ -887,14 +878,6 @@ export default function TrainScheduleV2ListPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's wagon chips, matching the detail page's wagon plan: used is slots
|
||||
* carrying a booking allocation, the denominator is the schedule's capacity
|
||||
* (API-computed: the larger of coupled consist and planned `maxWagons`, since
|
||||
* wagons are coupled on demand), and remaining excludes wagons reserved by
|
||||
* bookings that have not paid yet — that space is claimed, so it is not
|
||||
* bookable.
|
||||
*/
|
||||
/** Green tint for departures dedicated to a shipping line (overrides direction tint). */
|
||||
const SHIPPING_LINE_ROW_STYLE = {
|
||||
backgroundColor: "var(--mantine-color-edr-green-0)",
|
||||
@@ -959,30 +942,6 @@ function ShippingLineBadge({ schedule }: { schedule: TrainScheduleListItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
|
||||
// Pre-deploy API rows carry only wagonCount; fall back so the chip still
|
||||
// renders rather than reading 0 used on every train.
|
||||
const total = schedule.wagonsTotal ?? schedule.wagonCount;
|
||||
const used = schedule.wagonsUsed;
|
||||
const reserved = schedule.wagonsReserved ?? 0;
|
||||
const remaining = schedule.wagonsRemaining;
|
||||
|
||||
if (used == null) {
|
||||
return <MetricChip value={total} label="wgn" subtle />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MetricChip
|
||||
value={`${used}/${total}`}
|
||||
label={schedule.wagonCount === 0 ? "wgn planned" : "wgn used"}
|
||||
/>
|
||||
{reserved > used ? <MetricChip value={reserved} label="reserved" subtle /> : null}
|
||||
{remaining != null ? <MetricChip value={remaining} label="bookable" subtle /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricChip({
|
||||
value,
|
||||
label,
|
||||
@@ -1049,9 +1008,6 @@ function ScheduleCard({
|
||||
{schedule.reference}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text fw={600} size="sm" lineClamp={1}>
|
||||
{schedule.routeName ?? "Train schedule"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{day} · {time}
|
||||
@@ -1082,11 +1038,7 @@ function ScheduleCard({
|
||||
) : null}
|
||||
<ShippingLineBadge schedule={schedule} />
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<MetricChip value={schedule.bookingsCount} label="bkg" />
|
||||
<WagonChips schedule={schedule} />
|
||||
<MetricChip value={`${schedule.totalWeightTons}T`} label="" subtle />
|
||||
</Group>
|
||||
<MetricChip value={schedule.bookingsCount} label="bkg" />
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
|
||||
@@ -19,12 +19,14 @@ import {
|
||||
Select,
|
||||
Checkbox,
|
||||
} from "@mantine/core";
|
||||
import { ChevronDown, ChevronRight, History } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, FileText, History } from "lucide-react";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
|
||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
|
||||
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
|
||||
import { useListControls, toDayString } from "@/hooks/useListControls";
|
||||
@@ -103,6 +105,25 @@ export default function ContainerReturnsPage() {
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
const [historyRow, setHistoryRow] = useState<any | null>(null);
|
||||
const [allocateRow, setAllocateRow] = useState<EmptyContainerReturn | null>(null);
|
||||
const [documentBusyId, setDocumentBusyId] = useState<string | null>(null);
|
||||
|
||||
const viewInterchangeDocument = async (ret: EmptyContainerReturn) => {
|
||||
setDocumentBusyId(ret.id);
|
||||
const pdfWindow = window.open("", "_blank");
|
||||
try {
|
||||
const response = await importOperationsService.downloadEquipmentInterchangeDocument(ret.id);
|
||||
openPdfBlob(response.data, `equipment-interchange-${ret.containerNumber}.pdf`, pdfWindow);
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not open interchange receipt",
|
||||
description: await extractDownloadErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
setDocumentBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const { data: unloadedQueue = [], isLoading: queueLoading } = useQuery({
|
||||
queryKey: ["import-unloaded-queue"],
|
||||
@@ -416,6 +437,15 @@ export default function ContainerReturnsPage() {
|
||||
const nextStatus = RETURN_STATUS_ORDER[RETURN_STATUS_ORDER.indexOf(ret.status) + 1];
|
||||
return (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => void viewInterchangeDocument(ret)}
|
||||
loading={documentBusyId === ret.id}
|
||||
title="View equipment interchange receipt"
|
||||
>
|
||||
<FileText size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
|
||||
@@ -17,6 +17,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, PackageCheck, PackageOpen, TrainFront, Warehouse } from "lucide-react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path.
|
||||
@@ -93,6 +95,9 @@ const apiErrorMessage = (error: unknown) => {
|
||||
|
||||
function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
|
||||
const canUnload = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.unload);
|
||||
const queryClient = useQueryClient();
|
||||
const refresh = () =>
|
||||
queryClient.invalidateQueries({
|
||||
@@ -201,31 +206,37 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{/* Work the cargo right here while the train is at the yard. */}
|
||||
{r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.status === "PAID" && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
loading={load.isPending}
|
||||
onClick={() =>
|
||||
load.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
|
||||
}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
<Tooltip label={canLoad ? undefined : "You don't have permission to load cargo"} disabled={canLoad}>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
loading={load.isPending}
|
||||
disabled={!canLoad}
|
||||
onClick={() =>
|
||||
load.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
|
||||
}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{r.trainScheduleId && atDestination(r) && isRiding(r) && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<PackageOpen size={13} />}
|
||||
loading={unload.isPending}
|
||||
onClick={() =>
|
||||
unload.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
|
||||
}
|
||||
>
|
||||
Unload
|
||||
</Button>
|
||||
<Tooltip label={canUnload ? undefined : "You don't have permission to unload cargo"} disabled={canUnload}>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<PackageOpen size={13} />}
|
||||
loading={unload.isPending}
|
||||
disabled={!canUnload}
|
||||
onClick={() =>
|
||||
unload.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
|
||||
}
|
||||
>
|
||||
Unload
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Badge, Card, Center, Divider, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import { Button, Card, Center, Group, Loader, Popover, Select, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import { DatePickerInput } from '@mantine/dates';
|
||||
import {
|
||||
ClipboardList,
|
||||
Filter,
|
||||
PackageCheck,
|
||||
PackageOpen,
|
||||
PackagePlus,
|
||||
@@ -24,7 +27,7 @@ import {
|
||||
WarehouseOpsKpiStrip,
|
||||
ZoneOccupancyHeatmap,
|
||||
} from '@/components/warehouses';
|
||||
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
|
||||
import { useWarehouseDashboard, useWarehouses } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseDashboard } from '@/types/warehouse';
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
@@ -41,58 +44,110 @@ interface Metric {
|
||||
icon: React.ReactNode;
|
||||
/** Route to navigate to when the card is clicked. */
|
||||
to: string;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
const ORANGE = 'rgb(241, 147, 23)';
|
||||
const GREEN = '#084b21';
|
||||
|
||||
const METRICS: Metric[] = [
|
||||
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
|
||||
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory', theme: GREEN },
|
||||
{ key: 'receivedToday', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE },
|
||||
{ key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: GREEN },
|
||||
{ key: 'emptyContainers', label: 'Empty Containers', icon: <PackageOpen size={22} />, to: '/dashboard/containers', theme: ORANGE },
|
||||
{ key: 'importTrains', label: 'Import Trains', icon: <Train size={22} />, to: '/dashboard/import-warehouse', theme: GREEN },
|
||||
{ key: 'exportTrains', label: 'Export Trains', icon: <Train size={22} />, to: '/dashboard/export-warehouse', theme: ORANGE },
|
||||
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: GREEN },
|
||||
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: ORANGE },
|
||||
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={22} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP', theme: GREEN },
|
||||
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: ORANGE },
|
||||
{ key: 'delivered', label: 'Delivered', icon: <CircleCheck size={22} />, to: '/dashboard/warehouse-inventory?status=DELIVERED', theme: GREEN },
|
||||
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={18} />, to: '/dashboard/warehouses' },
|
||||
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={18} />, to: '/dashboard/warehouse-inventory' },
|
||||
{ key: 'received', label: 'Received Today', icon: <PackagePlus size={18} />, to: '/dashboard/warehouse-inventory?status=RECEIVED' },
|
||||
{ key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={18} />, to: '/dashboard/warehouse-inventory?status=RECEIVED' },
|
||||
{ key: 'emptyContainers', label: 'Empty Containers', icon: <PackageOpen size={18} />, to: '/dashboard/containers' },
|
||||
{ key: 'importTrains', label: 'Import Trains', icon: <Train size={18} />, to: '/dashboard/import-warehouse' },
|
||||
{ key: 'exportTrains', label: 'Export Trains', icon: <Train size={18} />, to: '/dashboard/export-warehouse' },
|
||||
{ key: 'loaded', label: 'Loaded', icon: <Truck size={18} />, to: '/dashboard/loaded-inventory' },
|
||||
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={18} />, to: '/dashboard/dispatch-queue' },
|
||||
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={18} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP' },
|
||||
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={18} />, to: '/dashboard/loading-queue' },
|
||||
{ key: 'delivered', label: 'Delivered', icon: <CircleCheck size={18} />, to: '/dashboard/warehouse-inventory?status=DELIVERED' },
|
||||
];
|
||||
|
||||
export default function WarehouseDashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isError, isLoading } = useWarehouseDashboard();
|
||||
// null → the API defaults `received` to "today", matching the page's original behaviour.
|
||||
const [receivedDate, setReceivedDate] = useState<string | null>(null);
|
||||
const [warehouseId, setWarehouseId] = useState<string | null>(null);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const hasCustomDate = Boolean(receivedDate);
|
||||
|
||||
const warehousesQuery = useWarehouses();
|
||||
const warehouseOptions = useMemo(
|
||||
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||
[warehousesQuery.data],
|
||||
);
|
||||
const activeFilterCount = (warehouseId ? 1 : 0) + (hasCustomDate ? 1 : 0);
|
||||
|
||||
const { data, isError, isLoading } = useWarehouseDashboard({
|
||||
// Same date both ends → the one day the picker selected, inclusive.
|
||||
dateFrom: receivedDate ?? undefined,
|
||||
dateTo: receivedDate ?? undefined,
|
||||
warehouseId: warehouseId ?? undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Warehouse Dashboard"
|
||||
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
||||
subtitle="Freight import/export logistics operations overview"
|
||||
action={
|
||||
<Badge
|
||||
color="edr-green"
|
||||
variant="light"
|
||||
size="lg"
|
||||
leftSection={
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--mantine-color-edr-green-6)',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Live · updates every 60s
|
||||
</Badge>
|
||||
<Group gap="sm" wrap="wrap" justify="flex-end">
|
||||
<DatePickerInput
|
||||
placeholder="Received: today"
|
||||
value={receivedDate}
|
||||
onChange={setReceivedDate}
|
||||
clearable
|
||||
w={180}
|
||||
/>
|
||||
<Popover opened={filtersOpen} onChange={setFiltersOpen} position="bottom-end" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<Filter size={16} />}
|
||||
rightSection={activeFilterCount > 0 ? <Text size="xs" fw={700} c="edr-green">{activeFilterCount}</Text> : null}
|
||||
onClick={() => setFiltersOpen((o) => !o)}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="sm" w={240}>
|
||||
<Select
|
||||
label="Warehouse"
|
||||
placeholder="All warehouses"
|
||||
clearable
|
||||
searchable
|
||||
data={warehouseOptions}
|
||||
value={warehouseId}
|
||||
onChange={setWarehouseId}
|
||||
/>
|
||||
{activeFilterCount > 0 && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setWarehouseId(null);
|
||||
setReceivedDate(null);
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
{(warehouseId || hasCustomDate) && (
|
||||
<Text size="xs" c="dimmed" mt={-8}>
|
||||
Scoped to{' '}
|
||||
{warehouseId ? warehouseOptions.find((o) => o.value === warehouseId)?.label ?? 'selected warehouse' : 'all warehouses'}
|
||||
{hasCustomDate ? ` · Received counts for ${receivedDate}` : ' · Received counts: today'}
|
||||
. Status-backlog and fleet counters are always current regardless of the date filter.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
@@ -102,41 +157,33 @@ export default function WarehouseDashboardPage() {
|
||||
<Text c="red">Failed to load warehouse dashboard.</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<Stack gap="xl">
|
||||
<Stack gap="lg">
|
||||
{/* Needs attention — live ops counters (received today, pending
|
||||
inspection, trucks on-site, items aging > 7 days). */}
|
||||
<Stack gap="sm">
|
||||
<SectionTitle>Needs attention</SectionTitle>
|
||||
<WarehouseOpsKpiStrip />
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
<WarehouseOpsKpiStrip />
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
|
||||
{METRICS.map((metric) => (
|
||||
<Card
|
||||
key={metric.key}
|
||||
padding="lg"
|
||||
padding="md"
|
||||
withBorder
|
||||
radius="md"
|
||||
onClick={() => navigate(metric.to)}
|
||||
className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="xs" c="edr-muted" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
|
||||
{metric.label}
|
||||
</Text>
|
||||
<Text fw={800} fz={32} mt={8} c="edr-text" lh={1.1}>
|
||||
{data ? data[metric.key] : 0}
|
||||
</Text>
|
||||
</div>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
size={46}
|
||||
radius="md"
|
||||
style={{ backgroundColor: `${metric.theme}1a`, color: metric.theme }}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon color="edr-green" variant="light" size={40} radius="md">
|
||||
{metric.icon}
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="xs" c="edr-muted" fw={600}>
|
||||
{metric.key === 'received' && hasCustomDate ? 'Received' : metric.label}
|
||||
</Text>
|
||||
<Text fw={700} fz={20} c="edr-text" lh={1.2}>
|
||||
{data ? data[metric.key] : 0}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user