mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +00:00
555 lines
18 KiB
TypeScript
555 lines
18 KiB
TypeScript
import { useCallback, useMemo, useRef, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import {
|
|
AlertCircle,
|
|
ArrowRight,
|
|
Calendar,
|
|
Clock,
|
|
FileText,
|
|
Inbox,
|
|
LayoutList,
|
|
Package,
|
|
Plus,
|
|
RefreshCw,
|
|
Search,
|
|
User,
|
|
X,
|
|
} from "lucide-react";
|
|
import {
|
|
Container,
|
|
Stack,
|
|
Group,
|
|
Text,
|
|
Card,
|
|
TextInput,
|
|
ActionIcon,
|
|
Paper,
|
|
Tabs,
|
|
} from "@mantine/core";
|
|
|
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
|
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
|
import {
|
|
BookingStatusTabs,
|
|
type BookingStatusTabKey,
|
|
} from "@/components/bookings/BookingStatusTabs";
|
|
import { BookingStatGrid } from "@/components/bookings/BookingStatGrid";
|
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
|
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
|
|
import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard";
|
|
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
|
import { OperationsBookingQueue } from "@/components/bookings/OperationsBookingQueue";
|
|
import { OperationsScheduledBookings } from "@/components/bookings/OperationsScheduledBookings";
|
|
import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
|
|
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
|
import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
|
|
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
|
import {
|
|
useBookingDetail,
|
|
useBookingList,
|
|
useBookingListSummary,
|
|
} from "@/hooks/bookings/useBookings";
|
|
import type { BookingListFilter } from "@/services/bookings.service";
|
|
import type { BookingListRow } from "@/types/booking";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
DataTable,
|
|
DataTableFooter,
|
|
type ColumnDef,
|
|
usePagination,
|
|
Badge,
|
|
Button,
|
|
Input,
|
|
} from "@edr/ui-common";
|
|
|
|
function getStatusesForTab(tab: BookingStatusTabKey): string | undefined {
|
|
const match = BOOKING_LIST_TABS.find((t) => t.key === tab);
|
|
if (!match?.statuses?.length) return undefined;
|
|
return match.statuses.join(",");
|
|
}
|
|
|
|
type OperationsSubTab = "ready" | "scheduled";
|
|
|
|
export default function BookingRequestsPage() {
|
|
const navigate = useNavigate();
|
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
const [query, setQuery] = useState("");
|
|
const [activeTab, setActiveTab] = useState<BookingStatusTabKey>("in_approval");
|
|
const [operationsSubTab, setOperationsSubTab] = useState<OperationsSubTab>("ready");
|
|
const [allocateOpen, setAllocateOpen] = useState(false);
|
|
const [allocateIds, setAllocateIds] = useState<string[]>([]);
|
|
const suppressRowClickRef = useRef(false);
|
|
const suppressRowClick = useCallback(() => {
|
|
suppressRowClickRef.current = true;
|
|
window.setTimeout(() => {
|
|
suppressRowClickRef.current = false;
|
|
}, 400);
|
|
}, []);
|
|
|
|
const tabStatuses = getStatusesForTab(activeTab);
|
|
const isOperationsTab = activeTab === "operations";
|
|
|
|
const filter: BookingListFilter = useMemo(() => {
|
|
if (isOperationsTab) {
|
|
if (operationsSubTab === "ready") {
|
|
return {
|
|
page: 1,
|
|
pageSize: 100,
|
|
statuses: "PAID",
|
|
schedulingStatuses: "NOT_SCHEDULED,HOLDING,ELIGIBLE",
|
|
assignedToSchedule: "false",
|
|
sortBy: "isGovernment",
|
|
sortOrder: "DESC",
|
|
tab: activeTab,
|
|
};
|
|
}
|
|
return {
|
|
page: 1,
|
|
pageSize: 100,
|
|
statuses: "PAID",
|
|
schedulingStatuses: "SCHEDULED,DISPATCHED",
|
|
sortBy: "scheduledDate",
|
|
sortOrder: "ASC",
|
|
tab: activeTab,
|
|
};
|
|
}
|
|
return {
|
|
page: pagination.pageIndex + 1,
|
|
pageSize: pagination.pageSize,
|
|
sortBy: "createdAt",
|
|
sortOrder: "DESC",
|
|
tab: activeTab,
|
|
...(tabStatuses ? { statuses: tabStatuses } : {}),
|
|
};
|
|
}, [
|
|
isOperationsTab,
|
|
operationsSubTab,
|
|
pagination.pageIndex,
|
|
pagination.pageSize,
|
|
activeTab,
|
|
tabStatuses,
|
|
]);
|
|
|
|
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
|
|
const primaryAllocateId = allocateIds[0];
|
|
const { data: allocateBooking } = useBookingDetail(
|
|
allocateOpen ? primaryAllocateId : undefined,
|
|
);
|
|
const {
|
|
data: summary,
|
|
isLoading: summaryLoading,
|
|
refetch: refetchSummary,
|
|
} = useBookingListSummary(filter);
|
|
|
|
const rows = useMemo(() => {
|
|
const items = (data?.items ?? []).map(toBookingListRow);
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return items;
|
|
return items.filter(
|
|
(b) =>
|
|
b.reference.toLowerCase().includes(q) ||
|
|
b.customerLabel.toLowerCase().includes(q),
|
|
);
|
|
}, [data?.items, query]);
|
|
|
|
const total = data?.total ?? 0;
|
|
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
|
const hasSearch = query.trim().length > 0;
|
|
const showEmpty = !isLoading && !isError && rows.length === 0;
|
|
|
|
const metrics = summary?.metrics;
|
|
const tabCounts = summary?.tabs;
|
|
const statValue = (value: number | undefined) =>
|
|
summaryLoading ? "—" : (value ?? 0);
|
|
|
|
const handleRefresh = useCallback(() => {
|
|
void refetch();
|
|
void refetchSummary();
|
|
}, [refetch, refetchSummary]);
|
|
|
|
const handleAllocateFromQueue = useCallback(
|
|
(ids: string[]) => {
|
|
const selected = rows.filter((b) => ids.includes(b.id));
|
|
const sorted = [...selected].sort(
|
|
(a, b) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0),
|
|
);
|
|
setAllocateIds(sorted.map((b) => b.id));
|
|
setAllocateOpen(true);
|
|
},
|
|
[rows],
|
|
);
|
|
|
|
const handleRowClick = useCallback(
|
|
(row: BookingListRow) => {
|
|
if (suppressRowClickRef.current) return;
|
|
navigate(`/dashboard/booking-requests/${row.id}`);
|
|
},
|
|
[navigate],
|
|
);
|
|
|
|
const columns: ColumnDef<BookingListRow>[] = [
|
|
{
|
|
id: "booking",
|
|
header: () => <span className={bookingTable.headerCell}>Booking</span>,
|
|
cell: ({ row }) => {
|
|
const b = row.original;
|
|
return (
|
|
<div className="flex items-center gap-3 py-1.5">
|
|
<div className={bookingTable.rowIcon}>
|
|
<Package className="size-4" strokeWidth={1.75} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="truncate font-medium text-foreground">{b.reference}</p>
|
|
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
|
<User className="size-3 shrink-0 opacity-70" />
|
|
{b.customerLabel}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "route",
|
|
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
|
cell: ({ row }) => {
|
|
const b = row.original;
|
|
return (
|
|
<div className="space-y-1 py-1">
|
|
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
|
<span className="max-w-[8rem] truncate">{b.originLabel}</span>
|
|
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
|
|
<span className="max-w-[8rem] truncate">{b.destinationLabel}</span>
|
|
</div>
|
|
<div className="flex gap-1.5">
|
|
<Badge
|
|
variant="outline"
|
|
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
|
|
>
|
|
{b.tradeDirection}
|
|
</Badge>
|
|
<Badge
|
|
variant="secondary"
|
|
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
|
|
>
|
|
{b.freightType}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "status",
|
|
size: 200,
|
|
minSize: 180,
|
|
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
|
cell: ({ row }) => (
|
|
<div className="py-1">
|
|
<BookingStatusBadge status={row.original.status} />
|
|
</div>
|
|
),
|
|
meta: {
|
|
headerClassName: "min-w-[11rem]",
|
|
cellClassName: "min-w-[11rem]",
|
|
},
|
|
},
|
|
{
|
|
id: "approval",
|
|
header: () => (
|
|
<span className={bookingTable.headerCell}>Approval</span>
|
|
),
|
|
cell: ({ row }) => <BookingApprovalProgressCell row={row.original} />,
|
|
},
|
|
{
|
|
id: "scheduled",
|
|
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
|
|
cell: ({ row }) => (
|
|
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
<Calendar className="size-3.5" />
|
|
{row.original.scheduledDate}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
id: "priority",
|
|
header: () => <span className={bookingTable.headerCell}>Priority</span>,
|
|
cell: ({ row }) => (
|
|
<BookingPriorityBadge score={row.original.priorityScore} />
|
|
),
|
|
},
|
|
{
|
|
id: "amount",
|
|
header: () => (
|
|
<span className={bookingTable.headerCell}>Amount</span>
|
|
),
|
|
cell: ({ row }) => {
|
|
const b = row.original;
|
|
return (
|
|
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
|
|
{b.paymentCurrency}{" "}
|
|
{b.totalAmount.toLocaleString(undefined, {
|
|
minimumFractionDigits: 2,
|
|
})}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "actions",
|
|
size: 140,
|
|
header: () => (
|
|
<span className={bookingTable.headerCell}>Actions</span>
|
|
),
|
|
cell: ({ row }) => (
|
|
<BookingActionsMenu
|
|
row={row.original}
|
|
variant="table"
|
|
onSuppressRowClick={suppressRowClick}
|
|
/>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div style={{ background: "var(--mantine-color-gray-0)", minHeight: "100vh" }}>
|
|
<Container size="xxl" py="xl">
|
|
<Breadcrumbs items={[{ label: "Operations" }, { label: "Booking requests" }]} />
|
|
{/*
|
|
<Card
|
|
p="lg"
|
|
radius="lg"
|
|
withBorder
|
|
mb="xl"
|
|
style={{
|
|
background: "white",
|
|
border: "1px solid var(--mantine-color-gray-2)",
|
|
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
|
|
}}
|
|
>
|
|
<Group justify="space-between" align="flex-start">
|
|
<Group gap="md" align="flex-start">
|
|
<ThemeIcon
|
|
size="lg"
|
|
radius="lg"
|
|
color="green"
|
|
variant="light"
|
|
>
|
|
<Inbox size={28} />
|
|
</ThemeIcon>
|
|
<Stack gap={8}>
|
|
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
|
|
Operations
|
|
</Text>
|
|
<Title order={1} size="h2">
|
|
Booking Requests
|
|
</Title>
|
|
<Text size="sm" c="dimmed" maw="500px">
|
|
Track bookings from submission through payment and operations. Monitor status, prioritize urgent bookings, and manage approvals.
|
|
</Text>
|
|
</Stack>
|
|
</Group>
|
|
<MantineButton
|
|
variant="light"
|
|
color="green"
|
|
leftSection={<RefreshCw size={18} />}
|
|
disabled={isFetching}
|
|
onClick={handleRefresh}
|
|
loading={isFetching}
|
|
>
|
|
Refresh
|
|
</MantineButton>
|
|
</Group>
|
|
</Card> */}
|
|
|
|
<div className="mt-6"></div>
|
|
<Stack gap="lg">
|
|
<BookingStatGrid
|
|
items={[
|
|
{
|
|
label: "In queue",
|
|
value: statValue(metrics?.inQueue),
|
|
hint: "Total matching filter",
|
|
icon: LayoutList,
|
|
},
|
|
{
|
|
label: "On this page",
|
|
value: statValue(metrics?.onThisPage),
|
|
hint: "Current page",
|
|
icon: FileText,
|
|
},
|
|
{
|
|
label: "Needs action",
|
|
value: statValue(metrics?.needsAction),
|
|
hint: "Submitted or pending approval",
|
|
icon: Clock,
|
|
accent:
|
|
!summaryLoading && (metrics?.needsAction ?? 0) > 0
|
|
? "amber"
|
|
: "default",
|
|
},
|
|
{
|
|
label: "Urgent",
|
|
value: statValue(metrics?.urgent),
|
|
hint: "High priority score",
|
|
icon: AlertCircle,
|
|
accent:
|
|
!summaryLoading && (metrics?.urgent ?? 0) > 0 ? "rose" : "default",
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<Paper
|
|
p="md"
|
|
radius="lg"
|
|
withBorder
|
|
style={{
|
|
background: "white",
|
|
border: "1px solid var(--mantine-color-gray-2)",
|
|
}}
|
|
>
|
|
<BookingStatusTabs
|
|
active={activeTab}
|
|
onChange={(tab) => {
|
|
setActiveTab(tab);
|
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
|
}}
|
|
counts={tabCounts}
|
|
/>
|
|
</Paper>
|
|
|
|
<Card
|
|
p="md"
|
|
radius="lg"
|
|
withBorder
|
|
style={{
|
|
background: "white",
|
|
border: "1px solid var(--mantine-color-gray-2)",
|
|
}}
|
|
>
|
|
<Stack gap="md">
|
|
<Group justify="space-between" gap="md" wrap="wrap">
|
|
<TextInput
|
|
placeholder="Search reference or customer…"
|
|
leftSection={<Search size={18} />}
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
rightSection={
|
|
query && (
|
|
<ActionIcon
|
|
size="sm"
|
|
color="gray"
|
|
radius="md"
|
|
variant="transparent"
|
|
onClick={() => setQuery("")}
|
|
>
|
|
<X size={16} />
|
|
</ActionIcon>
|
|
)
|
|
}
|
|
style={{ flex: 1, minWidth: "200px" }}
|
|
radius="lg"
|
|
/>
|
|
<Group gap="sm">
|
|
<Button
|
|
variant="filled"
|
|
leftSection={<Plus size={16} />}
|
|
onClick={() => navigate("/dashboard/booking-requests/new")}
|
|
>
|
|
Create booking
|
|
</Button>
|
|
<Text size="sm" c="dimmed">
|
|
{total} record{total !== 1 ? "s" : ""}
|
|
</Text>
|
|
</Group>
|
|
</Group>
|
|
|
|
{isOperationsTab ? (
|
|
<Stack gap="md">
|
|
<Tabs
|
|
value={operationsSubTab}
|
|
onChange={(value) =>
|
|
setOperationsSubTab((value as OperationsSubTab) ?? "ready")
|
|
}
|
|
>
|
|
<Tabs.List>
|
|
<Tabs.Tab value="ready">Ready to allocate</Tabs.Tab>
|
|
<Tabs.Tab value="scheduled">On train / scheduled</Tabs.Tab>
|
|
</Tabs.List>
|
|
</Tabs>
|
|
{isError ? (
|
|
<BookingTableEmpty
|
|
isError
|
|
hasSearch={false}
|
|
onRetry={handleRefresh}
|
|
/>
|
|
) : operationsSubTab === "ready" ? (
|
|
<OperationsBookingQueue
|
|
bookings={rows}
|
|
isLoading={isLoading}
|
|
onAllocate={handleAllocateFromQueue}
|
|
/>
|
|
) : (
|
|
<OperationsScheduledBookings
|
|
bookings={rows}
|
|
isLoading={isLoading}
|
|
/>
|
|
)}
|
|
</Stack>
|
|
) : showEmpty ? (
|
|
<BookingTableEmpty
|
|
isError={isError}
|
|
hasSearch={hasSearch}
|
|
onRetry={handleRefresh}
|
|
/>
|
|
) : (
|
|
<div style={{ overflowX: "auto" }}>
|
|
<DataTable
|
|
columns={columns}
|
|
data={rows}
|
|
status={isLoading ? "loading" : isError ? "error" : "success"}
|
|
onRowClick={handleRowClick}
|
|
pagination={{
|
|
pageIndex: pagination.pageIndex,
|
|
pageSize: pagination.pageSize,
|
|
pageCount,
|
|
totalCount: total,
|
|
}}
|
|
tableOptions={{
|
|
state: { pagination },
|
|
onPaginationChange: setPagination,
|
|
manualPagination: true,
|
|
pageCount,
|
|
}}
|
|
containerClassName={cn(
|
|
"border-0 shadow-none",
|
|
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
|
|
"[&_thead_th]:bg-muted/20 [&_thead_th]:backdrop-blur-sm",
|
|
"[&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
|
|
"[&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/20",
|
|
)}
|
|
footer={DataTableFooter}
|
|
/>
|
|
</div>
|
|
)}
|
|
</Stack>
|
|
</Card>
|
|
</Stack>
|
|
|
|
{allocateBooking ? (
|
|
<AllocateBookingWizard
|
|
booking={allocateBooking}
|
|
opened={allocateOpen}
|
|
onClose={() => {
|
|
setAllocateOpen(false);
|
|
setAllocateIds([]);
|
|
void refetch();
|
|
}}
|
|
initialBookingIds={allocateIds}
|
|
/>
|
|
) : null}
|
|
</Container>
|
|
</div>
|
|
);
|
|
}
|