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,3 +1,4 @@
import type { ReactNode } from "react";
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import {
Boxes,
@@ -12,6 +13,11 @@ import {
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
import LoadingScreen from "./components/LoadingScreen";
import { useAuth } from "./auth/useAuth";
import {
canAccessBookings,
canAccessRuleEngineResource,
hasPermission,
} from "@/lib/permissions";
import LoginPage from "./pages/auth/LoginPage";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
@@ -19,7 +25,6 @@ import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
import OverviewPage from "./pages/dashboard/OverviewPage";
import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
import RolesPage from "./pages/dashboard/user-management/RolesPage";
@@ -29,102 +34,114 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import {
getCategorySidebarChildren,
RULE_ENGINE_RESOURCES,
type RuleEngineNavCategory,
} from "./pages/ruleEngine/config/resources";
import type { RuleEngineResourceSlug } from "./types/rule-engine";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Main menu",
mutedTitle: true,
items: [
{
label: "Overview",
href: "/dashboard/overview",
icon: <LayoutDashboard />,
},
{
label: "Booking requests",
href: "/dashboard/booking-requests",
icon: <FileText />,
},
...demoItems,
],
},
{
title: "Administration",
items: [
{
label: "User management",
href: "/dashboard/user-management",
icon: <Network />,
children: [
{
label: "Users",
href: "/dashboard/user-management/users",
},
{
label: "Employees",
href: "/dashboard/user-management/employees",
},
{
label: "Position Types",
href: "/dashboard/user-management/position-types",
},
{
label: "Permissions",
href: "/dashboard/user-management/permissions",
},
{
label: "Roles",
href: "/dashboard/user-management/roles",
},
],
},
{
label: "File settings",
href: "/dashboard/file-settings",
icon: <Paperclip />,
},
{
label: "Dropdown settings",
href: "/dashboard/dropdown-settings",
icon: <Settings />,
},
],
},
{
title: "Freight configuration",
mutedTitle: true,
items: [
{
label: "Configuration",
href: "/dashboard/configuration",
icon: <Boxes />,
children: getCategorySidebarChildren("configuration"),
},
{
label: "Rules",
href: "/dashboard/rules",
icon: <SlidersHorizontal />,
children: getCategorySidebarChildren("rules"),
},
],
},
];
const hasPermission = (
const filterRuleEngineChildren = (
user: ReturnType<typeof useAuth>["user"],
key: string,
) => {
if (!user) return false;
if (user.permissions?.some((p) => p.key === key)) return true;
category: RuleEngineNavCategory,
): SidebarItem[] =>
getCategorySidebarChildren(category).filter((item) => {
const slug = item.href.split("/").pop() as RuleEngineResourceSlug;
return canAccessRuleEngineResource(user, slug, "view");
});
return (user.employee ?? []).some((emp) =>
(emp.positions ?? []).some((pos) =>
(pos.permissions ?? []).some((p) => p.key === key),
),
);
const buildSidebarSections = (
user: ReturnType<typeof useAuth>["user"],
demoItems: SidebarItem[],
): SidebarSection[] => {
const configurationChildren = filterRuleEngineChildren(user, "configuration");
const rulesChildren = filterRuleEngineChildren(user, "rules");
const mainItems: SidebarItem[] = [
{
label: "Overview",
href: "/dashboard/overview",
icon: <LayoutDashboard />,
},
...(canAccessBookings(user)
? [
{
label: "Booking requests",
href: "/dashboard/booking-requests",
icon: <FileText />,
},
]
: []),
...demoItems,
];
const freightConfigItems: SidebarItem[] = [];
if (configurationChildren.length) {
freightConfigItems.push({
label: "Configuration",
href: "/dashboard/configuration",
icon: <Boxes />,
children: configurationChildren,
});
}
if (rulesChildren.length) {
freightConfigItems.push({
label: "Rules",
href: "/dashboard/rules",
icon: <SlidersHorizontal />,
children: rulesChildren,
});
}
const sections: SidebarSection[] = [
{ title: "Main menu", mutedTitle: true, items: mainItems },
{
title: "Administration",
items: [
{
label: "User management",
href: "/dashboard/user-management",
icon: <Network />,
children: [
{ label: "Users", href: "/dashboard/user-management/users" },
{ label: "Position Types", href: "/dashboard/user-management/position-types" },
{ label: "Permissions", href: "/dashboard/user-management/permissions" },
{ label: "Roles", href: "/dashboard/user-management/roles" },
],
},
{
label: "File settings",
href: "/dashboard/file-settings",
icon: <Paperclip />,
},
{
label: "Dropdown settings",
href: "/dashboard/dropdown-settings",
icon: <Settings />,
},
],
},
];
if (freightConfigItems.length) {
sections.push({
title: "Freight configuration",
mutedTitle: true,
items: freightConfigItems,
});
}
return sections;
};
const PermissionRoute = ({
allow,
children,
}: {
allow: boolean;
children: ReactNode;
}) => (allow ? children : <Navigate to="/dashboard/overview" replace />);
const DashboardShell = () => {
const navigate = useNavigate();
const location = useLocation();
@@ -132,26 +149,14 @@ const DashboardShell = () => {
const demoItems: SidebarItem[] = [
...(hasPermission(user, "can:demo:user1")
? [
{
label: "User1",
href: "/dashboard/user1",
icon: <Settings />,
},
]
? [{ label: "User1", href: "/dashboard/user1", icon: <Settings /> }]
: []),
...(hasPermission(user, "can:demo:user2")
? [
{
label: "User2",
href: "/dashboard/user2",
icon: <Settings />,
},
]
? [{ label: "User2", href: "/dashboard/user2", icon: <Settings /> }]
: []),
];
const sidebarSections = buildSidebarSections(demoItems);
const sidebarSections = buildSidebarSections(user, demoItems);
const displayName = user?.name?.en || user?.username || user?.email || "User";
return (
@@ -169,6 +174,8 @@ const DashboardShell = () => {
);
};
const ruleEngineSlugs = RULE_ENGINE_RESOURCES.map((r) => r.slug);
const App = () => {
const { user, loading } = useAuth();
@@ -185,63 +192,104 @@ const App = () => {
);
}
const canBookings = canAccessBookings(user);
return (
<Routes>
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route
path="booking-requests/:id/contract"
element={<BookingContractPage />}
/>
<Route
path="booking-requests"
element={
<PermissionRoute allow={canBookings}>
<BookingRequestsPage />
</PermissionRoute>
}
/>
<Route
path="booking-requests/:id"
element={
<PermissionRoute allow={canBookings}>
<BookingRequestDetailPage />
</PermissionRoute>
}
/>
<Route
path="booking-requests/:id/contract"
element={
<PermissionRoute allow={canBookings}>
<BookingContractPage />
</PermissionRoute>
}
/>
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />
<Route path="user-management/position-types" element={<PositionTypesPage />} />
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route path="user-management/roles" element={<RolesPage />} />
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />
<Route path="user-management/position-types" element={<PositionTypesPage />} />
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route path="user-management/roles" element={<RolesPage />} />
<Route path="file-settings" element={<FileUploadSettingsPage />} />
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
<Route path="file-settings" element={<FileUploadSettingsPage />} />
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
<Route
path="configuration"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
<Route
path="configuration"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route
path="configuration/:resource"
element={
<PermissionRoute
allow={ruleEngineSlugs.some((slug) =>
canAccessRuleEngineResource(user, slug, "view"),
)}
>
<RuleEngineResourcePage />
</PermissionRoute>
}
/>
<Route
path="rules"
element={<Navigate to="/dashboard/rules/priority-rules" replace />}
/>
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
<Route
path="rules"
element={<Navigate to="/dashboard/rules/priority-rules" replace />}
/>
<Route
path="rules/:resource"
element={
<PermissionRoute
allow={ruleEngineSlugs.some((slug) =>
canAccessRuleEngineResource(user, slug, "view"),
)}
>
<RuleEngineResourcePage />
</PermissionRoute>
}
/>
<Route
path="rule-engine"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
<Route
path="rule-engine"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
<Route path="user1" element={<DemoUser1Page />} />
<Route path="user2" element={<DemoUser2Page />} />
<Route path="user1" element={<DemoUser1Page />} />
<Route path="user2" element={<DemoUser2Page />} />
<Route
path="org-structure"
element={<Navigate to="/dashboard/user-management" replace />}
/>
<Route
path="org-structure/*"
element={<Navigate to="/dashboard/user-management" replace />}
/>
</Route>
<Route
path="org-structure"
element={<Navigate to="/dashboard/user-management" replace />}
/>
<Route
path="org-structure/*"
element={<Navigate to="/dashboard/user-management" replace />}
/>
</Route>
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
</Routes>
);
};

View File

@@ -18,6 +18,6 @@ export const verifyMfaRequest = async (payload: {
};
export const getMeRequest = async () => {
const response = await api.get<AuthUser>("/auth/me");
const response = await api.get<AuthUser>("/me");
return response.data;
};

View File

@@ -39,6 +39,9 @@ export interface AuthUser {
name?: LocaleText;
roles?: AuthRole[];
permissions?: AuthPermission[];
/** Flat keys from GET /api/me (roles + position permissions). */
permissionKeys?: string[];
isSuperAdmin?: boolean;
employee?: AuthEmployeeRecord[];
hasSetPassword?: boolean;
status?: string;

View File

@@ -2,7 +2,9 @@ import { useMemo } from "react";
import { ShieldCheck } from "lucide-react";
import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
import { formatApprovalProgress } from "@/features/bookings/approval-progress";
import { getNextPendingApprovalStep } from "@/features/bookings/booking-actions.config";
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
import { Badge } from "@edr/ui-common";
import { cn } from "@/lib/utils";
@@ -21,31 +23,32 @@ export function ApprovalStepsCard({ booking }: ApprovalStepsCardProps) {
);
const nextPending = getNextPendingApprovalStep(steps);
const summary = formatApprovalProgress(booking.status, steps);
return (
<div className="overflow-hidden rounded-xl border border-amber-200/80 bg-gradient-to-b from-amber-50/40 to-card shadow-sm dark:from-amber-950/20">
<div className="flex items-center gap-3 border-b border-amber-200/50 bg-amber-50/50 px-5 py-4 dark:bg-amber-950/30">
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/15 text-amber-800 dark:text-amber-300">
<ShieldCheck className="size-4" />
<div className={cn(bookingSurface.sectionCard, bookingGlass.activeTab)}>
<div className={bookingSurface.sectionHeader}>
<div className={bookingSurface.sectionIcon}>
<ShieldCheck className="size-4" strokeWidth={1.75} />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">
Approval chain
</h2>
<p className="text-xs text-muted-foreground">
Next:{" "}
{nextPending
? `${nextPending.requiredRole} · step ${nextPending.stepOrder}`
: steps.length
? "All steps complete"
: "Accept submission to begin"}
{summary.detail ||
(nextPending
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
: steps.length
? "All steps complete"
: "Accept submission to begin")}
</p>
</div>
</div>
<div className="px-5 py-5">
{steps.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-muted/20 px-4 py-6 text-center text-sm text-muted-foreground">
<p className="rounded-lg border border-dashed border-border/60 bg-muted/10 px-4 py-6 text-center text-sm text-muted-foreground backdrop-blur-sm">
Use <strong className="font-semibold text-foreground">Accept for approval</strong>{" "}
in staff actions to instantiate steps.
</p>
@@ -74,24 +77,31 @@ function StepRow({
}) {
const statusStyles =
step.status === "APPROVED"
? "bg-emerald-500/15 text-emerald-800 dark:text-emerald-300"
? "border-emerald-500/25 bg-emerald-500/10 text-black"
: step.status === "REJECTED"
? "bg-red-500/15 text-red-800 dark:text-red-300"
? "bg-red-500/10 text-red-800 dark:text-red-300"
: isNext
? "bg-amber-500/15 text-amber-800 dark:text-amber-300"
: "bg-muted text-muted-foreground";
? "border-emerald-500/25 bg-emerald-500/10 text-black"
: "bg-muted/40 text-muted-foreground";
return (
<li
className={cn(
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors",
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors backdrop-blur-sm",
isNext
? "border-primary/30 bg-primary/[0.03] shadow-sm"
: "border-border/60 bg-card",
? bookingGlass.activeTab
: "border-border/50 bg-card/60",
)}
>
<div className="flex min-w-0 items-center gap-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-xs font-bold text-muted-foreground">
<span
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-lg text-xs font-bold",
isNext
? cn(bookingGlass.iconWellGreen, "text-black")
: "bg-muted/40 text-muted-foreground",
)}
>
{step.stepOrder}
</span>
<div className="min-w-0">
@@ -107,7 +117,7 @@ function StepRow({
</div>
<Badge
variant="outline"
className={cn("shrink-0 text-[9px] uppercase", statusStyles)}
className={cn("shrink-0 border text-[9px] uppercase", statusStyles)}
>
{step.status}
</Badge>

View File

@@ -4,12 +4,14 @@ import {
ExternalLink,
Loader2,
MoreHorizontal,
Upload,
} from "lucide-react";
import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { useBookingActionDialog } from "./useBookingActionDialog";
import { useAuth } from "@/auth/useAuth";
import {
getNextPendingApprovalStep,
isContractNavAction,
listRowHasActions,
type BookingActionContext,
} from "@/features/bookings/booking-actions.config";
@@ -30,18 +32,23 @@ interface BookingActionsMenuProps {
/** Compact table cell vs. larger detail toolbar */
variant?: "table" | "toolbar";
className?: string;
/** Suppresses table row navigation after menu/dialog close (click-through). */
onSuppressRowClick?: () => void;
}
export function BookingActionsMenu({
row,
variant = "table",
className,
onSuppressRowClick,
}: BookingActionsMenuProps) {
const navigate = useNavigate();
const { user } = useAuth();
const context: BookingActionContext = {
status: row.status,
paymentCurrency: row.paymentCurrency,
reference: row.reference,
approvalSteps: row.approvalSteps,
};
const flow = useBookingActionDialog(row.id, context);
@@ -50,9 +57,7 @@ export function BookingActionsMenu({
const goToContract = () =>
navigate(`/dashboard/booking-requests/${row.id}/contract`);
const showUsdPaymentHint =
row.status === "FULLY_EXECUTED" && row.paymentCurrency === "USD";
const hasMenu = listRowHasActions(row) || showUsdPaymentHint;
const hasMenu = listRowHasActions(row, user);
const primary = actions.find((a) => a.primary) ?? actions[0];
@@ -73,8 +78,9 @@ export function BookingActionsMenu({
return (
<>
<div
data-stop-row-click
className={cn(
"flex items-center justify-end gap-1",
"flex w-full min-h-[2.5rem] items-center justify-end gap-1",
variant === "table" && "opacity-80 transition-opacity group-hover/tr:opacity-100",
className,
)}
@@ -87,7 +93,7 @@ export function BookingActionsMenu({
className="hidden h-8 gap-1.5 px-2.5 shadow-sm lg:inline-flex"
disabled={mutations.isPending}
onClick={() =>
primary.id === "viewContract"
isContractNavAction(primary.id)
? goToContract()
: flow.openAction(primary)
}
@@ -119,7 +125,7 @@ export function BookingActionsMenu({
)}
disabled={mutations.isPending}
onClick={() =>
action.id === "viewContract"
isContractNavAction(action.id)
? goToContract()
: flow.openAction(action)
}
@@ -164,36 +170,29 @@ export function BookingActionsMenu({
"gap-2 cursor-pointer",
action.variant === "destructive" && "text-red-700 focus:text-red-700",
)}
onClick={() =>
action.id === "viewContract"
? goToContract()
: flow.openAction(action)
}
onSelect={(event) => {
event.preventDefault();
onSuppressRowClick?.();
if (isContractNavAction(action.id)) {
goToContract();
} else {
flow.openAction(action);
}
}}
>
<Icon className="size-4 opacity-70" />
<span>{action.label}</span>
</DropdownMenuItem>
);
})}
{showUsdPaymentHint && (
<DropdownMenuItem
className="gap-2 cursor-pointer"
onClick={() =>
navigate(`/dashboard/booking-requests/${row.id}`)
}
>
<Upload className="size-4 opacity-70" />
Upload payment proof
</DropdownMenuItem>
)}
{(actions.length > 0 || showUsdPaymentHint) && (
<DropdownMenuSeparator />
)}
{actions.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
className="gap-2 cursor-pointer"
onClick={() =>
navigate(`/dashboard/booking-requests/${row.id}`)
}
onSelect={(event) => {
event.preventDefault();
onSuppressRowClick?.();
navigate(`/dashboard/booking-requests/${row.id}`);
}}
>
<ExternalLink className="size-4 opacity-70" />
Open full details
@@ -205,12 +204,20 @@ export function BookingActionsMenu({
<BookingConfirmDialog
open={flow.dialogOpen}
onOpenChange={flow.setDialogOpen}
onOpenChange={(open) => {
if (!open) onSuppressRowClick?.();
flow.setDialogOpen(open);
}}
action={pendingAction}
reference={flow.mergedContext.reference}
inputValue={flow.inputValue}
onInputChange={flow.setInputValue}
onConfirm={flow.runAction}
selectedFile={flow.selectedFile}
onFileChange={flow.setSelectedFile}
onConfirm={() => {
onSuppressRowClick?.();
flow.runAction();
}}
isPending={mutations.isPending || flow.detailLoading}
confirmDisabled={flow.confirmDisabled}
extra={
@@ -220,10 +227,10 @@ export function BookingActionsMenu({
Loading approval steps
</p>
) : pendingAction?.id === "approve" &&
!flow.mergedContext.approvalSteps?.length ? (
!getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? (
<p className="rounded-lg border border-amber-200/80 bg-amber-50/50 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
No pending approval step found. Accept the submission on the detail
page first.
No pending approval step. Refresh the page after staff accept, or
reject the booking.
</p>
) : null
}

View File

@@ -1,5 +1,4 @@
import { useRef } from "react";
import { Download, Upload, Zap } from "lucide-react";
import { Download, Zap } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
import { BookingActionsMenu } from "./BookingActionsMenu";
@@ -7,6 +6,7 @@ import { bookingSurface } from "./booking-ui.styles";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
import { Button } from "@edr/ui-common";
import { cn } from "@/lib/utils";
type Mutations = ReturnType<typeof useBookingMutations>;
@@ -15,15 +15,13 @@ interface BookingActionsToolbarProps {
mutations: Mutations;
}
/** Detail-page actions: primary toolbar + payment uploads + downloads. */
/** Detail-page actions: primary toolbar + downloads. */
export function BookingActionsToolbar({
booking,
mutations,
}: BookingActionsToolbarProps) {
const fileRef = useRef<HTMLInputElement>(null);
const row = toBookingListRow(booking);
const { status, paymentCurrency } = booking;
const pending = mutations.isPending;
const { status } = booking;
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
const blob = await fn();
@@ -47,7 +45,7 @@ export function BookingActionsToolbar({
return (
<PanelShell title="Awaiting customer" description="No staff actions until resubmit.">
{booking.latestChangeRequestNote && (
<p className="rounded-lg border bg-muted/30 p-3 text-sm leading-relaxed">
<p className="rounded-lg border border-border/50 bg-muted/15 p-3 text-sm leading-relaxed backdrop-blur-sm">
{booking.latestChangeRequestNote}
</p>
)}
@@ -74,50 +72,11 @@ export function BookingActionsToolbar({
<BookingActionsMenu row={row} variant="toolbar" />
</PanelShell>
{status === "FULLY_EXECUTED" && paymentCurrency === "USD" && (
<PanelShell title="Payment (USD)" description="Upload proof of payment.">
<input
ref={fileRef}
type="file"
className="hidden"
accept=".pdf,.png,.jpg,.jpeg"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) mutations.submitPaymentProof.mutate(file);
}}
/>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
disabled={pending}
className="gap-2"
onClick={() => fileRef.current?.click()}
>
<Upload className="size-4" />
Upload payment proof
</Button>
<Button
variant="outline"
className="gap-2"
onClick={() =>
downloadBlob(
() => mutations.downloadPaymentLetter(),
`payment-letter-${booking.reference}.txt`,
)
}
>
<Download className="size-4" />
Request letter
</Button>
</div>
</PanelShell>
)}
{status === "CONTRACT_READY" && (
<PanelShell title="Documents" description="Download generated contract.">
<Button
variant="outline"
className="gap-2"
className="gap-2 border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
onClick={() =>
downloadBlob(
() => mutations.downloadContract(),
@@ -146,16 +105,10 @@ function PanelShell({
muted?: boolean;
}) {
return (
<div
className={
muted
? bookingSurface.sectionCard
: `${bookingSurface.sectionCard} ring-1 ring-primary/10`
}
>
<div className={cn(bookingSurface.sectionCard, !muted && "ring-0")}>
<div className={bookingSurface.sectionHeader}>
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Zap className="size-4" />
<div className={bookingSurface.sectionIcon}>
<Zap className="size-4" strokeWidth={1.75} />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">{title}</h2>

View File

@@ -0,0 +1,29 @@
import { formatApprovalProgress } from "@/features/bookings/approval-progress";
import type { BookingListRow } from "@/types/booking";
import { cn } from "@/lib/utils";
interface BookingApprovalProgressCellProps {
row: BookingListRow;
}
export function BookingApprovalProgressCell({ row }: BookingApprovalProgressCellProps) {
const summary = formatApprovalProgress(row.status, row.approvalSteps);
return (
<div className="min-w-[8.5rem] py-1">
<p
className={cn(
"text-sm font-semibold",
summary.complete ? "text-emerald-700 dark:text-emerald-400" : "text-foreground",
)}
>
{summary.label}
</p>
{summary.detail ? (
<p className="mt-0.5 line-clamp-2 text-[11px] leading-snug text-muted-foreground">
{summary.detail}
</p>
) : null}
</div>
);
}

View File

@@ -20,6 +20,8 @@ interface BookingConfirmDialogProps {
reference?: string;
inputValue: string;
onInputChange: (value: string) => void;
selectedFile?: File | null;
onFileChange?: (file: File | null) => void;
onConfirm: () => void;
isPending: boolean;
confirmDisabled?: boolean;
@@ -33,6 +35,8 @@ export function BookingConfirmDialog({
reference,
inputValue,
onInputChange,
selectedFile = null,
onFileChange,
onConfirm,
isPending,
confirmDisabled = false,
@@ -41,13 +45,25 @@ export function BookingConfirmDialog({
if (!action || !action.confirmTitle) return null;
const Icon = action.icon;
const needsInput = Boolean(action.input);
const inputMissing = needsInput && !inputValue.trim();
const needsTextInput =
action.input === "note" || action.input === "reason";
const needsFileInput = action.input === "file";
const inputMissing =
(needsTextInput && !inputValue.trim()) ||
(needsFileInput && !selectedFile);
const isDestructive = action.variant === "destructive";
const preventClickThrough = (event: React.MouseEvent) => {
event.preventDefault();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-md">
<DialogContent
className="gap-0 overflow-hidden p-0 sm:max-w-md"
showCloseButton={false}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<div
className={cn(
"border-b px-6 py-5",
@@ -86,7 +102,7 @@ export function BookingConfirmDialog({
</div>
<div className="space-y-4 px-6 py-5">
{needsInput && (
{needsTextInput && (
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{action.inputLabel}
@@ -101,6 +117,27 @@ export function BookingConfirmDialog({
/>
</div>
)}
{needsFileInput && (
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{action.inputLabel ?? "Bank slip file"}
<span className="text-red-600"> *</span>
</label>
<input
type="file"
accept=".pdf,.png,.jpg,.jpeg"
className="block w-full text-sm text-muted-foreground file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-2 file:text-xs file:font-semibold file:text-primary-foreground"
onChange={(e) =>
onFileChange?.(e.target.files?.[0] ?? null)
}
/>
{selectedFile && (
<p className="text-xs text-muted-foreground">
Selected: {selectedFile.name}
</p>
)}
</div>
)}
{extra}
</div>
@@ -109,6 +146,7 @@ export function BookingConfirmDialog({
type="button"
variant="outline"
disabled={isPending}
onMouseDown={preventClickThrough}
onClick={() => onOpenChange(false)}
>
Cancel
@@ -118,6 +156,7 @@ export function BookingConfirmDialog({
variant={isDestructive ? "destructive" : "default"}
disabled={isPending || inputMissing || confirmDisabled}
className="min-w-[7rem] gap-2"
onMouseDown={preventClickThrough}
onClick={onConfirm}
>
{isPending ? (

View File

@@ -2,7 +2,7 @@ import { Banknote, Receipt } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
import { Separator } from "@edr/ui-common";
import { bookingSurface } from "./booking-ui.styles";
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
const amount = Number(booking.totalAmount);
@@ -11,8 +11,8 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
return (
<div className={bookingSurface.sectionCard}>
<div className={bookingSurface.sectionHeader}>
<div className="flex size-9 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-700 dark:text-emerald-400">
<Banknote className="size-4" />
<div className={bookingSurface.sectionIcon}>
<Banknote className="size-4" strokeWidth={1.75} />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">
@@ -22,11 +22,11 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
</div>
</div>
<div className="space-y-4 px-5 py-5">
<div className="rounded-xl border border-primary/15 bg-gradient-to-br from-primary/[0.06] to-transparent p-4">
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
<div className={bookingSurface.valueCard}>
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Total amount
</p>
<p className="mt-1 font-mono text-2xl font-bold tracking-tight text-foreground">
<p className="mt-1 font-mono text-2xl font-semibold tabular-nums tracking-tight text-foreground">
{booking.paymentCurrency}{" "}
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</p>
@@ -35,8 +35,8 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
{modifiers.length > 0 && (
<>
<Separator />
<p className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
<Separator className="opacity-50" />
<p className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<Receipt className="size-3" />
Surcharges applied
</p>
@@ -44,7 +44,7 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
{modifiers.map((m) => (
<li
key={m.id}
className="flex justify-between rounded-lg border border-border/60 bg-muted/20 px-3 py-2 text-sm"
className="flex justify-between rounded-lg border border-border/50 bg-muted/15 px-3 py-2 text-sm backdrop-blur-sm"
>
<span className="text-muted-foreground">Modifier</span>
<span className="font-mono font-semibold tabular-nums">
@@ -70,7 +70,7 @@ function Row({
mono?: boolean;
}) {
return (
<div className="flex items-center justify-between gap-2 text-sm">
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/40 bg-muted/10 px-3 py-2.5 text-sm backdrop-blur-sm">
<span className="text-muted-foreground">{label}</span>
<span
className={

View File

@@ -1,4 +1,5 @@
import type { LucideIcon } from "lucide-react";
import { bookingGlass } from "./booking-ui.styles";
import { cn } from "@/lib/utils";
export interface StatItem {
@@ -9,43 +10,49 @@ export interface StatItem {
accent?: "default" | "amber" | "emerald" | "rose";
}
const accentStyles = {
default: "bg-primary/10 text-primary",
amber: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
emerald: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
rose: "bg-rose-500/10 text-rose-700 dark:text-rose-400",
const iconAccentStyles = {
default: "text-foreground/70",
amber: "text-amber-600 dark:text-amber-400",
emerald: "text-emerald-600 dark:text-emerald-400",
rose: "text-rose-600 dark:text-rose-400",
};
export function BookingStatGrid({ items }: { items: StatItem[] }) {
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{items.map((item) => {
const Icon = item.icon;
const accent = item.accent ?? "default";
return (
<div
key={item.label}
className="group relative overflow-hidden rounded-xl border border-border bg-card p-5 shadow-sm transition-all duration-200 hover:border-primary/20 hover:shadow-md"
className={cn(
"group relative overflow-hidden rounded-xl p-5 transition-all duration-200 hover:shadow-md",
bookingGlass.card,
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{item.label}
</p>
<p className="mt-2 text-3xl font-bold tabular-nums tracking-tight text-foreground">
<p className="mt-2 text-3xl font-semibold tabular-nums tracking-tight text-foreground">
{item.value}
</p>
{item.hint && (
<p className="mt-1 text-xs text-muted-foreground">{item.hint}</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
{item.hint}
</p>
)}
</div>
<div
className={cn(
"flex size-11 shrink-0 items-center justify-center rounded-xl transition-transform duration-200 group-hover:scale-105",
accentStyles[accent],
"flex size-10 shrink-0 items-center justify-center rounded-xl transition-transform duration-200 group-hover:scale-[1.02]",
bookingGlass.iconWellGreen,
iconAccentStyles[accent],
)}
>
<Icon className="size-5" strokeWidth={2} />
<Icon className="size-[18px]" strokeWidth={1.75} />
</div>
</div>
</div>

View File

@@ -1,27 +1,34 @@
import {
CheckCircle,
ClipboardCheck,
FileSignature,
FileText,
Inbox,
LayoutGrid,
ShieldCheck,
Train,
Wallet,
XCircle,
} from "lucide-react";
import {
BOOKING_LIST_TABS,
type BookingStatusTabKey,
} from "@/features/bookings/booking-status.config";
import { bookingGlass } from "./booking-ui.styles";
import { cn } from "@/lib/utils";
const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
all: <LayoutGrid className="size-4" />,
SUBMITTED: <Inbox className="size-4" />,
PENDING_APPROVAL: <ClipboardCheck className="size-4" />,
APPROVED_PENDING_SIGNATURE: <FileSignature className="size-4" />,
SIGNED_CUSTOMER: <FileText className="size-4" />,
PAYMENT_VERIFICATION_IN_PROGRESS: <ShieldCheck className="size-4" />,
all: <LayoutGrid className="size-3.5" strokeWidth={1.75} />,
intake: <Inbox className="size-3.5" strokeWidth={1.75} />,
in_approval: <ClipboardCheck className="size-3.5" strokeWidth={1.75} />,
approved_contract: <FileSignature className="size-3.5" strokeWidth={1.75} />,
payment: <Wallet className="size-3.5" strokeWidth={1.75} />,
operations: <Train className="size-3.5" strokeWidth={1.75} />,
completed: <CheckCircle className="size-3.5" strokeWidth={1.75} />,
closed: <XCircle className="size-3.5" strokeWidth={1.75} />,
};
const activeTabText = "text-black";
interface BookingStatusTabsProps {
active: BookingStatusTabKey;
onChange: (tab: BookingStatusTabKey) => void;
@@ -34,9 +41,9 @@ export function BookingStatusTabs({
counts,
}: BookingStatusTabsProps) {
return (
<div className="rounded-xl border border-border bg-muted/30 p-1.5">
<div className={bookingGlass.tabRail}>
<div
className="flex gap-1 overflow-x-auto pb-0.5 scrollbar-thin"
className="flex flex-nowrap gap-1.5 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
role="tablist"
aria-label="Booking status filters"
>
@@ -51,29 +58,38 @@ export function BookingStatusTabs({
aria-selected={isActive}
onClick={() => onChange(tab.key)}
className={cn(
"flex min-w-[7.5rem] shrink-0 flex-col items-start gap-0.5 rounded-lg px-3.5 py-2.5 text-left transition-all duration-200",
"flex min-w-[8rem] shrink-0 flex-col items-start gap-0.5 rounded-lg px-3 py-2.5 text-left transition-all duration-200",
isActive
? "bg-background text-foreground shadow-sm ring-1 ring-border/80"
: "text-muted-foreground hover:bg-background/60 hover:text-foreground",
? bookingGlass.activeTab
: "text-muted-foreground hover:bg-emerald-500/5 hover:text-foreground",
)}
>
<span className="flex w-full items-center justify-between gap-2">
<span
className={cn(
"flex items-center gap-2 text-sm font-semibold",
isActive && "text-primary",
"flex items-center gap-2 text-sm font-medium",
isActive ? activeTabText : "text-muted-foreground",
)}
>
{TAB_ICONS[tab.key]}
{tab.label}
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md",
isActive
? cn(bookingGlass.iconWellGreen, "text-black")
: "border border-transparent bg-muted/30",
)}
>
{TAB_ICONS[tab.key]}
</span>
<span className="whitespace-nowrap">{tab.label}</span>
</span>
{count !== undefined && count > 0 && (
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-bold tabular-nums",
"rounded-full px-2 py-0.5 text-[10px] font-semibold tabular-nums",
isActive
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground",
? cn("bg-emerald-500/15", activeTabText)
: "bg-muted/50 text-muted-foreground",
)}
>
{count}

View File

@@ -1,6 +1,7 @@
import { Package, Search } from "lucide-react";
import { Button } from "@edr/ui-common";
import { bookingSurface } from "./booking-ui.styles";
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
import { cn } from "@/lib/utils";
interface BookingTableEmptyProps {
isError?: boolean;
@@ -15,7 +16,12 @@ export function BookingTableEmpty({
}: BookingTableEmptyProps) {
return (
<div className={bookingSurface.emptyState}>
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<div
className={cn(
"flex size-14 items-center justify-center rounded-2xl text-muted-foreground",
bookingGlass.iconWellGreen,
)}
>
{hasSearch ? <Search className="size-6" /> : <Package className="size-6" />}
</div>
<div className="max-w-sm space-y-1">

View File

@@ -12,7 +12,7 @@ import {
getWorkflowStageIndex,
WORKFLOW_STAGES,
} from "@/features/bookings/booking-status.config";
import { bookingSurface } from "./booking-ui.styles";
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
const STAGE_ICONS = [FileText, FileSignature, FileSignature, Wallet, Train, Check];
@@ -35,8 +35,8 @@ export function BookingWorkflowStepper({
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">
<Train className="size-4" />
<div className={bookingSurface.sectionIcon}>
<Train className="size-4" strokeWidth={1.75} />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">
@@ -49,9 +49,9 @@ export function BookingWorkflowStepper({
</div>
<div className="space-y-8 px-5 py-6">
<div className="relative px-2">
<div className="absolute left-4 right-4 top-5 h-0.5 bg-border" />
<div className="absolute left-4 right-4 top-5 h-px bg-border/60" />
<div
className="absolute left-4 top-5 h-0.5 bg-primary transition-all duration-700 ease-out"
className="absolute left-4 top-5 h-px bg-emerald-500/40 transition-all duration-700 ease-out"
style={{
width:
!isTerminal && currentStage >= 0
@@ -71,14 +71,17 @@ export function BookingWorkflowStepper({
>
<div
className={cn(
"flex size-10 items-center justify-center rounded-full border-2 bg-card transition-all duration-300",
"flex size-10 items-center justify-center rounded-full border-2 bg-card/80 backdrop-blur-sm transition-all duration-300",
isCompleted &&
"border-primary bg-primary text-primary-foreground shadow-sm",
cn(bookingGlass.iconWellGreen, "border-emerald-500/30 text-black"),
isActive &&
"scale-110 border-primary bg-background text-primary shadow-md ring-4 ring-primary/15",
cn(
bookingGlass.activeTab,
"scale-105 border-emerald-500/30 text-black shadow-sm",
),
!isCompleted &&
!isActive &&
"border-border text-muted-foreground",
"border-border/60 text-muted-foreground",
)}
>
{isCompleted ? (
@@ -89,8 +92,8 @@ export function BookingWorkflowStepper({
</div>
<span
className={cn(
"text-center text-[10px] font-bold uppercase leading-tight tracking-wide",
isActive ? "text-primary" : "text-muted-foreground",
"text-center text-[10px] font-semibold uppercase leading-tight tracking-wide",
isActive ? "text-black" : "text-muted-foreground",
)}
>
{stage.label}
@@ -103,14 +106,17 @@ export function BookingWorkflowStepper({
<div
className={cn(
"rounded-xl border px-5 py-4",
"rounded-xl border px-5 py-4 backdrop-blur-sm",
isTerminal
? "border-destructive/20 bg-destructive/5"
: "border-primary/15 bg-primary/[0.04]",
: bookingGlass.activeTab,
)}
>
<h4
className={cn("text-sm font-bold tracking-tight", titleColor)}
className={cn(
"text-sm font-semibold tracking-tight",
isTerminal ? titleColor : "text-black",
)}
>
{title}
</h4>

View File

@@ -0,0 +1,32 @@
import { ArrowRight } from "lucide-react";
import type { BookingNextStep } from "@/types/booking";
import { bookingGlass } from "./booking-ui.styles";
import { cn } from "@/lib/utils";
interface NextStepBannerProps {
nextStep: BookingNextStep;
className?: string;
}
export function NextStepBanner({ nextStep, className }: NextStepBannerProps) {
return (
<div
className={cn(
"flex items-start gap-3 rounded-xl px-4 py-3 text-sm",
bookingGlass.activeTab,
className,
)}
role="status"
>
<ArrowRight className="mt-0.5 size-4 shrink-0 text-black" aria-hidden />
<div className="min-w-0 space-y-0.5">
<p className="font-semibold text-black">
Next: {nextStep.action.replace(/_/g, " ")}
{nextStep.requiredRole ? ` (${nextStep.requiredRole})` : ""}
</p>
<p className="text-muted-foreground">{nextStep.description}</p>
</div>
</div>
);
}

View File

@@ -1,32 +1,63 @@
/** Shared surfaces for booking list & detail — aligned with rule-engine polish. */
/** Shared surfaces for booking list & detail — frosted glass, neutral accents. */
export const bookingGlass = {
card:
"border border-border/50 bg-card/75 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/60",
panel:
"border border-border/50 bg-card/80 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/65",
rail:
"border border-border/40 bg-muted/20 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/15",
iconWell:
"border border-border/50 bg-background/70 text-foreground/75 shadow-sm backdrop-blur-sm supports-[backdrop-filter]:bg-background/50",
iconWellHero:
"border border-border/50 bg-background/60 text-foreground shadow-sm ring-1 ring-border/30 backdrop-blur-md supports-[backdrop-filter]:bg-background/45",
activeTab:
"border border-emerald-500/20 bg-emerald-500/10 shadow-sm backdrop-blur-md ring-1 ring-emerald-500/10 supports-[backdrop-filter]:bg-emerald-500/[0.08]",
iconWellGreen:
"border border-emerald-500/20 bg-emerald-500/15 text-emerald-700 shadow-sm backdrop-blur-sm supports-[backdrop-filter]:bg-emerald-500/10 dark:text-emerald-400",
tabRail:
"rounded-xl border border-border/60 bg-muted/10 p-2 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/5",
tableHeader:
"bg-muted/30 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/20",
} as const;
export const bookingSurface = {
page: "min-h-screen bg-gradient-to-b from-muted/40 via-background to-background",
pageInner: "mx-auto max-w-[1600px] space-y-6 p-6 lg:p-8",
hero:
"relative overflow-hidden rounded-2xl border border-border/80 bg-card shadow-sm",
page:
"min-h-screen bg-gradient-to-b from-muted/30 via-background to-background",
pageInner: "mx-auto max-w-[1600px] space-y-5 p-6 lg:p-8",
hero: `relative overflow-hidden rounded-2xl ${bookingGlass.card}`,
heroGlow:
"pointer-events-none absolute -right-20 -top-20 size-64 rounded-full bg-primary/10 blur-3xl",
panel:
"overflow-hidden rounded-xl border border-border bg-card shadow-sm",
panelToolbar:
"flex flex-wrap items-center justify-between gap-3 border-b border-border bg-muted/25 px-4 py-3.5 sm:px-5",
"pointer-events-none absolute -right-24 -top-24 size-72 rounded-full bg-muted/40 blur-3xl",
heroSheen:
"pointer-events-none absolute inset-0 bg-gradient-to-br from-background/40 via-transparent to-muted/20",
panel: `overflow-hidden rounded-xl ${bookingGlass.panel}`,
panelToolbar: `flex flex-wrap items-center justify-between gap-3 border-b border-border/50 px-4 py-3.5 sm:px-5 ${bookingGlass.rail}`,
tableWrap: "px-0",
sectionCard:
"overflow-hidden rounded-xl border border-border bg-card shadow-sm transition-shadow hover:shadow-md",
sectionCard: `overflow-hidden rounded-xl transition-shadow duration-200 hover:shadow-md ${bookingGlass.card}`,
sectionHeader:
"flex items-center gap-3 border-b border-border/60 bg-muted/20 px-5 py-4",
"flex items-center gap-3 border-b border-border/50 bg-muted/15 px-5 py-4 backdrop-blur-sm",
sectionBody: "px-5 py-5",
detailHero:
"relative overflow-hidden rounded-2xl border border-border bg-gradient-to-br from-card via-card to-primary/[0.04] shadow-sm",
detailHero: `relative overflow-hidden rounded-2xl ${bookingGlass.card}`,
sectionIcon: `flex size-9 shrink-0 items-center justify-center rounded-lg ${bookingGlass.iconWellGreen}`,
sectionIconLg: `flex size-11 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`,
valueCard:
"rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-emerald-500/[0.08]",
stickySidebar: "lg:sticky lg:top-6 lg:self-start",
metricTile:
"rounded-lg border border-border/70 bg-background/80 px-4 py-3 shadow-xs",
"rounded-lg border border-border/50 bg-background/70 px-4 py-3 shadow-xs backdrop-blur-sm",
emptyState:
"flex flex-col items-center justify-center gap-3 px-6 py-16 text-center",
} as const;
export const bookingInput = {
search:
"h-10 w-full rounded-lg border border-input bg-background pl-10 text-sm shadow-xs transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25 sm:max-w-xs",
"h-10 w-full rounded-lg border border-border/60 bg-background/80 pl-10 text-sm shadow-xs backdrop-blur-sm transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-ring/60 focus-visible:ring-[3px] focus-visible:ring-ring/20 sm:max-w-xs",
} as const;
export const bookingTable = {
headerCell:
"h-11 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground",
rowHover:
"transition-colors hover:bg-muted/25 data-[state=selected]:bg-muted/30",
rowIcon: `flex size-10 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`,
} as const;

View File

@@ -6,6 +6,7 @@ import {
type BookingActionContext,
type BookingActionDef,
} from "@/features/bookings/booking-actions.config";
import { useAuth } from "@/auth/useAuth";
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
export function useBookingActionDialog(
@@ -14,13 +15,18 @@ export function useBookingActionDialog(
) {
const [pendingAction, setPendingAction] = useState<BookingActionDef | null>(null);
const [inputValue, setInputValue] = useState("");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const needsApprovalSteps =
pendingAction?.id === "approve" || pendingAction?.id === "rejectApproval";
const needsApprovalContext =
context.status === "PENDING_APPROVAL" ||
context.status === "APPROVED_PENDING_SIGNATURE";
const { data: detail, isLoading: detailLoading } = useBookingDetail(
needsApprovalSteps ? bookingId : undefined,
needsApprovalSteps || needsApprovalContext ? bookingId : undefined,
);
const mergedContext: BookingActionContext = {
@@ -29,12 +35,14 @@ export function useBookingActionDialog(
reference: detail?.reference ?? context.reference,
};
const { user } = useAuth();
const mutations = useBookingMutations(bookingId);
const actions = getBookingActions(mergedContext);
const actions = getBookingActions(mergedContext, user);
const openAction = useCallback((action: BookingActionDef) => {
setPendingAction(action);
setInputValue("");
setSelectedFile(null);
setDialogOpen(true);
}, []);
@@ -42,6 +50,7 @@ export function useBookingActionDialog(
setDialogOpen(false);
setPendingAction(null);
setInputValue("");
setSelectedFile(null);
}, []);
const runAction = useCallback(() => {
@@ -82,11 +91,8 @@ export function useBookingActionDialog(
break;
case "viewContract":
break;
case "generatePnr":
mutations.generatePnr.mutate(undefined, { onSuccess });
break;
case "verifyPayment":
mutations.verifyPayment.mutate(undefined, { onSuccess });
case "payBooking":
mutations.payBooking.mutate(undefined, { onSuccess });
break;
case "startTransit":
mutations.startTransit.mutate(undefined, { onSuccess });
@@ -94,12 +100,16 @@ export function useBookingActionDialog(
case "complete":
mutations.complete.mutate(undefined, { onSuccess });
break;
case "cancel":
mutations.cancel.mutate(inputValue.trim(), { onSuccess });
break;
default:
break;
}
}, [
pendingAction,
inputValue,
selectedFile,
mergedContext.approvalSteps,
mutations,
closeDialog,
@@ -109,13 +119,18 @@ export function useBookingActionDialog(
mutations.isPending ||
(needsApprovalSteps && detailLoading) ||
(pendingAction?.id === "approve" &&
!getNextPendingApprovalStep(mergedContext.approvalSteps));
!getNextPendingApprovalStep(mergedContext.approvalSteps)) ||
(pendingAction?.input === "file" && !selectedFile) ||
(pendingAction?.input === "reason" && !inputValue.trim()) ||
(pendingAction?.input === "note" && !inputValue.trim());
return {
actions,
pendingAction,
inputValue,
setInputValue,
selectedFile,
setSelectedFile,
dialogOpen,
setDialogOpen: (open: boolean) => {
if (!open) closeDialog();

View File

@@ -21,8 +21,9 @@ export interface RuleEngineCardGridProps {
pageCount: number;
totalCount: number;
};
onEdit: (record: RuleEngineRecord) => void;
onDelete: (record: RuleEngineRecord) => void;
onEdit?: (record: RuleEngineRecord) => void;
onDelete?: (record: RuleEngineRecord) => void;
readOnly?: boolean;
onViewChain?: () => void;
onSubmitRate?: (id: string) => void;
onApproveRate?: (record: RuleEngineRecord) => void;
@@ -41,6 +42,7 @@ const RuleEngineCardGrid = ({
onViewChain,
onSubmitRate,
onApproveRate,
readOnly = false,
}: RuleEngineCardGridProps) => {
const presentation = resolveCardPresentation(config);
@@ -153,8 +155,9 @@ const RuleEngineCardGrid = ({
record={record}
config={config}
layout="compact"
onEdit={onEdit}
onDelete={onDelete}
readOnly={readOnly}
onEdit={onEdit ?? (() => {})}
onDelete={onDelete ?? (() => {})}
onViewChain={onViewChain}
onSubmitRate={onSubmitRate}
onApproveRate={onApproveRate}

View File

@@ -26,6 +26,7 @@ export interface RuleEngineRecordActionsProps {
onSubmitRate?: (id: string) => void;
onApproveRate?: (record: RuleEngineRecord) => void;
layout?: "row" | "compact";
readOnly?: boolean;
}
const RuleEngineRecordActions = ({
@@ -37,6 +38,7 @@ const RuleEngineRecordActions = ({
onSubmitRate,
onApproveRate,
layout = "row",
readOnly = false,
}: RuleEngineRecordActionsProps) => {
const status = String(record.status ?? "");
const hasRateActions =
@@ -47,6 +49,21 @@ const RuleEngineRecordActions = ({
? "h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
: "h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground";
if (readOnly) {
return onViewChain ? (
<Button
type="button"
variant="ghost"
size="icon"
className={iconBtnClass}
onClick={onViewChain}
aria-label="View chain"
>
<Eye className="h-4 w-4" />
</Button>
) : null;
}
return (
<div className="flex items-center justify-end gap-0.5">
<Button

View File

@@ -10,7 +10,7 @@ export interface RuleEngineToolbarProps {
search: string;
onSearchChange: (value: string) => void;
searchPlaceholder: string;
onAdd: () => void;
onAdd?: () => void;
addLabel?: string;
viewMode: RuleEngineViewMode;
onViewModeChange: (mode: RuleEngineViewMode) => void;
@@ -81,10 +81,12 @@ const RuleEngineToolbar = ({
<Filter className="h-4 w-4" />
Filter
</Button>
<Button type="button" className={ruleEngineToolbar.primaryBtn} onClick={onAdd}>
<Plus className="h-4 w-4" />
{addLabel}
</Button>
{onAdd ? (
<Button type="button" className={ruleEngineToolbar.primaryBtn} onClick={onAdd}>
<Plus className="h-4 w-4" />
{addLabel}
</Button>
) : null}
</div>
</div>
);

View File

@@ -32,6 +32,8 @@ export const QUERY_KEYS = {
ROOT: ["bookings"] as const,
list: (filter?: BookingListFilter) =>
["bookings", "list", filter ?? {}] as const,
listSummary: (filter?: BookingListFilter) =>
["bookings", "list-summary", filter ?? {}] as const,
byId: (id: string) => ["bookings", "detail", id] as const,
},

View File

@@ -77,6 +77,7 @@ export const URL_CONSTANTS = {
BOOKINGS: {
BASE: "/bookings",
LIST_SUMMARY: "/bookings/list-summary",
BY_ID: (id: string) => `/bookings/${id}`,
QUEUE: (queue: string) => `/bookings/queues/${queue}`,
STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`,
@@ -95,11 +96,7 @@ export const URL_CONSTANTS = {
SUMMARY: (id: string) => `/bookings/${id}/summary`,
CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`,
MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`,
PAYMENT_PNR: (id: string) => `/bookings/${id}/payment/pnr`,
PAYMENT_PROOF: (id: string) => `/bookings/${id}/payment/proof`,
PAYMENT_VERIFY: (id: string) => `/bookings/${id}/payment/verify`,
PAYMENT_REQUEST_LETTER: (id: string) =>
`/bookings/${id}/payment/request-letter`,
PAYMENT_PAY: (id: string) => `/bookings/${id}/payment/pay`,
START_TRANSIT: (id: string) => `/bookings/${id}/operations/start-transit`,
COMPLETE: (id: string) => `/bookings/${id}/operations/complete`,
CANCEL: (id: string) => `/bookings/${id}/cancel`,

View File

@@ -0,0 +1,79 @@
import type { BookingApprovalStep, BookingStatus } from "@/types/booking";
import { getNextPendingApprovalStep } from "@/features/bookings/booking-actions.config";
export interface ApprovalProgressSummary {
label: string;
detail: string;
complete: boolean;
}
/** Compact approval chain summary for list rows and badges. */
export function formatApprovalProgress(
status: BookingStatus | string,
steps?: BookingApprovalStep[] | null,
): ApprovalProgressSummary {
const sorted = [...(steps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder);
if (sorted.length === 0) {
if (status === "SUBMITTED") {
return {
label: "Awaiting accept",
detail: "Staff must accept intake",
complete: false,
};
}
if (
status === "PENDING_APPROVAL" ||
status === "APPROVED_PENDING_SIGNATURE"
) {
return {
label: "No steps",
detail: "Approval chain not started",
complete: false,
};
}
if (
[
"APPROVED",
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
"PAID",
"COMPLETED",
].includes(status)
) {
return {
label: "Approved",
detail: "Internal approval complete",
complete: true,
};
}
return { label: "—", detail: "", complete: false };
}
const approved = sorted.filter((s) => s.status === "APPROVED").length;
const total = sorted.length;
const next = getNextPendingApprovalStep(sorted);
if (!next && approved === total) {
return {
label: `${approved}/${total} done`,
detail: sorted.map((s) => `${s.requiredRole}`).join(" · "),
complete: true,
};
}
if (next) {
return {
label: `${approved}/${total}`,
detail: `Next: ${next.requiredRole} (step ${next.stepOrder})`,
complete: false,
};
}
return {
label: `${approved}/${total}`,
detail: sorted.map((s) => `${s.requiredRole}: ${s.status}`).join(" · "),
complete: approved === total,
};
}

View File

@@ -12,6 +12,8 @@ import {
XCircle,
} from "lucide-react";
import type { AuthUser } from "@/auth/types";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import type {
BookingApprovalStep,
BookingDetail,
@@ -26,12 +28,13 @@ export type BookingActionId =
| "rejectApproval"
| "generateContract"
| "viewContract"
| "generatePnr"
| "verifyPayment"
| "signContractStaff"
| "payBooking"
| "startTransit"
| "complete";
| "complete"
| "cancel";
export type BookingActionInputKind = "note" | "reason";
export type BookingActionInputKind = "note" | "reason" | "file";
export interface BookingActionDef {
id: BookingActionId;
@@ -140,18 +143,118 @@ const SUBMITTED_ACTIONS: BookingActionDef[] = [
},
];
const CANCEL_ACTION: BookingActionDef = {
id: "cancel",
label: "Cancel booking",
shortLabel: "Cancel",
description: "Cancel this booking",
confirmTitle: "Cancel booking?",
confirmDescription:
"The booking will be marked cancelled. Provide a reason for the audit trail.",
variant: "destructive",
icon: Ban,
input: "reason",
inputLabel: "Cancellation reason",
inputPlaceholder: "Reason for cancellation…",
};
const VIEW_CONTRACT_ACTION: BookingActionDef = {
id: "viewContract",
label: "View contract",
shortLabel: "Contract",
description: "Open contract document and signatures",
confirmTitle: "",
confirmDescription: "",
variant: "outline",
icon: FileSignature,
};
const SIGN_CONTRACT_STAFF_ACTION: BookingActionDef = {
id: "signContractStaff",
label: "Sign contract",
shortLabel: "Sign",
description: "Open contract page and apply staff counter-signature",
confirmTitle: "",
confirmDescription: "",
variant: "default",
icon: FileSignature,
primary: true,
};
const PAY_BOOKING_ACTION: BookingActionDef = {
id: "payBooking",
label: "Pay",
shortLabel: "Pay",
description: "Complete in-app payment",
confirmTitle: "Complete payment?",
confirmDescription:
"This simulates an in-app payment (Telebirr for ETB, card for USD) and marks the booking as paid.",
variant: "default",
icon: Wallet,
primary: true,
};
function withCancel(actions: BookingActionDef[]): BookingActionDef[] {
return [...actions, CANCEL_ACTION];
}
const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
accept: FREIGHT_PERMS.bookings.staffAccept,
requestChanges: FREIGHT_PERMS.bookings.requestChanges,
reject: FREIGHT_PERMS.bookings.reject,
rejectApproval: FREIGHT_PERMS.bookings.rejectApproval,
generateContract: FREIGHT_PERMS.bookings.generateContract,
viewContract: FREIGHT_PERMS.bookings.view,
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
payBooking: FREIGHT_PERMS.bookings.view,
startTransit: FREIGHT_PERMS.bookings.operations,
complete: FREIGHT_PERMS.bookings.operations,
cancel: FREIGHT_PERMS.bookings.cancel,
};
const approvePermissionForRole = (role: string): string | undefined => {
if (role === "LINE_STAFF") return FREIGHT_PERMS.bookings.approveLineStaff;
if (role === "DIRECTOR") return FREIGHT_PERMS.bookings.approveDirector;
if (role === "CEO") return FREIGHT_PERMS.bookings.approveCeo;
return undefined;
};
function filterActionsByUser(
actions: BookingActionDef[],
user: AuthUser | null | undefined,
approvalSteps?: BookingApprovalStep[] | null,
): BookingActionDef[] {
if (!user) return [];
const next = getNextPendingApprovalStep(approvalSteps);
return actions.filter((action) => {
if (action.id === "approve" && next) {
const perm = approvePermissionForRole(next.requiredRole);
return perm ? hasPermission(user, perm) : false;
}
const perm = ACTION_PERMISSION[action.id];
return perm ? hasPermission(user, perm) : true;
});
}
/** Actions available for the current booking status (detail or list). */
export function getBookingActions(ctx: BookingActionContext): BookingActionDef[] {
const { status, paymentCurrency, approvalSteps } = ctx;
export function getBookingActions(
ctx: BookingActionContext,
user?: AuthUser | null,
): BookingActionDef[] {
const { status, approvalSteps } = ctx;
let actions: BookingActionDef[];
switch (status) {
case "SUBMITTED":
return SUBMITTED_ACTIONS;
actions = withCancel(SUBMITTED_ACTIONS);
break;
case "PENDING_APPROVAL":
case "APPROVED_PENDING_SIGNATURE":
return approvalActions(approvalSteps);
actions = withCancel(approvalActions(approvalSteps));
break;
case "APPROVED":
return [
actions = [
{
id: "generateContract",
label: "Generate contract",
@@ -164,64 +267,23 @@ export function getBookingActions(ctx: BookingActionContext): BookingActionDef[]
icon: FileText,
primary: true,
},
CANCEL_ACTION,
];
break;
case "CONTRACT_READY":
actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }];
break;
case "SIGNED_CUSTOMER":
actions = [SIGN_CONTRACT_STAFF_ACTION, VIEW_CONTRACT_ACTION];
break;
case "FULLY_EXECUTED":
return [
{
id: "viewContract",
label:
status === "SIGNED_CUSTOMER"
? "View & sign contract (staff)"
: status === "CONTRACT_READY"
? "View contract"
: "View executed contract",
shortLabel: "Contract",
description: "Open contract document and signatures",
confirmTitle: "",
confirmDescription: "",
variant: "default",
icon: FileSignature,
primary: true,
},
];
case "FULLY_EXECUTED":
if (paymentCurrency === "ETB") {
return [
{
id: "generatePnr",
label: "Generate PNR",
shortLabel: "PNR",
description: "Issue PNR for ETB bank payment",
confirmTitle: "Generate PNR?",
confirmDescription:
"A payment reference number will be issued for the customer.",
variant: "default",
icon: Wallet,
primary: true,
},
];
}
return [];
case "PAYMENT_VERIFICATION_IN_PROGRESS":
return [
{
id: "verifyPayment",
label: "Verify payment",
shortLabel: "Verify",
description: "Confirm USD payment proof",
confirmTitle: "Verify payment?",
confirmDescription:
"Finance confirms the uploaded proof and marks the booking as paid.",
variant: "default",
icon: Check,
primary: true,
},
actions = [
PAY_BOOKING_ACTION,
{ ...VIEW_CONTRACT_ACTION, label: "View executed contract" },
];
break;
case "PAID":
case "PNR_GENERATED":
return [
actions = [
{
id: "startTransit",
label: "Start transit",
@@ -234,8 +296,9 @@ export function getBookingActions(ctx: BookingActionContext): BookingActionDef[]
primary: true,
},
];
break;
case "IN_TRANSIT":
return [
actions = [
{
id: "complete",
label: "Complete booking",
@@ -249,20 +312,39 @@ export function getBookingActions(ctx: BookingActionContext): BookingActionDef[]
primary: true,
},
];
break;
case "CHANGES_REQUESTED":
actions = [CANCEL_ACTION];
break;
default:
return [];
actions = [];
}
if (user === undefined) return actions;
return filterActionsByUser(actions, user, approvalSteps);
}
export function listRowHasActions(row: {
status: BookingStatus;
paymentCurrency: string;
}): boolean {
const actions = getBookingActions({
status: row.status,
paymentCurrency: row.paymentCurrency,
reference: "",
});
if (actions.length > 0) return true;
return row.status === "FULLY_EXECUTED" && row.paymentCurrency === "USD";
/** Opens contract page without confirmation dialog. */
export function isContractNavAction(id: BookingActionId): boolean {
return id === "viewContract" || id === "signContractStaff";
}
export function listRowHasActions(
row: {
status: BookingStatus;
paymentCurrency: string;
approvalSteps?: BookingApprovalStep[] | null;
},
user?: AuthUser | null,
): boolean {
const actions = getBookingActions(
{
status: row.status,
paymentCurrency: row.paymentCurrency,
reference: "",
approvalSteps: row.approvalSteps,
},
user,
);
return actions.length > 0;
}

View File

@@ -199,20 +199,31 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
};
export const BOOKING_LIST_TABS = [
{ key: "all", label: "All bookings", status: null },
{ key: "SUBMITTED", label: "Submitted", status: "SUBMITTED" },
{ key: "PENDING_APPROVAL", label: "Pending Approval", status: "PENDING_APPROVAL" },
{ key: "all", label: "All bookings", statuses: null as string[] | null },
{ key: "intake", label: "Submitted", statuses: ["SUBMITTED"] },
{
key: "APPROVED_PENDING_SIGNATURE",
label: "Pending Signature",
status: "APPROVED_PENDING_SIGNATURE",
key: "in_approval",
label: "In approval",
statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE"],
},
{ key: "SIGNED_CUSTOMER", label: "Customer Signed", status: "SIGNED_CUSTOMER" },
{
key: "PAYMENT_VERIFICATION_IN_PROGRESS",
label: "Payment Verification",
status: "PAYMENT_VERIFICATION_IN_PROGRESS",
key: "approved_contract",
label: "Approved & contract",
statuses: [
"APPROVED",
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
],
},
{
key: "payment",
label: "Payment",
statuses: ["FULLY_EXECUTED", "PAID"],
},
{ key: "operations", label: "Operations", statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"] },
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
] as const;
export type BookingStatusTabKey = (typeof BOOKING_LIST_TABS)[number]["key"];
@@ -229,11 +240,7 @@ export const WORKFLOW_STAGES = [
},
{
label: "Payment",
statuses: [
"PNR_GENERATED",
"PAYMENT_VERIFICATION_IN_PROGRESS",
"PAID",
],
statuses: ["FULLY_EXECUTED", "PAID"],
},
{
label: "Operations",

View File

@@ -18,6 +18,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
return {
id: booking.id,
reference: booking.reference,
approvalSteps: booking.approvalSteps,
customerLabel: labelFromRef(booking.company, booking.companyId),
// customerLabel: labelFromRef(booking.customer, booking.customerId),
status: booking.status,

View File

@@ -17,6 +17,14 @@ export function useBookingList(filter?: BookingListFilter, enabled = true) {
});
}
export function useBookingListSummary(filter?: BookingListFilter, enabled = true) {
return useQuery({
queryKey: QUERY_KEYS.BOOKINGS.listSummary(filter),
queryFn: () => bookingsService.getListSummary(filter),
enabled,
});
}
export function useBookingDetail(id: string | undefined) {
return useQuery({
queryKey: QUERY_KEYS.BOOKINGS.byId(id ?? ""),
@@ -103,23 +111,10 @@ export function useBookingMutations(bookingId: string) {
onError: () => toast.error("Failed to sign contract"),
});
const generatePnr = useMutation({
mutationFn: () => api.bookings.generatePnr.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "PNR generated"),
onError: () => toast.error("Failed to generate PNR"),
});
const submitPaymentProof = useMutation({
mutationFn: (file: File) =>
bookingsService.submitPaymentProof(bookingId, file),
onSuccess: (data) => onSuccess(data, "Payment proof uploaded"),
onError: () => toast.error("Failed to upload payment proof"),
});
const verifyPayment = useMutation({
mutationFn: () => api.bookings.verifyPayment.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "Payment verified"),
onError: () => toast.error("Failed to verify payment"),
const payBooking = useMutation({
mutationFn: () => api.bookings.payBooking.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "Payment completed"),
onError: () => toast.error("Failed to complete payment"),
});
const startTransit = useMutation({
@@ -149,9 +144,7 @@ export function useBookingMutations(bookingId: string) {
rejectStep.isPending ||
generateContract.isPending ||
signContract.isPending ||
generatePnr.isPending ||
submitPaymentProof.isPending ||
verifyPayment.isPending ||
payBooking.isPending ||
startTransit.isPending ||
complete.isPending ||
cancel.isPending;
@@ -164,15 +157,11 @@ export function useBookingMutations(bookingId: string) {
rejectStep,
generateContract,
signContract,
generatePnr,
submitPaymentProof,
verifyPayment,
payBooking,
startTransit,
complete,
cancel,
isPending,
downloadContract: () => bookingsService.downloadContract(bookingId),
downloadPaymentLetter: () =>
bookingsService.downloadPaymentRequestLetter(bookingId),
};
}

View File

@@ -0,0 +1,82 @@
import type { AuthUser } from "@/auth/types";
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
export const FREIGHT_PERMS = {
bookings: {
view: "edr_freight_app:bookings:view",
staffAccept: "edr_freight_app:bookings:staff_accept",
requestChanges: "edr_freight_app:bookings:request_changes",
reject: "edr_freight_app:bookings:reject",
approveLineStaff: "edr_freight_app:bookings:approve_line_staff",
approveDirector: "edr_freight_app:bookings:approve_director",
approveCeo: "edr_freight_app:bookings:approve_ceo",
rejectApproval: "edr_freight_app:bookings:reject_approval",
generateContract: "edr_freight_app:bookings:generate_contract",
signStaff: "edr_freight_app:bookings:sign_staff",
operations: "edr_freight_app:bookings:operations",
cancel: "edr_freight_app:bookings:cancel",
},
} as const;
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
slug.replace(/-/g, "_");
export function getPermissionKeys(user: AuthUser | null | undefined): string[] {
if (!user) return [];
if (user.permissionKeys?.length) return user.permissionKeys;
const keys = new Set<string>();
for (const p of user.permissions ?? []) {
if (p.key) keys.add(p.key);
}
for (const emp of user.employee ?? []) {
for (const pos of emp.positions ?? []) {
for (const p of pos.permissions ?? []) {
if (p.key) keys.add(p.key);
}
}
}
return [...keys];
}
export function isSuperAdmin(user: AuthUser | null | undefined): boolean {
if (user?.isSuperAdmin) return true;
return Boolean(user?.roles?.some((r) => r.key === "super_admin"));
}
export function hasPermission(
user: AuthUser | null | undefined,
key: string,
): boolean {
if (!user) return false;
if (isSuperAdmin(user)) return true;
return getPermissionKeys(user).includes(key);
}
export function canAccessBookings(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.bookings.view);
}
export function ruleEngineViewKey(slug: RuleEngineResourceSlug): string {
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`;
}
export function ruleEngineManageKey(slug: RuleEngineResourceSlug): string {
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`;
}
export function canAccessRuleEngineResource(
user: AuthUser | null | undefined,
slug: RuleEngineResourceSlug,
mode: "view" | "manage",
): boolean {
const key = mode === "manage" ? ruleEngineManageKey(slug) : ruleEngineViewKey(slug);
return hasPermission(user, key);
}
export function canAccessAnyRuleEngineView(
user: AuthUser | null | undefined,
slugs: RuleEngineResourceSlug[],
): boolean {
return slugs.some((slug) => canAccessRuleEngineResource(user, slug, "view"));
}

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>

View File

@@ -305,16 +305,10 @@ export const api = {
bookingsService.signContract(id, payload),
),
generatePnr: endpoint<{ id: string }, BookingDetail>(
payBooking: endpoint<{ id: string }, BookingDetail>(
"bookings",
"generatePnr",
({ id }) => bookingsService.generatePnr(id),
),
verifyPayment: endpoint<{ id: string }, BookingDetail>(
"bookings",
"verifyPayment",
({ id }) => bookingsService.verifyPayment(id),
"payBooking",
({ id }) => bookingsService.payBooking(id),
),
startTransit: endpoint<{ id: string }, BookingDetail>(

View File

@@ -7,6 +7,10 @@ const B = URL_CONSTANTS.BOOKINGS;
export interface BookingListFilter {
status?: string;
/** Comma-separated statuses for grouped tabs */
statuses?: string;
/** Tab key for React Query cache (not sent to API) */
tab?: string;
// customerId?: string;
companyId?: string;
freightType?: string;
@@ -23,6 +27,29 @@ export interface PaginatedBookings {
total: number;
}
export interface BookingListSummaryMetrics {
inQueue: number;
onThisPage: number;
needsAction: number;
urgent: number;
}
export interface BookingListSummaryTabs {
all: number;
intake: number;
in_approval: number;
approved_contract: number;
payment: number;
operations: number;
completed: number;
closed: number;
}
export interface BookingListSummary {
metrics: BookingListSummaryMetrics;
tabs: BookingListSummaryTabs;
}
export interface ApproveStepPayload {
id: string;
stepId: string;
@@ -66,9 +93,41 @@ async function postBooking<T>(url: string, body?: unknown): Promise<T> {
}
export const bookingsService = {
getListSummary: async (filter?: BookingListFilter): Promise<BookingListSummary> => {
const params: Record<string, string | number | boolean | undefined> = {};
if (filter) {
if (filter.statuses) params.statuses = filter.statuses;
else if (filter.status) params.status = filter.status;
if (filter.page != null) params.page = filter.page;
if (filter.pageSize != null) params.pageSize = filter.pageSize;
if (filter.companyId) params.companyId = filter.companyId;
if (filter.freightType) params.freightType = filter.freightType;
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
}
const response = await client.get<BookingListSummary>(B.LIST_SUMMARY, {
params,
});
return unwrap(response.data) as BookingListSummary;
},
list: async (filter?: BookingListFilter): Promise<PaginatedBookings> => {
const params: Record<string, string | number | boolean | undefined> = {};
if (filter) {
if (filter.statuses) params.statuses = filter.statuses;
else if (filter.status) params.status = filter.status;
// filter.tab is intentionally omitted from API params
if (filter.page != null) params.page = filter.page;
if (filter.pageSize != null) params.pageSize = filter.pageSize;
if (filter.sortBy) params.sortBy = filter.sortBy;
if (filter.sortOrder) params.sortOrder = filter.sortOrder;
if (filter.companyId) params.companyId = filter.companyId;
if (filter.freightType) params.freightType = filter.freightType;
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
}
const response = await client.get<PaginatedBookings>(B.BASE, {
params: filter,
params,
});
const data = unwrap(response.data);
return {
@@ -112,14 +171,14 @@ export const bookingsService = {
const response = await client.get(B.CONTRACT_DOWNLOAD(id), {
responseType: "blob",
});
return response.data as Blob;
return ensurePdfBlob(response.data as Blob);
},
downloadContractDocument: async (id: string): Promise<Blob> => {
const response = await client.get(B.CONTRACT_DOCUMENT(id), {
responseType: "blob",
});
return response.data as Blob;
return ensurePdfBlob(response.data as Blob);
},
signContract: (id: string, payload: SignContractPayload) =>
@@ -142,26 +201,7 @@ export const bookingsService = {
role: "STAFF",
}),
generatePnr: (id: string) => postBooking<BookingDetail>(B.PAYMENT_PNR(id)),
submitPaymentProof: async (id: string, file: File): Promise<BookingDetail> => {
const form = new FormData();
form.append("file", file);
const response = await client.post<BookingDetail>(B.PAYMENT_PROOF(id), form, {
headers: { "Content-Type": "multipart/form-data" },
});
return unwrap(response.data) as BookingDetail;
},
verifyPayment: (id: string) =>
postBooking<BookingDetail>(B.PAYMENT_VERIFY(id)),
downloadPaymentRequestLetter: async (id: string): Promise<Blob> => {
const response = await client.get(B.PAYMENT_REQUEST_LETTER(id), {
responseType: "blob",
});
return response.data as Blob;
},
payBooking: (id: string) => postBooking<BookingDetail>(B.PAYMENT_PAY(id)),
startTransit: (id: string) =>
postBooking<BookingDetail>(B.START_TRANSIT(id)),
@@ -171,3 +211,11 @@ export const bookingsService = {
cancel: (id: string, reason: string) =>
postBooking<BookingDetail>(B.CANCEL(id), { reason }),
};
async function ensurePdfBlob(blob: Blob): Promise<Blob> {
if (blob.type.includes("application/json")) {
const body = JSON.parse(await blob.text()) as { message?: string };
throw new Error(body.message ?? "Contract PDF download failed");
}
return blob;
}

View File

@@ -48,11 +48,27 @@ export interface BookingApprovalStep {
id: string;
stepOrder: number;
requiredRole: string;
blocksRole?: string | null;
status: "PENDING" | "APPROVED" | "REJECTED" | "SKIPPED";
actionedAt?: string | null;
remarks?: string | null;
}
export interface BookingNextStep {
action: string;
description: string;
requiredRole?: string;
}
export interface InAppPaymentReceipt {
success: boolean;
provider: string;
providerRef: string;
amount: number;
currency: string;
paidAt: string;
}
export interface BookingReviewNote {
id: string;
note: string;
@@ -90,6 +106,8 @@ export interface BookingDetail {
equipmentReturn?: string;
contractSummary?: string | null;
latestChangeRequestNote?: string | null;
nextStep?: BookingNextStep | null;
paymentReceipt?: InAppPaymentReceipt;
createdAt: string;
updatedAt: string;
// customer?: BookingNamedRef & { companyName?: string };
@@ -114,6 +132,7 @@ export interface BookingListRow {
id: string;
reference: string;
customerLabel: string;
approvalSteps?: BookingApprovalStep[];
status: BookingStatus;
scheduledDate: string;
totalAmount: number;