mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
353 lines
10 KiB
TypeScript
353 lines
10 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,
|
||
Group,
|
||
Modal,
|
||
Stack,
|
||
Text,
|
||
Textarea,
|
||
TextInput,
|
||
} from "@mantine/core";
|
||
import { 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))
|
||
: "—";
|
||
|
||
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 "—";
|
||
}
|
||
|
||
export default function ShipmentRequestsPage() {
|
||
const navigate = useNavigate();
|
||
const queryClient = useQueryClient();
|
||
const [query, setQuery] = useState("");
|
||
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 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,
|
||
}));
|
||
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]);
|
||
|
||
const columns = useMemo<ColumnDef<ShipmentListRow>[]>(
|
||
() => [
|
||
{
|
||
id: "reference",
|
||
header: "Request",
|
||
meta: cellMeta,
|
||
cell: ({ row }) => (
|
||
<Text size="sm" fw={700} c="dark.5">
|
||
{row.original.reference}
|
||
</Text>
|
||
),
|
||
},
|
||
{
|
||
id: "contract",
|
||
header: "Contract",
|
||
meta: cellMeta,
|
||
cell: ({ row }) => (
|
||
<Text size="sm" c="gray.7">
|
||
{row.original.contractReference}
|
||
</Text>
|
||
),
|
||
},
|
||
{
|
||
id: "summary",
|
||
header: "Requested",
|
||
meta: cellMeta,
|
||
cell: ({ row }) => (
|
||
<Badge variant="light" color="edr-green" radius="sm">
|
||
{row.original.summary}
|
||
</Badge>
|
||
),
|
||
},
|
||
{
|
||
id: "date",
|
||
header: "Preferred date",
|
||
meta: cellMeta,
|
||
cell: ({ row }) => (
|
||
<Text size="sm">{fmtDate(row.original.scheduledDate)}</Text>
|
||
),
|
||
},
|
||
{
|
||
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],
|
||
);
|
||
|
||
return (
|
||
<PageContainer>
|
||
<Stack gap="lg">
|
||
<PageHeader
|
||
title="Shipment Requests"
|
||
subtitle="Customer requests to ship under general customs contracts. Accept one to create the booking and start its clearance."
|
||
meta={
|
||
<Badge
|
||
variant="light"
|
||
color="edr-green"
|
||
radius="sm"
|
||
leftSection={<PackageSearch size={13} />}
|
||
>
|
||
{rows.length} pending
|
||
</Badge>
|
||
}
|
||
action={
|
||
<ActionIcon
|
||
variant="default"
|
||
size="lg"
|
||
radius="md"
|
||
onClick={() => refetch()}
|
||
loading={isFetching}
|
||
aria-label="Refresh"
|
||
>
|
||
<RefreshCw size={16} />
|
||
</ActionIcon>
|
||
}
|
||
/>
|
||
|
||
<TextInput
|
||
radius="md"
|
||
maw={360}
|
||
placeholder="Search request, contract, cargo…"
|
||
leftSection={<Search size={15} />}
|
||
value={query}
|
||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||
/>
|
||
|
||
{rows.length === 0 && !isLoading ? (
|
||
<Box
|
||
py={56}
|
||
style={{
|
||
borderRadius: 14,
|
||
border: "1px dashed var(--mantine-color-gray-3)",
|
||
textAlign: "center",
|
||
}}
|
||
>
|
||
<Inbox size={26} className="text-muted-foreground" />
|
||
<Text c="dimmed" mt="sm">
|
||
No pending shipment requests.
|
||
</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>
|
||
);
|
||
}
|