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