mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
Last mile confirmation request , approval, payment Feature
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
import { 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,
|
||||
});
|
||||
|
||||
const invalidate = () =>
|
||||
qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.ROOT });
|
||||
|
||||
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 nums = row.original.requestedContainerNumbers;
|
||||
return <Text size="sm">{nums?.length ? nums.join(", ") : "—"}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
...(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)}
|
||||
title={<Text fw={700}>Approve request{approveTarget?.booking?.reference ? ` · ${approveTarget.booking.reference}` : ""}</Text>}
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<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)}>
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -155,6 +155,13 @@ export const QUERY_KEYS = {
|
||||
byId: (id: string) => ["last-mile", "detail", id] as const,
|
||||
},
|
||||
|
||||
LAST_MILE_REQUESTS: {
|
||||
ROOT: ["last-mile-requests"] as const,
|
||||
list: (filter?: Record<string, unknown>) =>
|
||||
["last-mile-requests", "list", filter ?? {}] as const,
|
||||
freeTruckCount: ["last-mile-requests", "free-truck-count"] as const,
|
||||
},
|
||||
|
||||
RULE_ENGINE: {
|
||||
ROOT: ["rule-engine"] as const,
|
||||
list: (
|
||||
|
||||
@@ -707,6 +707,14 @@ export const URL_CONSTANTS = {
|
||||
PROOF_OF_DELIVERY: (id: string) => `/last-mile/${id}/proof-of-delivery`,
|
||||
},
|
||||
|
||||
LAST_MILE_REQUESTS: {
|
||||
BASE: "/last-mile-requests",
|
||||
BY_ID: (id: string) => `/last-mile-requests/${id}`,
|
||||
FREE_TRUCK_COUNT: "/last-mile-requests/free-truck-count",
|
||||
APPROVE: (id: string) => `/last-mile-requests/${id}/approve`,
|
||||
REJECT: (id: string) => `/last-mile-requests/${id}/reject`,
|
||||
},
|
||||
|
||||
DRIVERS: {
|
||||
BASE: "/drivers",
|
||||
BY_ID: (id: string) => `/drivers/${id}`,
|
||||
|
||||
@@ -116,6 +116,9 @@ export const FREIGHT_PERMS = {
|
||||
assignVehicles: "edr_freight_app:last_mile:assign_vehicles",
|
||||
setDistances: "edr_freight_app:last_mile:set_distances",
|
||||
generateInvoice: "edr_freight_app:last_mile:generate_invoice",
|
||||
requestView: "edr_freight_app:last_mile:request_view",
|
||||
requestReview: "edr_freight_app:last_mile:request_review",
|
||||
requestApprove: "edr_freight_app:last_mile:request_approve",
|
||||
},
|
||||
locomotives: {
|
||||
view: "edr_freight_app:locomotives:view",
|
||||
|
||||
@@ -47,6 +47,9 @@ import { bookingsService } from "@/services/bookings.service";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
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 { LastMileRequestsPanel } from "@/components/operations/LastMileRequestsPanel";
|
||||
import {
|
||||
LAST_MILE_STATUSES,
|
||||
type LastMileApiStatus,
|
||||
@@ -543,6 +546,9 @@ const buildTripSlipHtml = (record: LastMileRecord, vehicle?: TripSlipVehicle | n
|
||||
const LastMilePage = () => {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const canViewRequests = hasPermission(user, FREIGHT_PERMS.lastMile.requestView);
|
||||
const [view, setView] = useState<"legs" | "requests">("legs");
|
||||
const [podRecord, setPodRecord] = useState<LastMileRecord | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
@@ -1590,6 +1596,30 @@ const LastMilePage = () => {
|
||||
|
||||
return (
|
||||
<Stack gap="md" p="md">
|
||||
{canViewRequests && (
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant={view === "legs" ? "filled" : "default"}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setView("legs")}
|
||||
>
|
||||
Deliveries
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={view === "requests" ? "filled" : "default"}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setView("requests")}
|
||||
>
|
||||
Requests
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{view === "requests" && canViewRequests ? (
|
||||
<LastMileRequestsPanel />
|
||||
) : (
|
||||
<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%">
|
||||
@@ -1672,6 +1702,7 @@ const LastMilePage = () => {
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 2-step Assign Mile (arrival queue → vehicle) */}
|
||||
<Modal
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { api } from '../auth/http';
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
|
||||
export const LAST_MILE_REQUEST_STATUSES = [
|
||||
'AWAITING_CONFIRMATION',
|
||||
'SUBMITTED',
|
||||
'APPROVED',
|
||||
'REJECTED',
|
||||
] as const;
|
||||
export type LastMileRequestStatus = (typeof LAST_MILE_REQUEST_STATUSES)[number];
|
||||
|
||||
export interface LastMileRequest {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
booking?: {
|
||||
id: string;
|
||||
reference?: string;
|
||||
companyId?: string;
|
||||
company?: { id: string; name?: string } | null;
|
||||
} | null;
|
||||
trainScheduleId: string;
|
||||
status: LastMileRequestStatus;
|
||||
requestedContainerNumbers?: string[] | null;
|
||||
reminderSentAt?: string | null;
|
||||
submittedByUserId?: string | null;
|
||||
submittedAt?: string | null;
|
||||
reviewedByStaffId?: string | null;
|
||||
reviewedAt?: string | null;
|
||||
rejectionReason?: string | null;
|
||||
resultingLastMileId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LastMileRequestListResponse {
|
||||
data: LastMileRequest[];
|
||||
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||
}
|
||||
|
||||
const LMR = URL_CONSTANTS.LAST_MILE_REQUESTS;
|
||||
|
||||
export const lastMileRequestsService = {
|
||||
list: (params?: { status?: LastMileRequestStatus; bookingId?: string; page?: number; pageSize?: number }) =>
|
||||
api.get<LastMileRequestListResponse>(LMR.BASE, { params }),
|
||||
getById: (id: string) => api.get<LastMileRequest>(LMR.BY_ID(id)),
|
||||
freeTruckCount: () => api.get<{ count: number }>(LMR.FREE_TRUCK_COUNT),
|
||||
approve: (id: string, advanceAmount: number) =>
|
||||
api.post<LastMileRequest>(LMR.APPROVE(id), { advanceAmount }),
|
||||
reject: (id: string, reason: string) =>
|
||||
api.post<LastMileRequest>(LMR.REJECT(id), { reason }),
|
||||
};
|
||||
Reference in New Issue
Block a user