mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 21:50:57 +00:00
Approval now snapshots the rate estimate and generates a last-mile contract instead of invoicing immediately. The customer picks a delivery date on the confirm form, then reviews and signs the contract in the portal (saved signature or drawn); the signed PDF is stored as LM_<CustomerName>.pdf and only then is the advance invoice issued. Backoffice shows signature status and the contract download.
370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import {
|
||
Badge,
|
||
Box,
|
||
Button,
|
||
Card,
|
||
Group,
|
||
Modal,
|
||
NumberInput,
|
||
Stack,
|
||
Text,
|
||
Textarea,
|
||
} from "@mantine/core";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import type { ColumnDef } from "@edr/ui-common";
|
||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||
|
||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||
import { useToast } from "@/hooks/use-toast";
|
||
import { useAuth } from "@/auth/useAuth";
|
||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||
import {
|
||
lastMileRequestsService,
|
||
type LastMileRequest,
|
||
type LastMileRequestStatus,
|
||
} from "@/services/last-mile-requests.service";
|
||
|
||
const STATUS_META: Record<LastMileRequestStatus, { label: string; color: string }> = {
|
||
AWAITING_CONFIRMATION: { label: "Awaiting Confirmation", color: "gray" },
|
||
SUBMITTED: { label: "Submitted", color: "yellow" },
|
||
APPROVED: { label: "Approved", color: "green" },
|
||
REJECTED: { label: "Rejected", color: "red" },
|
||
};
|
||
|
||
type StatusFilter = "ALL" | LastMileRequestStatus;
|
||
|
||
const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||
{ value: "SUBMITTED", label: "Submitted" },
|
||
{ value: "APPROVED", label: "Approved" },
|
||
{ value: "REJECTED", label: "Rejected" },
|
||
{ value: "AWAITING_CONFIRMATION", label: "Awaiting Confirmation" },
|
||
{ value: "ALL", label: "All" },
|
||
];
|
||
|
||
const fmtDate = (iso?: string | null) =>
|
||
iso ? new Date(iso).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" }) : "—";
|
||
|
||
export function LastMileRequestsPanel() {
|
||
const { toast } = useToast();
|
||
const qc = useQueryClient();
|
||
const { user } = useAuth();
|
||
const canApprove = hasPermission(user, FREIGHT_PERMS.lastMile.requestApprove);
|
||
|
||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("SUBMITTED");
|
||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||
const [approveTarget, setApproveTarget] = useState<LastMileRequest | null>(null);
|
||
const [rejectTarget, setRejectTarget] = useState<LastMileRequest | null>(null);
|
||
const [advanceAmount, setAdvanceAmount] = useState<number | string>("");
|
||
const [rejectReason, setRejectReason] = useState("");
|
||
|
||
const filter = {
|
||
...(statusFilter !== "ALL" ? { status: statusFilter } : {}),
|
||
page: pagination.pageIndex + 1,
|
||
pageSize: pagination.pageSize,
|
||
};
|
||
|
||
const { data, isLoading } = useQuery({
|
||
queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.list(filter),
|
||
queryFn: async () => (await lastMileRequestsService.list(filter)).data,
|
||
});
|
||
const rows = data?.data ?? [];
|
||
const meta = data?.meta;
|
||
|
||
const { data: freeTrucks } = useQuery({
|
||
queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.freeTruckCount,
|
||
queryFn: async () => (await lastMileRequestsService.freeTruckCount()).data,
|
||
});
|
||
|
||
// Rule-based estimate for the approve dialog (estimated km × live last-mile
|
||
// rates). Prefills the advance once, without clobbering a typed value.
|
||
const { data: estimate } = useQuery({
|
||
queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.priceEstimate(approveTarget?.id ?? ""),
|
||
queryFn: async () =>
|
||
(await lastMileRequestsService.priceEstimate(approveTarget!.id)).data,
|
||
enabled: Boolean(approveTarget),
|
||
});
|
||
useEffect(() => {
|
||
if (approveTarget && estimate?.total != null && advanceAmount === "") {
|
||
setAdvanceAmount(estimate.total);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [estimate, approveTarget]);
|
||
|
||
const invalidate = () =>
|
||
qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.ROOT });
|
||
|
||
const downloadContract = async (r: LastMileRequest) => {
|
||
try {
|
||
const { data: blob } = await lastMileRequestsService.contractDocument(r.id);
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `LM_${r.booking?.company?.name?.replace(/[^A-Za-z0-9._-]+/g, "_") ?? r.id}.pdf`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
} catch {
|
||
toast({ title: "Contract PDF not available", variant: "destructive" });
|
||
}
|
||
};
|
||
|
||
const approve = useMutation({
|
||
mutationFn: () =>
|
||
lastMileRequestsService.approve(approveTarget!.id, Number(advanceAmount)),
|
||
onSuccess: () => {
|
||
void invalidate();
|
||
toast({ title: "Request approved" });
|
||
setApproveTarget(null);
|
||
setAdvanceAmount("");
|
||
},
|
||
onError: (e: unknown) => {
|
||
const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||
toast({ title: "Approve failed", description, variant: "destructive" });
|
||
},
|
||
});
|
||
|
||
const reject = useMutation({
|
||
mutationFn: () => lastMileRequestsService.reject(rejectTarget!.id, rejectReason.trim()),
|
||
onSuccess: () => {
|
||
void invalidate();
|
||
toast({ title: "Request rejected" });
|
||
setRejectTarget(null);
|
||
setRejectReason("");
|
||
},
|
||
onError: (e: unknown) => {
|
||
const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||
toast({ title: "Reject failed", description, variant: "destructive" });
|
||
},
|
||
});
|
||
|
||
const columns: ColumnDef<LastMileRequest>[] = [
|
||
{
|
||
id: "booking",
|
||
header: () => <span>Booking</span>,
|
||
cell: ({ row }) => {
|
||
const r = row.original;
|
||
return (
|
||
<Stack gap={0}>
|
||
<Text size="sm" fw={600}>{r.booking?.reference ?? r.bookingId}</Text>
|
||
<Text size="xs" c="dimmed">{r.booking?.company?.name ?? "—"}</Text>
|
||
</Stack>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: "containers",
|
||
header: () => <span>Requested Containers</span>,
|
||
cell: ({ row }) => {
|
||
const r = row.original;
|
||
const nums = r.requestedContainerNumbers;
|
||
return (
|
||
<Stack gap={0}>
|
||
<Text size="sm">{nums?.length ? nums.join(", ") : "—"}</Text>
|
||
{r.requestedDeliveryDate && (
|
||
<Text size="xs" c="dimmed">Delivery: {r.requestedDeliveryDate}</Text>
|
||
)}
|
||
</Stack>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: "submittedAt",
|
||
header: () => <span>Submitted</span>,
|
||
cell: ({ row }) => <Text size="sm">{fmtDate(row.original.submittedAt)}</Text>,
|
||
},
|
||
{
|
||
id: "status",
|
||
header: () => <span>Status</span>,
|
||
cell: ({ row }) => {
|
||
const meta = STATUS_META[row.original.status];
|
||
return (
|
||
<Badge color={meta.color} variant="light" size="sm">
|
||
{meta.label}
|
||
</Badge>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: "contract",
|
||
header: () => <span>LM Contract</span>,
|
||
cell: ({ row }) => {
|
||
const r = row.original;
|
||
if (r.status !== "APPROVED") return <Text size="sm" c="dimmed">—</Text>;
|
||
return (
|
||
<Stack gap={4} align="flex-start">
|
||
<Badge color={r.customerSignedAt ? "green" : "yellow"} variant="light" size="sm">
|
||
{r.customerSignedAt ? "Signed" : "Awaiting signature"}
|
||
</Badge>
|
||
<Button size="compact-xs" variant="subtle" onClick={() => void downloadContract(r)}>
|
||
Download PDF
|
||
</Button>
|
||
</Stack>
|
||
);
|
||
},
|
||
},
|
||
...(canApprove
|
||
? [
|
||
{
|
||
id: "actions",
|
||
header: () => <span>Actions</span>,
|
||
cell: ({ row }: { row: { original: LastMileRequest } }) => {
|
||
const r = row.original;
|
||
if (r.status !== "SUBMITTED") return null;
|
||
return (
|
||
<Group gap="xs">
|
||
<Button size="xs" variant="light" color="green" onClick={() => setApproveTarget(r)}>
|
||
Approve
|
||
</Button>
|
||
<Button size="xs" variant="light" color="red" onClick={() => setRejectTarget(r)}>
|
||
Reject
|
||
</Button>
|
||
</Group>
|
||
);
|
||
},
|
||
} as ColumnDef<LastMileRequest>,
|
||
]
|
||
: []),
|
||
];
|
||
|
||
return (
|
||
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||
<Stack gap={0}>
|
||
<Box px="md" pt="md" pb="sm" w="100%">
|
||
<Stack gap="sm">
|
||
<Text size="sm" c="dimmed">
|
||
{freeTrucks?.count ?? 0} truck{freeTrucks?.count === 1 ? "" : "s"} currently free
|
||
</Text>
|
||
<Group gap="xs" wrap="wrap">
|
||
{FILTER_OPTIONS.map((option) => {
|
||
const active = statusFilter === option.value;
|
||
return (
|
||
<Button
|
||
key={option.value}
|
||
size="xs"
|
||
variant={active ? "filled" : "default"}
|
||
styles={{ label: { fontWeight: 500 } }}
|
||
onClick={() => {
|
||
setStatusFilter(option.value);
|
||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||
}}
|
||
>
|
||
{option.label}
|
||
</Button>
|
||
);
|
||
})}
|
||
</Group>
|
||
</Stack>
|
||
</Box>
|
||
|
||
<DataTable
|
||
columns={columns}
|
||
data={rows}
|
||
status={isLoading ? "loading" : "success"}
|
||
emptyMessage="No last-mile confirmation requests found"
|
||
pagination={{
|
||
pageIndex: pagination.pageIndex,
|
||
pageSize: pagination.pageSize,
|
||
pageCount: meta?.totalPages ?? 1,
|
||
totalCount: meta?.total ?? 0,
|
||
}}
|
||
tableOptions={{
|
||
manualPagination: true,
|
||
pageCount: meta?.totalPages ?? 1,
|
||
state: { pagination },
|
||
onPaginationChange: setPagination,
|
||
}}
|
||
containerClassName="border-0 shadow-none bg-transparent"
|
||
footer={({ table, pagination: fp }) => (
|
||
<DataTableFooter table={table} pagination={fp} options={{ labels: { items: "requests" } }} />
|
||
)}
|
||
/>
|
||
</Stack>
|
||
|
||
<Modal
|
||
opened={Boolean(approveTarget)}
|
||
onClose={() => {
|
||
setApproveTarget(null);
|
||
setAdvanceAmount("");
|
||
}}
|
||
title={<Text fw={700}>Approve request{approveTarget?.booking?.reference ? ` · ${approveTarget.booking.reference}` : ""}</Text>}
|
||
centered
|
||
>
|
||
<Stack gap="md">
|
||
{estimate?.total != null && (
|
||
<Stack gap={4}>
|
||
{estimate.lines.map((line) => (
|
||
<Text key={line.description} size="xs" c="dimmed">
|
||
{line.description} — {line.amount.toLocaleString()}
|
||
</Text>
|
||
))}
|
||
<Text size="sm" fw={600}>
|
||
Estimated total: {estimate.total.toLocaleString()} {estimate.currency}
|
||
{estimate.estimatedKm != null
|
||
? ` · ${estimate.estimatedKm} km (straight-line estimate)`
|
||
: ""}
|
||
</Text>
|
||
</Stack>
|
||
)}
|
||
<NumberInput
|
||
label="Advance amount"
|
||
placeholder="0.00"
|
||
required
|
||
min={0.01}
|
||
value={advanceAmount}
|
||
onChange={setAdvanceAmount}
|
||
/>
|
||
<Group justify="flex-end">
|
||
<Button
|
||
variant="default"
|
||
onClick={() => {
|
||
setApproveTarget(null);
|
||
setAdvanceAmount("");
|
||
}}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
disabled={!(Number(advanceAmount) > 0)}
|
||
loading={approve.isPending}
|
||
onClick={() => approve.mutate()}
|
||
>
|
||
Confirm
|
||
</Button>
|
||
</Group>
|
||
</Stack>
|
||
</Modal>
|
||
|
||
<Modal
|
||
opened={Boolean(rejectTarget)}
|
||
onClose={() => setRejectTarget(null)}
|
||
title={<Text fw={700}>Reject request{rejectTarget?.booking?.reference ? ` · ${rejectTarget.booking.reference}` : ""}</Text>}
|
||
centered
|
||
>
|
||
<Stack gap="md">
|
||
<Textarea
|
||
label="Reason"
|
||
placeholder="Why is this request being rejected?"
|
||
required
|
||
minRows={3}
|
||
value={rejectReason}
|
||
onChange={(e) => setRejectReason(e.currentTarget.value)}
|
||
/>
|
||
<Group justify="flex-end">
|
||
<Button variant="default" onClick={() => setRejectTarget(null)}>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
color="red"
|
||
disabled={!rejectReason.trim()}
|
||
loading={reject.isPending}
|
||
onClick={() => reject.mutate()}
|
||
>
|
||
Confirm
|
||
</Button>
|
||
</Group>
|
||
</Stack>
|
||
</Modal>
|
||
</Card>
|
||
);
|
||
}
|