booking flow,summtion, approval, contract, mock payemnt and integration to back office, and also add permissions

This commit is contained in:
marshal
2026-06-05 10:38:31 +03:00
parent 810bbc1168
commit d226d7ef22
94 changed files with 3509 additions and 1042 deletions

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>