Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestsPage.tsx
2026-08-13 13:46:46 +00:00

663 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 AZ" },
];
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>
);
}