mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
booking flow,summtion, approval, contract, mock payemnt and integration to back office, and also add permissions
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -17,7 +17,6 @@ import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { invalidateBookingDetail } from "@/utils/queryInvalidation";
|
||||
import {
|
||||
bookingsService,
|
||||
type ContractView,
|
||||
type SignContractPayload,
|
||||
} from "@/services/bookings.service";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -37,6 +36,7 @@ export default function BookingContractPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [signOpen, setSignOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
@@ -82,7 +82,10 @@ export default function BookingContractPage() {
|
||||
}
|
||||
}, [id, data?.reference]);
|
||||
|
||||
const handlePrint = () => window.print();
|
||||
const handlePrint = () => {
|
||||
iframeRef.current?.contentWindow?.focus();
|
||||
iframeRef.current?.contentWindow?.print();
|
||||
};
|
||||
|
||||
const openSign = () => {
|
||||
setSignerName("");
|
||||
@@ -135,7 +138,7 @@ export default function BookingContractPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sticky top-0 z-10 mb-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-background/95 p-4 shadow-sm backdrop-blur print:hidden">
|
||||
<div style={{boxShadow:"3px 3px 20px 1px lightgrey"}} className="sticky top-0 z-10 mb-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-background/95 p-4 shadow-sm backdrop-blur print:hidden">
|
||||
<Button variant="ghost" size="sm" className="gap-2" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back
|
||||
@@ -158,9 +161,19 @@ export default function BookingContractPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article
|
||||
className="contract-document mx-auto max-w-[210mm] rounded-xl border bg-white p-8 shadow-sm print:border-0 print:shadow-none"
|
||||
dangerouslySetInnerHTML={{ __html: extractBodyHtml(data.html) }}
|
||||
{!data.hasContractDocument && (
|
||||
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 print:hidden">
|
||||
A PDF has not been stored yet. Download will generate the latest
|
||||
contract document automatically.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title={`Contract ${data.reference}`}
|
||||
srcDoc={data.html}
|
||||
sandbox="allow-same-origin"
|
||||
className="mx-auto block min-h-[297mm] w-full max-w-[210mm] rounded-xl border bg-white shadow-sm print:h-[297mm] print:border-0 print:shadow-none"
|
||||
/>
|
||||
|
||||
<Dialog open={signOpen} onOpenChange={setSignOpen}>
|
||||
@@ -210,9 +223,3 @@ export default function BookingContractPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Render server HTML body content inside our layout wrapper. */
|
||||
function extractBodyHtml(fullHtml: string): string {
|
||||
const match = fullHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
||||
return match ? match[1] : fullHtml;
|
||||
}
|
||||
|
||||
@@ -13,17 +13,20 @@ import {
|
||||
RefreshCw,
|
||||
Train,
|
||||
Truck,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
|
||||
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
|
||||
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
|
||||
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
|
||||
import { bookingSurface } from "@/components/bookings/booking-ui.styles";
|
||||
import {
|
||||
bookingGlass,
|
||||
bookingSurface,
|
||||
} from "@/components/bookings/booking-ui.styles";
|
||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import {
|
||||
@@ -49,7 +52,7 @@ export default function BookingRequestDetailPage() {
|
||||
return (
|
||||
<div className={bookingSurface.page}>
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 p-8">
|
||||
<Loader2 className="size-10 animate-spin text-primary" />
|
||||
<Loader2 className="size-10 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Loading booking…
|
||||
</p>
|
||||
@@ -68,8 +71,13 @@ export default function BookingRequestDetailPage() {
|
||||
"mx-auto max-w-md p-12 text-center",
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex size-16 items-center justify-center rounded-2xl bg-muted">
|
||||
<Package className="size-8 text-muted-foreground" />
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex size-16 items-center justify-center rounded-2xl",
|
||||
bookingGlass.iconWellGreen,
|
||||
)}
|
||||
>
|
||||
<Package className="size-8" />
|
||||
</div>
|
||||
<h1 className="mt-6 text-xl font-bold text-foreground">
|
||||
Booking not found
|
||||
@@ -107,31 +115,54 @@ export default function BookingRequestDetailPage() {
|
||||
|
||||
<div className={bookingSurface.detailHero}>
|
||||
<div className={bookingSurface.heroGlow} />
|
||||
<div className={bookingSurface.heroSheen} />
|
||||
<div className="relative p-6 sm:p-8">
|
||||
<div className="mb-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-2 gap-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => navigate("/dashboard/booking-requests")}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to list
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex size-16 shrink-0 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-lg shadow-primary/20">
|
||||
<Package className="size-7" />
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-16 shrink-0 items-center justify-center rounded-2xl",
|
||||
bookingGlass.iconWellGreen,
|
||||
)}
|
||||
>
|
||||
<Package className="size-7" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Booking reference
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground sm:text-[1.75rem]">
|
||||
{booking.reference}
|
||||
</h1>
|
||||
<BookingStatusBadge status={booking.status} />
|
||||
<BookingPriorityBadge score={booking.priorityScore} />
|
||||
</div>
|
||||
{booking.nextStep && (
|
||||
<NextStepBanner nextStep={booking.nextStep} className="max-w-xl" />
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5 font-medium text-foreground">
|
||||
<Building2 className="size-4 text-primary" />
|
||||
<Building2 className="size-4 opacity-70" />
|
||||
{row.customerLabel}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Calendar className="size-4" />
|
||||
<Calendar className="size-4 opacity-70" />
|
||||
Scheduled {booking.scheduledDate}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Clock className="size-4" />
|
||||
<Clock className="size-4 opacity-70" />
|
||||
Created{" "}
|
||||
{new Date(booking.createdAt).toLocaleDateString(undefined, {
|
||||
dateStyle: "medium",
|
||||
@@ -142,11 +173,11 @@ export default function BookingRequestDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-stretch gap-3 sm:items-end">
|
||||
<div className="rounded-xl border border-primary/20 bg-background/80 px-5 py-4 text-right shadow-sm backdrop-blur-sm">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
<div className={cn(bookingSurface.valueCard, "min-w-[12rem] text-right")}>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Total value
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-2xl font-bold tabular-nums text-foreground">
|
||||
<p className="mt-1 font-mono text-2xl font-semibold tabular-nums text-foreground">
|
||||
{booking.paymentCurrency}{" "}
|
||||
{amount.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
@@ -159,7 +190,7 @@ export default function BookingRequestDetailPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 self-end"
|
||||
className="gap-2 self-end border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
|
||||
disabled={isFetching}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
@@ -191,7 +222,7 @@ export default function BookingRequestDetailPage() {
|
||||
title="Contract summary"
|
||||
subtitle="Generated terms"
|
||||
>
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded-lg border border-border/60 bg-muted/20 p-4 font-mono text-xs leading-relaxed text-muted-foreground">
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded-lg border border-border/50 bg-muted/10 p-4 font-mono text-xs leading-relaxed text-muted-foreground backdrop-blur-sm">
|
||||
{booking.contractSummary}
|
||||
</pre>
|
||||
</SectionShell>
|
||||
@@ -213,7 +244,8 @@ export default function BookingRequestDetailPage() {
|
||||
<div className={bookingSurface.sectionCard}>
|
||||
<div className="px-5 py-4">
|
||||
<Button
|
||||
className="w-full gap-2"
|
||||
className="w-full gap-2 shadow-sm"
|
||||
variant="default"
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/booking-requests/${booking.id}/contract`)
|
||||
}
|
||||
@@ -249,9 +281,7 @@ function SectionShell({
|
||||
return (
|
||||
<div className={bookingSurface.sectionCard}>
|
||||
<div className={bookingSurface.sectionHeader}>
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
{icon}
|
||||
</div>
|
||||
<div className={bookingSurface.sectionIcon}>{icon}</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
||||
{subtitle && (
|
||||
@@ -277,14 +307,22 @@ function RouteCard({
|
||||
title="Route & service"
|
||||
subtitle="Corridor and service level"
|
||||
>
|
||||
<div className="flex flex-col items-stretch gap-6 rounded-xl border border-dashed border-border bg-muted/15 p-5 md:flex-row md:items-center md:justify-between">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-stretch gap-6 rounded-xl border border-dashed border-emerald-500/20 p-5 backdrop-blur-sm md:flex-row md:items-center md:justify-between",
|
||||
bookingGlass.activeTab,
|
||||
)}
|
||||
>
|
||||
<RouteEndpoint label="Origin" station={row.originLabel} />
|
||||
<div className="flex flex-col items-center gap-2 px-4">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Train className="size-5" />
|
||||
<div className={cn("flex size-10 items-center justify-center rounded-full", bookingGlass.iconWellGreen)}>
|
||||
<Train className="size-5 text-black" strokeWidth={1.75} />
|
||||
</div>
|
||||
<ArrowRight className="size-5 rotate-90 text-muted-foreground md:rotate-0" />
|
||||
<Badge variant="outline" className="text-[10px] font-semibold uppercase">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-border/50 bg-background/50 text-[10px] font-medium uppercase backdrop-blur-sm"
|
||||
>
|
||||
{booking.serviceType?.label ??
|
||||
booking.serviceType?.code ??
|
||||
"Rail service"}
|
||||
@@ -369,10 +407,10 @@ function CargoCard({ booking }: { booking: BookingDetail }) {
|
||||
{containers.length > 0 && (
|
||||
<>
|
||||
<Separator className="my-5" />
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<div className="overflow-hidden rounded-lg border border-border/50 backdrop-blur-sm">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/30 text-left text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
<tr className="border-b border-border/50 bg-muted/20 text-left text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<th className="px-4 py-3">Container type</th>
|
||||
<th className="px-4 py-3">Qty</th>
|
||||
<th className="px-4 py-3">VGM / unit</th>
|
||||
@@ -415,14 +453,14 @@ function RouteEndpoint({
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3 md:max-w-[14rem]">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm">
|
||||
<MapPin className="size-5" />
|
||||
<div className={bookingSurface.sectionIconLg}>
|
||||
<MapPin className="size-5" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<p className="truncate text-sm font-bold text-foreground">{station}</p>
|
||||
<p className="truncate text-sm font-semibold text-foreground">{station}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -444,10 +482,10 @@ function MetricTile({
|
||||
highlight && "border-amber-300/50 bg-amber-50/50 dark:bg-amber-950/20",
|
||||
)}
|
||||
>
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1.5 text-sm font-semibold leading-snug text-foreground">
|
||||
<p className="mt-1.5 text-sm font-medium leading-snug text-foreground">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -23,12 +23,18 @@ import {
|
||||
} from "@/components/bookings/BookingStatusTabs";
|
||||
import { BookingStatGrid } from "@/components/bookings/BookingStatGrid";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
|
||||
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||
import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
|
||||
import { bookingInput, bookingSurface } from "@/components/bookings/booking-ui.styles";
|
||||
import {
|
||||
bookingGlass,
|
||||
bookingInput,
|
||||
bookingSurface,
|
||||
bookingTable,
|
||||
} from "@/components/bookings/booking-ui.styles";
|
||||
import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import { useBookingList } from "@/hooks/bookings/useBookings";
|
||||
import { useBookingList, useBookingListSummary } from "@/hooks/bookings/useBookings";
|
||||
import type { BookingListFilter } from "@/services/bookings.service";
|
||||
import type { BookingListRow } from "@/types/booking";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -42,16 +48,26 @@ import {
|
||||
Input,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
function getStatusForTab(tab: BookingStatusTabKey): string | undefined {
|
||||
function getStatusesForTab(tab: BookingStatusTabKey): string | undefined {
|
||||
const match = BOOKING_LIST_TABS.find((t) => t.key === tab);
|
||||
return match?.status ?? undefined;
|
||||
if (!match?.statuses?.length) return undefined;
|
||||
return match.statuses.join(",");
|
||||
}
|
||||
|
||||
export default function BookingRequestsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<BookingStatusTabKey>("SUBMITTED");
|
||||
const [activeTab, setActiveTab] = useState<BookingStatusTabKey>("in_approval");
|
||||
const suppressRowClickRef = useRef(false);
|
||||
const suppressRowClick = useCallback(() => {
|
||||
suppressRowClickRef.current = true;
|
||||
window.setTimeout(() => {
|
||||
suppressRowClickRef.current = false;
|
||||
}, 400);
|
||||
}, []);
|
||||
|
||||
const tabStatuses = getStatusesForTab(activeTab);
|
||||
|
||||
const filter: BookingListFilter = useMemo(
|
||||
() => ({
|
||||
@@ -59,12 +75,18 @@ export default function BookingRequestsPage() {
|
||||
pageSize: pagination.pageSize,
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
...(getStatusForTab(activeTab) ? { status: getStatusForTab(activeTab) } : {}),
|
||||
tab: activeTab,
|
||||
...(tabStatuses ? { statuses: tabStatuses } : {}),
|
||||
}),
|
||||
[pagination.pageIndex, pagination.pageSize, activeTab],
|
||||
[pagination.pageIndex, pagination.pageSize, activeTab, tabStatuses],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
|
||||
const {
|
||||
data: summary,
|
||||
isLoading: summaryLoading,
|
||||
refetch: refetchSummary,
|
||||
} = useBookingListSummary(filter);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const items = (data?.items ?? []).map(toBookingListRow);
|
||||
@@ -82,26 +104,39 @@ export default function BookingRequestsPage() {
|
||||
const hasSearch = query.trim().length > 0;
|
||||
const showEmpty = !isLoading && !isError && rows.length === 0;
|
||||
|
||||
const pendingCount = rows.filter(
|
||||
(b) => b.status === "SUBMITTED" || b.status === "PENDING_APPROVAL",
|
||||
).length;
|
||||
const urgentCount = rows.filter((b) => b.priorityScore >= 1000).length;
|
||||
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 handleRowClick = useCallback(
|
||||
(row: BookingListRow) => {
|
||||
if (suppressRowClickRef.current) return;
|
||||
navigate(`/dashboard/booking-requests/${row.id}`);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const columns: ColumnDef<BookingListRow>[] = [
|
||||
{
|
||||
id: "booking",
|
||||
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Booking</span>,
|
||||
header: () => <span className={bookingTable.headerCell}>Booking</span>,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-primary/80 text-primary-foreground shadow-sm">
|
||||
<Package className="size-4" />
|
||||
<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-semibold text-foreground">{b.reference}</p>
|
||||
<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" />
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{b.customerLabel}
|
||||
</p>
|
||||
</div>
|
||||
@@ -111,7 +146,7 @@ export default function BookingRequestsPage() {
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Route</span>,
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
return (
|
||||
@@ -122,10 +157,16 @@ export default function BookingRequestsPage() {
|
||||
<span className="max-w-[8rem] truncate">{b.destinationLabel}</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px] font-semibold uppercase">
|
||||
<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 px-1.5 text-[10px] font-medium">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
|
||||
>
|
||||
{b.freightType}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -135,12 +176,19 @@ export default function BookingRequestsPage() {
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Status</span>,
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => <BookingStatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "approval",
|
||||
header: () => (
|
||||
<span className={bookingTable.headerCell}>Approval</span>
|
||||
),
|
||||
cell: ({ row }) => <BookingApprovalProgressCell row={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "scheduled",
|
||||
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Scheduled</span>,
|
||||
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" />
|
||||
@@ -150,7 +198,7 @@ export default function BookingRequestsPage() {
|
||||
},
|
||||
{
|
||||
id: "priority",
|
||||
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Priority</span>,
|
||||
header: () => <span className={bookingTable.headerCell}>Priority</span>,
|
||||
cell: ({ row }) => (
|
||||
<BookingPriorityBadge score={row.original.priorityScore} />
|
||||
),
|
||||
@@ -158,7 +206,7 @@ export default function BookingRequestsPage() {
|
||||
{
|
||||
id: "amount",
|
||||
header: () => (
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">Amount</span>
|
||||
<span className={bookingTable.headerCell}>Amount</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
@@ -176,12 +224,14 @@ export default function BookingRequestsPage() {
|
||||
id: "actions",
|
||||
size: 140,
|
||||
header: () => (
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">
|
||||
Actions
|
||||
</span>
|
||||
<span className={bookingTable.headerCell}>Actions</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<BookingActionsMenu row={row.original} variant="table" />
|
||||
<BookingActionsMenu
|
||||
row={row.original}
|
||||
variant="table"
|
||||
onSuppressRowClick={suppressRowClick}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -193,13 +243,22 @@ export default function BookingRequestsPage() {
|
||||
|
||||
<div className={bookingSurface.hero}>
|
||||
<div className={bookingSurface.heroGlow} />
|
||||
<div className={bookingSurface.heroSheen} />
|
||||
<div className="relative flex flex-col gap-6 p-6 sm:flex-row sm:items-center sm:justify-between sm:p-8">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex size-14 shrink-0 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-lg shadow-primary/25">
|
||||
<Inbox className="size-7" />
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-14 shrink-0 items-center justify-center rounded-2xl",
|
||||
bookingGlass.iconWellGreen,
|
||||
)}
|
||||
>
|
||||
<Inbox className="size-6" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Operations
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-foreground sm:text-[1.75rem]">
|
||||
Booking requests
|
||||
</h1>
|
||||
<p className="mt-1.5 max-w-xl text-sm leading-relaxed text-muted-foreground">
|
||||
@@ -211,9 +270,9 @@ export default function BookingRequestsPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
className="gap-2 border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
|
||||
disabled={isFetching}
|
||||
onClick={() => refetch()}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("size-4", isFetching && "animate-spin")}
|
||||
@@ -228,29 +287,33 @@ export default function BookingRequestsPage() {
|
||||
items={[
|
||||
{
|
||||
label: "In queue",
|
||||
value: total,
|
||||
value: statValue(metrics?.inQueue),
|
||||
hint: "Total matching filter",
|
||||
icon: LayoutList,
|
||||
},
|
||||
{
|
||||
label: "On this page",
|
||||
value: rows.length,
|
||||
hint: "Current view",
|
||||
value: statValue(metrics?.onThisPage),
|
||||
hint: "Current page",
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
label: "Needs action",
|
||||
value: pendingCount,
|
||||
value: statValue(metrics?.needsAction),
|
||||
hint: "Submitted or pending approval",
|
||||
icon: Clock,
|
||||
accent: "amber",
|
||||
accent:
|
||||
!summaryLoading && (metrics?.needsAction ?? 0) > 0
|
||||
? "amber"
|
||||
: "default",
|
||||
},
|
||||
{
|
||||
label: "Urgent",
|
||||
value: urgentCount,
|
||||
value: statValue(metrics?.urgent),
|
||||
hint: "High priority score",
|
||||
icon: AlertCircle,
|
||||
accent: urgentCount > 0 ? "rose" : "default",
|
||||
accent:
|
||||
!summaryLoading && (metrics?.urgent ?? 0) > 0 ? "rose" : "default",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -261,9 +324,7 @@ export default function BookingRequestsPage() {
|
||||
setActiveTab(tab);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
counts={{
|
||||
[activeTab]: total,
|
||||
}}
|
||||
counts={tabCounts}
|
||||
/>
|
||||
|
||||
<div className={bookingSurface.panel}>
|
||||
@@ -290,7 +351,11 @@ export default function BookingRequestsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="hidden text-xs text-muted-foreground sm:inline">
|
||||
<span
|
||||
className={cn(
|
||||
"hidden rounded-md border border-border/50 bg-background/50 px-2.5 py-1 text-xs text-muted-foreground backdrop-blur-sm sm:inline",
|
||||
)}
|
||||
>
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
@@ -300,7 +365,7 @@ export default function BookingRequestsPage() {
|
||||
<BookingTableEmpty
|
||||
isError={isError}
|
||||
hasSearch={hasSearch}
|
||||
onRetry={() => refetch()}
|
||||
onRetry={handleRefresh}
|
||||
/>
|
||||
) : (
|
||||
<div className={bookingSurface.tableWrap}>
|
||||
@@ -308,9 +373,7 @@ export default function BookingRequestsPage() {
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={(row) =>
|
||||
navigate(`/dashboard/booking-requests/${row.id}`)
|
||||
}
|
||||
onRowClick={handleRowClick}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
@@ -323,7 +386,13 @@ export default function BookingRequestsPage() {
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none [&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/40"
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Navigate, useLocation, useParams } from "react-router-dom";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canAccessRuleEngineResource } from "@/lib/permissions";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
@@ -54,6 +56,7 @@ const pathCategory = (pathname: string): RuleEngineNavCategory | undefined => {
|
||||
};
|
||||
|
||||
const RuleEngineResourcePage = () => {
|
||||
const { user } = useAuth();
|
||||
const { resource: resourceSlug } = useParams<{ resource: string }>();
|
||||
const location = useLocation();
|
||||
const category = pathCategory(location.pathname);
|
||||
@@ -75,6 +78,13 @@ const RuleEngineResourcePage = () => {
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
);
|
||||
|
||||
const canView = Boolean(
|
||||
config && canAccessRuleEngineResource(user, config.slug, "view"),
|
||||
);
|
||||
const canManage = Boolean(
|
||||
config && canAccessRuleEngineResource(user, config.slug, "manage"),
|
||||
);
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({
|
||||
search: config?.supportsSearch ? search.trim() || undefined : undefined,
|
||||
@@ -207,6 +217,7 @@ const RuleEngineResourcePage = () => {
|
||||
<RuleEngineRecordActions
|
||||
record={row.original}
|
||||
config={config}
|
||||
readOnly={!canManage}
|
||||
onEdit={(record) => {
|
||||
setEditing(record);
|
||||
setFormOpen(true);
|
||||
@@ -215,15 +226,15 @@ const RuleEngineResourcePage = () => {
|
||||
onViewChain={
|
||||
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
|
||||
}
|
||||
onSubmitRate={(id) => submit.mutate(id)}
|
||||
onApproveRate={handleApproveRate}
|
||||
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
||||
onApproveRate={canManage ? handleApproveRate : undefined}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
return base;
|
||||
}, [config, submit, handleApproveRate]);
|
||||
}, [canManage, config, submit, handleApproveRate]);
|
||||
|
||||
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
|
||||
@@ -235,6 +246,10 @@ const RuleEngineResourcePage = () => {
|
||||
return <Navigate to={defaultPath} replace />;
|
||||
}
|
||||
|
||||
if (!canView) {
|
||||
return <Navigate to="/dashboard/overview" replace />;
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setFormOpen(true);
|
||||
@@ -279,7 +294,7 @@ const RuleEngineResourcePage = () => {
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
searchPlaceholder={config.searchPlaceholder}
|
||||
onAdd={openCreate}
|
||||
onAdd={canManage ? openCreate : undefined}
|
||||
addLabel={`Add ${config.label.replace(/s$/, "")}`}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
@@ -333,13 +348,14 @@ const RuleEngineResourcePage = () => {
|
||||
itemLabel={itemLabel}
|
||||
table={cardTable}
|
||||
pagination={paginationState}
|
||||
onEdit={openEdit}
|
||||
onDelete={setDeleteTarget}
|
||||
readOnly={!canManage}
|
||||
onEdit={canManage ? openEdit : undefined}
|
||||
onDelete={canManage ? setDeleteTarget : undefined}
|
||||
onViewChain={
|
||||
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
|
||||
}
|
||||
onSubmitRate={(id) => submit.mutate(id)}
|
||||
onApproveRate={handleApproveRate}
|
||||
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
||||
onApproveRate={canManage ? handleApproveRate : undefined}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user