mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
663 lines
20 KiB
TypeScript
663 lines
20 KiB
TypeScript
import { useMemo, useState } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
ActionIcon,
|
||
Badge,
|
||
Box,
|
||
Button,
|
||
CloseButton,
|
||
Group,
|
||
Modal,
|
||
Paper,
|
||
SegmentedControl,
|
||
Select,
|
||
Stack,
|
||
Text,
|
||
Textarea,
|
||
TextInput,
|
||
} from "@mantine/core";
|
||
import { DatePickerInput } from "@mantine/dates";
|
||
import { getDateRangePresets } from "@/components/common/dateRangePresets";
|
||
import {
|
||
ArrowUpDown,
|
||
FilterX,
|
||
Inbox,
|
||
PackageSearch,
|
||
RefreshCw,
|
||
Search,
|
||
} from "lucide-react";
|
||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||
import type { Freight } from "@edr/types";
|
||
|
||
import { PageContainer } from "@/components/page/PageContainer";
|
||
import { PageHeader } from "@/components/page/PageHeader";
|
||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||
import {
|
||
getShipmentRejectAction,
|
||
getShipmentStaffRowAction,
|
||
type ShipmentListRow,
|
||
} from "@/features/contracts/mapShipmentListRow";
|
||
import { contractsService } from "@/services/contracts.service";
|
||
|
||
const cellMeta = {
|
||
headerClassName: ruleEngineTable.headerCell,
|
||
cellClassName: ruleEngineTable.bodyCell,
|
||
};
|
||
|
||
const fmtDate = (iso?: string | null) =>
|
||
iso
|
||
? new Intl.DateTimeFormat("en-GB", {
|
||
day: "2-digit",
|
||
month: "short",
|
||
year: "numeric",
|
||
}).format(new Date(iso))
|
||
: "—";
|
||
|
||
const fmtDateTime = (iso?: string | null) =>
|
||
iso
|
||
? new Intl.DateTimeFormat("en-GB", {
|
||
day: "2-digit",
|
||
month: "short",
|
||
year: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
}).format(new Date(iso))
|
||
: "—";
|
||
|
||
function summarizeLines(lines: Freight.RequestedShipmentLines): string {
|
||
if (lines.containers?.length) {
|
||
return lines.containers
|
||
.map((c) => `${c.quantity}× ${c.containerSize}`)
|
||
.join(", ");
|
||
}
|
||
if (lines.bulk) {
|
||
const b = lines.bulk;
|
||
if (b.cargoWeightTons) return `${b.cargoWeightTons} t bulk`;
|
||
if (b.itemCount) return `${b.itemCount} items`;
|
||
return "Bulk";
|
||
}
|
||
return "—";
|
||
}
|
||
|
||
const STATUS_META: Record<
|
||
Freight.BookingRequestStatus,
|
||
{ label: string; color: string }
|
||
> = {
|
||
PENDING: { label: "Pending", color: "yellow" },
|
||
ACCEPTED: { label: "Accepted", color: "edr-green" },
|
||
REJECTED: { label: "Rejected", color: "red" },
|
||
CANCELLED: { label: "Cancelled", color: "gray" },
|
||
};
|
||
|
||
type StatusFilter = "ALL" | Freight.BookingRequestStatus;
|
||
type CargoFilter = "ALL" | "CONTAINER" | "BULK";
|
||
type SortKey =
|
||
| "submitted-desc"
|
||
| "submitted-asc"
|
||
| "preferred-asc"
|
||
| "preferred-desc"
|
||
| "reference";
|
||
|
||
const SORT_OPTIONS: Array<{ value: SortKey; label: string }> = [
|
||
{ value: "submitted-desc", label: "Newest first" },
|
||
{ value: "submitted-asc", label: "Oldest first" },
|
||
{ value: "preferred-asc", label: "Preferred date (soonest)" },
|
||
{ value: "preferred-desc", label: "Preferred date (latest)" },
|
||
{ value: "reference", label: "Reference A–Z" },
|
||
];
|
||
|
||
const time = (iso?: string | null) => (iso ? new Date(iso).getTime() : 0);
|
||
|
||
export default function ShipmentRequestsPage() {
|
||
const navigate = useNavigate();
|
||
const queryClient = useQueryClient();
|
||
|
||
const [query, setQuery] = useState("");
|
||
const [status, setStatus] = useState<StatusFilter>("PENDING");
|
||
const [cargo, setCargo] = useState<CargoFilter>("ALL");
|
||
const [preferredFrom, setPreferredFrom] = useState<Date | null>(null);
|
||
const [preferredTo, setPreferredTo] = useState<Date | null>(null);
|
||
const [sort, setSort] = useState<SortKey>("submitted-desc");
|
||
|
||
const [rejectTarget, setRejectTarget] = useState<ShipmentListRow | null>(null);
|
||
const [rejectNote, setRejectNote] = useState("");
|
||
const [acceptTarget, setAcceptTarget] = useState<ShipmentListRow | null>(null);
|
||
|
||
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
||
queryKey: ["shipment-request-queue"],
|
||
queryFn: () => contractsService.getBookingRequestQueue(),
|
||
refetchInterval: 30_000,
|
||
});
|
||
|
||
const reject = useMutation({
|
||
mutationFn: () =>
|
||
contractsService.rejectBookingRequest(rejectTarget!.id, rejectNote),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["shipment-request-queue"] });
|
||
setRejectTarget(null);
|
||
setRejectNote("");
|
||
},
|
||
});
|
||
|
||
const allRows = useMemo<ShipmentListRow[]>(
|
||
() =>
|
||
(data ?? []).map((r) => {
|
||
const lines = r.requestedLines ?? {};
|
||
return {
|
||
id: r.id,
|
||
reference: r.reference || r.id.slice(0, 8),
|
||
contractId: r.contractId,
|
||
contractReference: r.contract?.reference ?? r.contractId,
|
||
scheduledDate: r.scheduledDate,
|
||
summary: summarizeLines(lines),
|
||
status: r.status,
|
||
createdBookingId: r.createdBookingId,
|
||
createdAt: r.createdAt,
|
||
customerName: r.contract?.company?.name ?? null,
|
||
freightKind: lines.containers?.length
|
||
? "CONTAINER"
|
||
: lines.bulk
|
||
? "BULK"
|
||
: r.contract?.freightType === "BULK"
|
||
? "BULK"
|
||
: "CONTAINER",
|
||
hazardous:
|
||
(lines.containers ?? []).some((c) => (c.hazardousQuantity ?? 0) > 0) ||
|
||
(lines.bulk?.hazardousQuantity ?? 0) > 0,
|
||
reefer: (lines.containers ?? []).some(
|
||
(c) => (c.reeferQuantity ?? 0) > 0,
|
||
),
|
||
};
|
||
}),
|
||
[data],
|
||
);
|
||
|
||
// Status counts always reflect the whole queue so the segmented control
|
||
// reads as a live overview, independent of the other filters.
|
||
const counts = useMemo(() => {
|
||
const c: Record<StatusFilter, number> = {
|
||
ALL: allRows.length,
|
||
PENDING: 0,
|
||
ACCEPTED: 0,
|
||
REJECTED: 0,
|
||
CANCELLED: 0,
|
||
};
|
||
allRows.forEach((r) => {
|
||
c[r.status] += 1;
|
||
});
|
||
return c;
|
||
}, [allRows]);
|
||
|
||
const rows = useMemo<ShipmentListRow[]>(() => {
|
||
let out = allRows;
|
||
|
||
if (status !== "ALL") out = out.filter((r) => r.status === status);
|
||
if (cargo !== "ALL") out = out.filter((r) => r.freightKind === cargo);
|
||
|
||
// Preferred-date range: rows without a preferred day drop out once a bound
|
||
// is set — a date filter that keeps dateless rows reads as broken.
|
||
if (preferredFrom || preferredTo) {
|
||
const from = preferredFrom ? preferredFrom.getTime() : -Infinity;
|
||
const to = preferredTo
|
||
? preferredTo.getTime() + 24 * 60 * 60 * 1000 - 1
|
||
: Infinity;
|
||
out = out.filter((r) => {
|
||
if (!r.scheduledDate) return false;
|
||
const t = time(r.scheduledDate);
|
||
return t >= from && t <= to;
|
||
});
|
||
}
|
||
|
||
const q = query.trim().toLowerCase();
|
||
if (q) {
|
||
out = out.filter(
|
||
(r) =>
|
||
r.reference.toLowerCase().includes(q) ||
|
||
r.contractReference.toLowerCase().includes(q) ||
|
||
(r.customerName ?? "").toLowerCase().includes(q) ||
|
||
r.summary.toLowerCase().includes(q),
|
||
);
|
||
}
|
||
|
||
const sorted = [...out];
|
||
switch (sort) {
|
||
case "submitted-asc":
|
||
sorted.sort((a, b) => time(a.createdAt) - time(b.createdAt));
|
||
break;
|
||
case "preferred-asc":
|
||
// Requests without a preferred day sink to the bottom in both orders.
|
||
sorted.sort(
|
||
(a, b) =>
|
||
(a.scheduledDate ? time(a.scheduledDate) : Infinity) -
|
||
(b.scheduledDate ? time(b.scheduledDate) : Infinity),
|
||
);
|
||
break;
|
||
case "preferred-desc":
|
||
sorted.sort(
|
||
(a, b) =>
|
||
(b.scheduledDate ? time(b.scheduledDate) : -Infinity) -
|
||
(a.scheduledDate ? time(a.scheduledDate) : -Infinity),
|
||
);
|
||
break;
|
||
case "reference":
|
||
sorted.sort((a, b) => a.reference.localeCompare(b.reference));
|
||
break;
|
||
default:
|
||
// Newest submitted on top.
|
||
sorted.sort((a, b) => time(b.createdAt) - time(a.createdAt));
|
||
}
|
||
return sorted;
|
||
}, [allRows, status, cargo, preferredFrom, preferredTo, query, sort]);
|
||
|
||
const filtersActive =
|
||
query.trim() !== "" ||
|
||
status !== "PENDING" ||
|
||
cargo !== "ALL" ||
|
||
preferredFrom !== null ||
|
||
preferredTo !== null ||
|
||
sort !== "submitted-desc";
|
||
|
||
const clearFilters = () => {
|
||
setQuery("");
|
||
setStatus("PENDING");
|
||
setCargo("ALL");
|
||
setPreferredFrom(null);
|
||
setPreferredTo(null);
|
||
setSort("submitted-desc");
|
||
};
|
||
|
||
const columns = useMemo<ColumnDef<ShipmentListRow>[]>(
|
||
() => [
|
||
{
|
||
id: "reference",
|
||
header: "Request",
|
||
meta: cellMeta,
|
||
cell: ({ row }) => (
|
||
<Box>
|
||
<Text size="sm" fw={700} c="dark.5">
|
||
{row.original.reference}
|
||
</Text>
|
||
<Text size="xs" c="dimmed" mt={2}>
|
||
Submitted {fmtDateTime(row.original.createdAt)}
|
||
</Text>
|
||
</Box>
|
||
),
|
||
},
|
||
{
|
||
id: "contract",
|
||
header: "Contract",
|
||
meta: cellMeta,
|
||
cell: ({ row }) => (
|
||
<Box>
|
||
<Text size="sm" c="gray.7">
|
||
{row.original.contractReference}
|
||
</Text>
|
||
{row.original.customerName ? (
|
||
<Text size="xs" c="dimmed" mt={2} truncate maw={200}>
|
||
{row.original.customerName}
|
||
</Text>
|
||
) : null}
|
||
</Box>
|
||
),
|
||
},
|
||
{
|
||
id: "summary",
|
||
header: "Requested",
|
||
meta: cellMeta,
|
||
cell: ({ row }) => (
|
||
<Group gap={6} wrap="wrap">
|
||
<Badge variant="light" color="edr-green" radius="sm">
|
||
{row.original.summary}
|
||
</Badge>
|
||
{row.original.hazardous ? (
|
||
<Badge variant="light" color="red" radius="sm">
|
||
Hazardous
|
||
</Badge>
|
||
) : null}
|
||
{row.original.reefer ? (
|
||
<Badge variant="light" color="blue" radius="sm">
|
||
Reefer
|
||
</Badge>
|
||
) : null}
|
||
</Group>
|
||
),
|
||
},
|
||
{
|
||
id: "date",
|
||
header: "Preferred date",
|
||
meta: cellMeta,
|
||
cell: ({ row }) => (
|
||
<Text size="sm">{fmtDate(row.original.scheduledDate)}</Text>
|
||
),
|
||
},
|
||
{
|
||
id: "status",
|
||
header: "Status",
|
||
meta: cellMeta,
|
||
cell: ({ row }) => {
|
||
const meta = STATUS_META[row.original.status];
|
||
return (
|
||
<Badge variant="light" color={meta.color} radius="sm">
|
||
{meta.label}
|
||
</Badge>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: "actions",
|
||
header: () => <span className={ruleEngineTable.headerCell}>Action</span>,
|
||
meta: cellMeta,
|
||
cell: ({ row }) => {
|
||
const primary = getShipmentStaffRowAction(row.original);
|
||
const rejectAction = getShipmentRejectAction(row.original);
|
||
return (
|
||
<Group gap={6} wrap="nowrap" justify="flex-end">
|
||
{rejectAction ? (
|
||
<Button
|
||
size="compact-sm"
|
||
radius="md"
|
||
variant="light"
|
||
color="red"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setRejectTarget(row.original);
|
||
}}
|
||
>
|
||
{rejectAction.label}
|
||
</Button>
|
||
) : null}
|
||
{primary.kind === "navigate" ? (
|
||
<Button
|
||
size="compact-sm"
|
||
radius="md"
|
||
variant={primary.variant === "filled" ? "filled" : primary.variant}
|
||
color="edr-green"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (
|
||
primary.label === "Accept" &&
|
||
row.original.status === "PENDING"
|
||
) {
|
||
setAcceptTarget(row.original);
|
||
} else {
|
||
navigate(primary.to(row.original));
|
||
}
|
||
}}
|
||
>
|
||
{primary.label}
|
||
</Button>
|
||
) : null}
|
||
</Group>
|
||
);
|
||
},
|
||
},
|
||
],
|
||
[navigate],
|
||
);
|
||
|
||
const hasAnyRequests = allRows.length > 0;
|
||
|
||
return (
|
||
<PageContainer>
|
||
<Stack gap="lg">
|
||
<PageHeader
|
||
title="Shipment Requests"
|
||
subtitle="Customer requests to ship under general customs contracts. Each request starts its booking's clearance immediately — complete the booking from the clearance page once it is ready."
|
||
meta={
|
||
<Badge
|
||
variant="light"
|
||
color="edr-green"
|
||
radius="sm"
|
||
leftSection={<PackageSearch size={13} />}
|
||
>
|
||
{counts.PENDING} pending
|
||
</Badge>
|
||
}
|
||
action={
|
||
<ActionIcon
|
||
variant="default"
|
||
size="lg"
|
||
radius="md"
|
||
onClick={() => refetch()}
|
||
loading={isFetching}
|
||
aria-label="Refresh"
|
||
>
|
||
<RefreshCw size={16} />
|
||
</ActionIcon>
|
||
}
|
||
/>
|
||
|
||
<Paper withBorder radius="lg" p="md" style={{ borderColor: "#E6ECF2" }}>
|
||
<Stack gap="sm">
|
||
<Group gap="sm" wrap="wrap">
|
||
<TextInput
|
||
radius="md"
|
||
style={{ flex: 1, minWidth: 220 }}
|
||
placeholder="Search request, contract, customer, cargo…"
|
||
leftSection={<Search size={15} />}
|
||
rightSection={
|
||
query ? (
|
||
<CloseButton
|
||
size="sm"
|
||
aria-label="Clear search"
|
||
onClick={() => setQuery("")}
|
||
/>
|
||
) : null
|
||
}
|
||
value={query}
|
||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||
/>
|
||
<Select
|
||
radius="md"
|
||
w={150}
|
||
value={cargo}
|
||
onChange={(v) => setCargo((v as CargoFilter) ?? "ALL")}
|
||
data={[
|
||
{ value: "ALL", label: "All cargo" },
|
||
{ value: "CONTAINER", label: "Containers" },
|
||
{ value: "BULK", label: "Bulk" },
|
||
]}
|
||
allowDeselect={false}
|
||
aria-label="Cargo type"
|
||
/>
|
||
<DatePickerInput
|
||
type="range"
|
||
radius="md"
|
||
w={230}
|
||
placeholder="Preferred date range"
|
||
value={[preferredFrom, preferredTo]}
|
||
onChange={([from, to]) => {
|
||
setPreferredFrom(from ? new Date(from) : null);
|
||
setPreferredTo(to ? new Date(to) : null);
|
||
}}
|
||
presets={getDateRangePresets()}
|
||
clearable
|
||
aria-label="Preferred date range"
|
||
/>
|
||
<Select
|
||
radius="md"
|
||
w={215}
|
||
leftSection={<ArrowUpDown size={14} />}
|
||
value={sort}
|
||
onChange={(v) => setSort((v as SortKey) ?? "submitted-desc")}
|
||
data={SORT_OPTIONS}
|
||
allowDeselect={false}
|
||
aria-label="Sort by"
|
||
/>
|
||
</Group>
|
||
|
||
<Group justify="space-between" gap="sm" wrap="wrap">
|
||
<SegmentedControl
|
||
radius="md"
|
||
size="xs"
|
||
value={status}
|
||
onChange={(v) => setStatus(v as StatusFilter)}
|
||
data={[
|
||
{ value: "ALL", label: `All · ${counts.ALL}` },
|
||
{ value: "PENDING", label: `Pending · ${counts.PENDING}` },
|
||
{ value: "ACCEPTED", label: `Accepted · ${counts.ACCEPTED}` },
|
||
{ value: "REJECTED", label: `Rejected · ${counts.REJECTED}` },
|
||
{ value: "CANCELLED", label: `Cancelled · ${counts.CANCELLED}` },
|
||
]}
|
||
/>
|
||
<Group gap="sm">
|
||
<Text size="sm" c="dimmed">
|
||
{rows.length} of {allRows.length} request
|
||
{allRows.length === 1 ? "" : "s"}
|
||
</Text>
|
||
{filtersActive ? (
|
||
<Button
|
||
variant="subtle"
|
||
color="gray"
|
||
size="compact-sm"
|
||
radius="md"
|
||
leftSection={<FilterX size={14} />}
|
||
onClick={clearFilters}
|
||
>
|
||
Clear filters
|
||
</Button>
|
||
) : null}
|
||
</Group>
|
||
</Group>
|
||
</Stack>
|
||
</Paper>
|
||
|
||
{rows.length === 0 && !isLoading && !isError ? (
|
||
<Box
|
||
py={56}
|
||
style={{
|
||
borderRadius: 14,
|
||
border: "1px dashed var(--mantine-color-gray-3)",
|
||
textAlign: "center",
|
||
}}
|
||
>
|
||
<Inbox size={26} className="text-muted-foreground" />
|
||
{hasAnyRequests ? (
|
||
<>
|
||
<Text c="dimmed" mt="sm">
|
||
No requests match the current filters.
|
||
</Text>
|
||
<Button
|
||
variant="subtle"
|
||
color="gray"
|
||
size="compact-sm"
|
||
radius="md"
|
||
mt="xs"
|
||
leftSection={<FilterX size={14} />}
|
||
onClick={clearFilters}
|
||
>
|
||
Clear filters
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<Text c="dimmed" mt="sm">
|
||
No shipment requests yet.
|
||
</Text>
|
||
)}
|
||
</Box>
|
||
) : (
|
||
<DataTable
|
||
columns={columns}
|
||
data={rows}
|
||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||
onRowClick={(row) =>
|
||
navigate(`/dashboard/shipment-requests/${row.id}`)
|
||
}
|
||
containerClassName="overflow-x-auto rounded-lg border border-edr-border"
|
||
/>
|
||
)}
|
||
</Stack>
|
||
|
||
<Modal
|
||
opened={rejectTarget !== null}
|
||
onClose={() => {
|
||
if (!reject.isPending) {
|
||
setRejectTarget(null);
|
||
setRejectNote("");
|
||
}
|
||
}}
|
||
title="Reject shipment request"
|
||
radius="md"
|
||
centered
|
||
>
|
||
<Stack gap="md">
|
||
<Text size="sm" c="dimmed">
|
||
Reject request{" "}
|
||
<Text span fw={600}>
|
||
{rejectTarget?.reference}
|
||
</Text>
|
||
? The customer will be notified.
|
||
</Text>
|
||
<Textarea
|
||
label="Reason"
|
||
placeholder="Explain why this request cannot be accepted…"
|
||
value={rejectNote}
|
||
onChange={(e) => setRejectNote(e.currentTarget.value)}
|
||
minRows={3}
|
||
radius="md"
|
||
/>
|
||
<Group justify="flex-end" gap="sm">
|
||
<Button
|
||
variant="default"
|
||
radius="md"
|
||
onClick={() => {
|
||
setRejectTarget(null);
|
||
setRejectNote("");
|
||
}}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
color="red"
|
||
radius="md"
|
||
loading={reject.isPending}
|
||
disabled={!rejectNote.trim()}
|
||
onClick={() => reject.mutate()}
|
||
>
|
||
Reject request
|
||
</Button>
|
||
</Group>
|
||
</Stack>
|
||
</Modal>
|
||
|
||
<Modal
|
||
opened={acceptTarget !== null}
|
||
onClose={() => setAcceptTarget(null)}
|
||
title="Accept shipment request"
|
||
radius="md"
|
||
centered
|
||
>
|
||
<Stack gap="md">
|
||
<Text size="sm" c="dimmed">
|
||
Proceed to create a booking for request{" "}
|
||
<Text span fw={600}>
|
||
{acceptTarget?.reference}
|
||
</Text>
|
||
? You will confirm the shipment price before submitting.
|
||
</Text>
|
||
<Group justify="flex-end" gap="sm">
|
||
<Button variant="default" radius="md" onClick={() => setAcceptTarget(null)}>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
color="edr-green"
|
||
radius="md"
|
||
onClick={() => {
|
||
if (!acceptTarget) return;
|
||
const to = getShipmentStaffRowAction(acceptTarget);
|
||
if (to.kind === "navigate") {
|
||
navigate(to.to(acceptTarget));
|
||
}
|
||
setAcceptTarget(null);
|
||
}}
|
||
>
|
||
Continue
|
||
</Button>
|
||
</Group>
|
||
</Stack>
|
||
</Modal>
|
||
</PageContainer>
|
||
);
|
||
}
|