enhance shipment requests page with filtering and sorting options

- Added status and cargo filters to the ShipmentRequestsPage.
- Implemented date range filtering for preferred dates.
- Introduced sorting options for shipment requests based on submission date and reference.
- Enhanced the display of shipment request details, including status badges and customer information.
- Updated the UI to include a search input with clear functionality and improved layout for filters.

feat: add equipment return option in new shipment form

- Introduced a toggle for equipment return in the NewShipmentPage.
- Updated form schema to include  field for container contracts.
- Enhanced user experience with visual feedback on the equipment return selection.

fix: update booking DTO to include equipment return option

- Added  field to CreateBookingUnderContractDto for per-shipment override.
- Updated related types and schemas to accommodate the new field for better contract handling.
This commit is contained in:
Marshal
2026-07-10 10:32:41 +00:00
parent 2fb1917956
commit 1abef3ce34
19 changed files with 2130 additions and 294 deletions

View File

@@ -6,14 +6,26 @@ import {
Badge,
Box,
Button,
CloseButton,
Group,
Modal,
Paper,
SegmentedControl,
Select,
Stack,
Text,
Textarea,
TextInput,
} from "@mantine/core";
import { Inbox, PackageSearch, RefreshCw, Search } from "lucide-react";
import { DateInput } from "@mantine/dates";
import {
ArrowUpDown,
FilterX,
Inbox,
PackageSearch,
RefreshCw,
Search,
} from "lucide-react";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import type { Freight } from "@edr/types";
@@ -41,6 +53,17 @@ const fmtDate = (iso?: string | null) =>
}).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
@@ -56,10 +79,46 @@ function summarizeLines(lines: Freight.RequestedShipmentLines): string {
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);
@@ -80,26 +139,132 @@ export default function ShipmentRequestsPage() {
},
});
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[]>(() => {
const all = (data ?? []).map((r) => ({
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(r.requestedLines ?? {}),
status: r.status,
createdBookingId: r.createdBookingId,
}));
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) return all;
return all.filter(
(r) =>
r.reference.toLowerCase().includes(q) ||
r.contractReference.toLowerCase().includes(q) ||
r.summary.toLowerCase().includes(q),
);
}, [data, query]);
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>[]>(
() => [
@@ -108,9 +273,14 @@ export default function ShipmentRequestsPage() {
header: "Request",
meta: cellMeta,
cell: ({ row }) => (
<Text size="sm" fw={700} c="dark.5">
{row.original.reference}
</Text>
<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>
),
},
{
@@ -118,9 +288,16 @@ export default function ShipmentRequestsPage() {
header: "Contract",
meta: cellMeta,
cell: ({ row }) => (
<Text size="sm" c="gray.7">
{row.original.contractReference}
</Text>
<Box>
<Text size="sm" c="gray.7">
{row.original.contractReference}
</Text>
{row.original.customerName ? (
<Text size="xs" c="dimmed" mt={2}>
{row.original.customerName}
</Text>
) : null}
</Box>
),
},
{
@@ -128,9 +305,21 @@ export default function ShipmentRequestsPage() {
header: "Requested",
meta: cellMeta,
cell: ({ row }) => (
<Badge variant="light" color="edr-green" radius="sm">
{row.original.summary}
</Badge>
<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>
),
},
{
@@ -141,6 +330,19 @@ export default function ShipmentRequestsPage() {
<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>,
@@ -193,6 +395,8 @@ export default function ShipmentRequestsPage() {
[navigate],
);
const hasAnyRequests = allRows.length > 0;
return (
<PageContainer>
<Stack gap="lg">
@@ -206,7 +410,7 @@ export default function ShipmentRequestsPage() {
radius="sm"
leftSection={<PackageSearch size={13} />}
>
{rows.length} pending
{counts.PENDING} pending
</Badge>
}
action={
@@ -223,16 +427,108 @@ export default function ShipmentRequestsPage() {
}
/>
<TextInput
radius="md"
maw={360}
placeholder="Search request, contract, cargo…"
leftSection={<Search size={15} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
/>
<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"
/>
<DateInput
radius="md"
w={150}
placeholder="Preferred from"
value={preferredFrom}
onChange={(v) => setPreferredFrom(v ? new Date(v) : null)}
maxDate={preferredTo ?? undefined}
clearable
aria-label="Preferred date from"
/>
<DateInput
radius="md"
w={150}
placeholder="Preferred to"
value={preferredTo}
onChange={(v) => setPreferredTo(v ? new Date(v) : null)}
minDate={preferredFrom ?? undefined}
clearable
aria-label="Preferred date to"
/>
<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>
{rows.length === 0 && !isLoading ? (
<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={{
@@ -242,9 +538,28 @@ export default function ShipmentRequestsPage() {
}}
>
<Inbox size={26} className="text-muted-foreground" />
<Text c="dimmed" mt="sm">
No pending shipment requests.
</Text>
{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