ui improvemnt

This commit is contained in:
Marshal
2026-06-06 21:17:20 +00:00
parent b62ea3f95f
commit 08b46c306f
35 changed files with 2731 additions and 1604 deletions

View File

@@ -18,6 +18,7 @@ async function bootstrap() {
// freight portal (5173), passenger portal (5174), backoffices (5183/5184) // freight portal (5173), passenger portal (5174), backoffices (5183/5184)
// and any other dev port can call the API with cookies + Authorization. // and any other dev port can call the API with cookies + Authorization.
// For production, restrict `origin` to known FQDNs. // For production, restrict `origin` to known FQDNs.
app.enableCors({ app.enableCors({
origin: true, // reflect request origin origin: true, // reflect request origin
credentials: true, credentials: true,
@@ -52,8 +53,10 @@ async function bootstrap() {
SwaggerModule.setup("api/docs", app, document); SwaggerModule.setup("api/docs", app, document);
const port = parseInt(process.env.PORT ?? "3001", 10); const port = parseInt(process.env.PORT ?? "3001", 10);
await app.listen(port, "0.0.0.0"); // await app.listen(port, "0.0.0.0");
// await app.listen(port) await app.listen(
port)
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`[freight-api] listening on port ${port}`); console.log(`[freight-api] listening on port ${port}`);
} }

View File

@@ -1,5 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Check, ShieldCheck } from "lucide-react"; import { Check, ShieldCheck } from "lucide-react";
import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core";
import { BookingConfirmDialog } from "./BookingConfirmDialog"; import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
@@ -11,9 +12,7 @@ import {
} from "@/features/bookings/booking-actions.config"; } from "@/features/bookings/booking-actions.config";
import type { useBookingMutations } from "@/hooks/bookings/useBookings"; import type { useBookingMutations } from "@/hooks/bookings/useBookings";
import type { BookingApprovalStep, BookingDetail } from "@/types/booking"; import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
import { bookingGlass, bookingSurface } from "./booking-ui.styles"; import { SectionCard } from "./detail/SectionCard";
import { Badge, Button } from "@edr/ui-common";
import { cn } from "@/lib/utils";
type Mutations = ReturnType<typeof useBookingMutations>; type Mutations = ReturnType<typeof useBookingMutations>;
@@ -26,23 +25,16 @@ interface ApprovalStepsCardProps {
export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) { export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) {
const { user } = useAuth(); const { user } = useAuth();
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingStep, setPendingStep] = useState<BookingApprovalStep | null>( const [pendingStep, setPendingStep] = useState<BookingApprovalStep | null>(null);
null,
);
const steps = useMemo( const steps = useMemo(
() => () => [...(booking.approvalSteps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder),
[...(booking.approvalSteps ?? [])].sort(
(a, b) => a.stepOrder - b.stepOrder,
),
[booking.approvalSteps], [booking.approvalSteps],
); );
const nextPending = getNextPendingApprovalStep(steps); const nextPending = getNextPendingApprovalStep(steps);
const summary = formatApprovalProgress(booking.status, steps); const summary = formatApprovalProgress(booking.status, steps);
const pendingAction = pendingStep const pendingAction = pendingStep ? buildApproveActionForStep(pendingStep) : null;
? buildApproveActionForStep(pendingStep)
: null;
const openApprove = (step: BookingApprovalStep) => { const openApprove = (step: BookingApprovalStep) => {
setPendingStep(step); setPendingStep(step);
@@ -62,54 +54,60 @@ export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps
); );
}; };
const subtitle =
summary.detail ||
(nextPending
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
: steps.length
? "All steps complete"
: "Accept submission to begin");
return ( return (
<> <>
<div className={cn(bookingSurface.sectionCard, bookingGlass.activeTab)}> <SectionCard
<div className={bookingSurface.sectionHeader}> icon={ShieldCheck}
<div className={bookingSurface.sectionIcon}> title="Approval chain"
<ShieldCheck className="size-4" strokeWidth={1.75} /> extra={
</div> <Badge color="green" variant="light" radius="sm">
<div> {steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
<h2 className="text-sm font-semibold text-foreground"> </Badge>
Approval chain }
</h2> >
<p className="text-xs text-muted-foreground"> <Text size="xs" c="dimmed" mb="sm">
{summary.detail || {subtitle}
(nextPending </Text>
? `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 ? (
{steps.length === 0 ? ( <Text
<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"> size="sm"
Use{" "} c="dimmed"
<strong className="font-semibold text-foreground"> ta="center"
Accept for approval py="lg"
</strong>{" "} px="md"
in staff actions to instantiate steps. style={{
</p> borderRadius: 8,
) : ( border: "1px dashed var(--mantine-color-gray-3)",
<ul className="space-y-2"> background: "var(--mantine-color-gray-0)",
{steps.map((step) => ( }}
<StepRow >
key={step.id} Use <strong>Accept for approval</strong> in staff actions to instantiate steps.
step={step} </Text>
steps={steps} ) : (
user={user} <Stack gap="xs">
isNext={nextPending?.id === step.id} {steps.map((step) => (
isPending={mutations.approveStep.isPending} <StepRow
onApprove={openApprove} key={step.id}
/> step={step}
))} steps={steps}
</ul> user={user}
)} isNext={nextPending?.id === step.id}
</div> isPending={mutations.approveStep.isPending}
</div> onApprove={openApprove}
/>
))}
</Stack>
)}
</SectionCard>
<BookingConfirmDialog <BookingConfirmDialog
open={confirmOpen} open={confirmOpen}
@@ -144,64 +142,74 @@ function StepRow({
onApprove: (step: BookingApprovalStep) => void; onApprove: (step: BookingApprovalStep) => void;
}) { }) {
const canApprove = canActOnApprovalStep(user, step, steps); const canApprove = canActOnApprovalStep(user, step, steps);
const statusStyles = const statusColor =
step.status === "APPROVED" step.status === "APPROVED"
? "border-emerald-500/25 bg-emerald-500/10 text-black" ? "green"
: step.status === "REJECTED" : step.status === "REJECTED"
? "bg-red-500/10 text-red-800 dark:text-red-300" ? "red"
: isNext : isNext
? "border-emerald-500/25 bg-emerald-500/10 text-black" ? "green"
: "bg-muted/40 text-muted-foreground"; : "gray";
const highlighted = isNext || step.status === "APPROVED";
return ( return (
<li <Group
className={cn( justify="space-between"
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors backdrop-blur-sm", wrap="nowrap"
isNext ? bookingGlass.activeTab : "border-border/50 bg-card/60", gap="sm"
)} px="sm"
py="xs"
style={{
borderRadius: 8,
border: `1px solid ${highlighted ? "var(--mantine-color-green-2)" : "var(--mantine-color-gray-2)"}`,
background: isNext ? "var(--mantine-color-green-0)" : "white",
}}
> >
<div className="flex min-w-0 items-center gap-3"> <Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<span <Box
className={cn( style={{
"flex size-8 shrink-0 items-center justify-center rounded-lg text-xs font-bold", display: "flex",
isNext alignItems: "center",
? cn(bookingGlass.iconWellGreen, "text-black") justifyContent: "center",
: "bg-muted/40 text-muted-foreground", width: 28,
)} height: 28,
borderRadius: 8,
flexShrink: 0,
fontSize: 12,
fontWeight: 700,
background: isNext ? "var(--mantine-color-green-1)" : "var(--mantine-color-gray-1)",
color: isNext ? "var(--mantine-color-green-8)" : "var(--mantine-color-gray-6)",
}}
> >
{step.stepOrder} {step.stepOrder}
</span> </Box>
<div className="min-w-0"> <Box style={{ minWidth: 0 }}>
<p className="text-sm font-semibold text-foreground"> <Text size="sm" fw={600}>
{step.requiredRole} {step.requiredRole}
</p> </Text>
{step.remarks && ( {step.remarks && (
<p className="truncate text-xs text-muted-foreground"> <Text size="xs" c="dimmed" truncate>
{step.remarks} {step.remarks}
</p> </Text>
)} )}
</div> </Box>
</div> </Group>
<div className="flex shrink-0 items-center gap-2"> <Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
{canApprove && ( {canApprove && (
<Button <Button
type="button" size="compact-sm"
size="sm" color="green"
className="h-8 gap-1.5 shadow-sm" leftSection={<Check size={14} />}
disabled={isPending} disabled={isPending}
onClick={() => onApprove(step)} onClick={() => onApprove(step)}
> >
<Check className="size-3.5" />
Approve Approve
</Button> </Button>
)} )}
<Badge <Badge variant="light" color={statusColor} size="sm" radius="sm" tt="uppercase">
variant="outline"
className={cn("shrink-0 border text-[9px] uppercase", statusStyles)}
>
{step.status} {step.status}
</Badge> </Badge>
</div> </Group>
</li> </Group>
); );
} }

View File

@@ -1,10 +1,6 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import { ChevronRight, ExternalLink, MoreHorizontal } from "lucide-react";
ChevronRight, import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
ExternalLink,
Loader2,
MoreHorizontal,
} from "lucide-react";
import { BookingConfirmDialog } from "./BookingConfirmDialog"; import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { useBookingActionDialog } from "./useBookingActionDialog"; import { useBookingActionDialog } from "./useBookingActionDialog";
@@ -16,16 +12,6 @@ import {
type BookingActionContext, type BookingActionContext,
} from "@/features/bookings/booking-actions.config"; } from "@/features/bookings/booking-actions.config";
import type { BookingListRow } from "@/types/booking"; import type { BookingListRow } from "@/types/booking";
import { cn } from "@/lib/utils";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@edr/ui-common";
interface BookingActionsMenuProps { interface BookingActionsMenuProps {
row: BookingListRow; row: BookingListRow;
@@ -39,7 +25,6 @@ interface BookingActionsMenuProps {
export function BookingActionsMenu({ export function BookingActionsMenu({
row, row,
variant = "table", variant = "table",
className,
onSuppressRowClick, onSuppressRowClick,
}: BookingActionsMenuProps) { }: BookingActionsMenuProps) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -57,184 +42,179 @@ export function BookingActionsMenu({
const goToContract = () => const goToContract = () =>
navigate(`/dashboard/booking-requests/${row.id}/contract`); navigate(`/dashboard/booking-requests/${row.id}/contract`);
const hasMenu = listRowHasActions(row, user); const handleAction = (action: (typeof actions)[number]) => {
onSuppressRowClick?.();
if (isContractNavAction(action.id)) {
goToContract();
} else {
flow.openAction(action);
}
};
const hasMenu = listRowHasActions(row, user);
const primary = actions.find((a) => a.primary) ?? actions[0]; const primary = actions.find((a) => a.primary) ?? actions[0];
if (!hasMenu && variant === "table") { if (!hasMenu && variant === "table") {
return ( return (
<Button <ActionIcon
variant="ghost" variant="subtle"
size="icon" color="gray"
className="size-8 text-muted-foreground hover:text-primary"
onClick={() => navigate(`/dashboard/booking-requests/${row.id}`)} onClick={() => navigate(`/dashboard/booking-requests/${row.id}`)}
aria-label="View booking" aria-label="View booking"
> >
<ChevronRight className="size-4" /> <ChevronRight size={16} />
</Button> </ActionIcon>
);
}
// Toolbar: lay every action out as a button row.
if (variant === "toolbar" && actions.length > 0) {
return (
<>
<Group gap="sm" w="100%">
{actions.map((action) => {
const Icon = action.icon;
const destructive = action.variant === "destructive";
return (
<Button
key={action.id}
size="sm"
variant={action.primary && !destructive ? "filled" : "default"}
color={destructive ? "red" : action.primary ? "green" : "gray"}
leftSection={<Icon size={16} />}
disabled={mutations.isPending}
onClick={() => handleAction(action)}
>
{action.label}
</Button>
);
})}
</Group>
<ActionDialog flow={flow} pendingAction={pendingAction} onSuppressRowClick={onSuppressRowClick} />
</>
); );
} }
return ( return (
<> <Group
<div gap={4}
data-stop-row-click justify="flex-end"
className={cn( wrap="nowrap"
"flex w-full min-h-[2.5rem] items-center justify-end gap-1", data-stop-row-click
variant === "table" && "opacity-80 transition-opacity group-hover/tr:opacity-100", onClick={(e) => e.stopPropagation()}
className, onKeyDown={(e) => e.stopPropagation()}
)} >
onClick={(e) => e.stopPropagation()} {variant === "table" && primary && (
onKeyDown={(e) => e.stopPropagation()} <Button
> size="compact-sm"
{variant === "table" && primary && ( color="green"
<Button visibleFrom="lg"
size="sm" leftSection={<primary.icon size={14} />}
className="hidden h-8 gap-1.5 px-2.5 shadow-sm lg:inline-flex" disabled={mutations.isPending}
disabled={mutations.isPending} onClick={() => handleAction(primary)}
onClick={() => >
isContractNavAction(primary.id) {primary.shortLabel}
? goToContract() </Button>
: flow.openAction(primary) )}
}
<Menu position="bottom-end" width={220} withinPortal>
<Menu.Target>
<ActionIcon
variant={variant === "table" ? "subtle" : "default"}
color="gray"
loading={mutations.isPending}
aria-label="Booking actions"
> >
<primary.icon className="size-3.5" /> <MoreHorizontal size={16} />
{primary.shortLabel} </ActionIcon>
</Button> </Menu.Target>
)} <Menu.Dropdown>
<Menu.Label>
{variant === "toolbar" && actions.length > 0 ? ( <Text size="xs" ff="monospace" c="dimmed">
<div className="flex w-full flex-wrap gap-2">
{actions.map((action) => {
const Icon = action.icon;
return (
<Button
key={action.id}
size="sm"
variant={
action.variant === "destructive"
? "outline"
: action.primary
? "default"
: "outline"
}
className={cn(
"gap-2 shadow-sm",
action.variant === "destructive" &&
"border-red-200 text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30",
)}
disabled={mutations.isPending}
onClick={() =>
isContractNavAction(action.id)
? goToContract()
: flow.openAction(action)
}
>
<Icon className="size-4" />
{action.label}
</Button>
);
})}
</div>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={variant === "table" ? "ghost" : "outline"}
size={variant === "table" ? "icon" : "sm"}
className={cn(
variant === "table" ? "size-8" : "gap-2",
"shrink-0",
)}
disabled={mutations.isPending}
aria-label="Booking actions"
>
{mutations.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<MoreHorizontal className="size-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-mono text-xs text-muted-foreground">
{row.reference} {row.reference}
</DropdownMenuLabel> </Text>
<DropdownMenuSeparator /> </Menu.Label>
{actions.map((action) => { {actions.map((action) => {
const Icon = action.icon; const Icon = action.icon;
return ( return (
<DropdownMenuItem <Menu.Item
key={action.id} key={action.id}
className={cn( color={action.variant === "destructive" ? "red" : undefined}
"gap-2 cursor-pointer", leftSection={<Icon size={15} />}
action.variant === "destructive" && "text-red-700 focus:text-red-700", onClick={() => handleAction(action)}
)} >
onSelect={(event) => { {action.label}
event.preventDefault(); </Menu.Item>
onSuppressRowClick?.(); );
if (isContractNavAction(action.id)) { })}
goToContract(); {actions.length > 0 && <Menu.Divider />}
} else { <Menu.Item
flow.openAction(action); leftSection={<ExternalLink size={15} />}
} onClick={() => {
}} onSuppressRowClick?.();
> navigate(`/dashboard/booking-requests/${row.id}`);
<Icon className="size-4 opacity-70" /> }}
<span>{action.label}</span> >
</DropdownMenuItem> Open full details
); </Menu.Item>
})} </Menu.Dropdown>
{actions.length > 0 && <DropdownMenuSeparator />} </Menu>
<DropdownMenuItem
className="gap-2 cursor-pointer"
onSelect={(event) => {
event.preventDefault();
onSuppressRowClick?.();
navigate(`/dashboard/booking-requests/${row.id}`);
}}
>
<ExternalLink className="size-4 opacity-70" />
Open full details
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<BookingConfirmDialog <ActionDialog flow={flow} pendingAction={pendingAction} onSuppressRowClick={onSuppressRowClick} />
open={flow.dialogOpen} </Group>
onOpenChange={(open) => { );
if (!open) onSuppressRowClick?.(); }
flow.setDialogOpen(open);
}} function ActionDialog({
action={pendingAction} flow,
reference={flow.mergedContext.reference} pendingAction,
inputValue={flow.inputValue} onSuppressRowClick,
onInputChange={flow.setInputValue} }: {
selectedFile={flow.selectedFile} flow: ReturnType<typeof useBookingActionDialog>;
onFileChange={flow.setSelectedFile} pendingAction: ReturnType<typeof useBookingActionDialog>["pendingAction"];
onConfirm={() => { onSuppressRowClick?: () => void;
onSuppressRowClick?.(); }) {
flow.runAction(); return (
}} <BookingConfirmDialog
isPending={mutations.isPending || flow.detailLoading} open={flow.dialogOpen}
confirmDisabled={flow.confirmDisabled} onOpenChange={(open) => {
extra={ if (!open) onSuppressRowClick?.();
flow.detailLoading ? ( flow.setDialogOpen(open);
<p className="flex items-center gap-2 text-sm text-muted-foreground"> }}
<Loader2 className="size-4 animate-spin" /> action={pendingAction}
Loading approval steps reference={flow.mergedContext.reference}
</p> inputValue={flow.inputValue}
) : pendingAction?.id === "approve" && onInputChange={flow.setInputValue}
!getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? ( selectedFile={flow.selectedFile}
<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"> onFileChange={flow.setSelectedFile}
No pending approval step. Refresh the page after staff accept, or onConfirm={() => {
reject the booking. onSuppressRowClick?.();
</p> flow.runAction();
) : null }}
} isPending={flow.mutations.isPending || flow.detailLoading}
/> confirmDisabled={flow.confirmDisabled}
</> extra={
flow.detailLoading ? (
<Text size="sm" c="dimmed">
Loading approval steps
</Text>
) : pendingAction?.id === "approve" &&
!getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? (
<Text
size="sm"
c="orange.9"
p="xs"
style={{
borderRadius: 8,
border: "1px solid var(--mantine-color-orange-2)",
background: "var(--mantine-color-orange-0)",
}}
>
No pending approval step. Refresh the page after staff accept, or reject the
booking.
</Text>
) : null
}
/>
); );
} }

View File

@@ -1,12 +1,11 @@
import { Download, Zap } from "lucide-react"; import { Download, Zap, FileText } from "lucide-react";
import { Stack, Text, Button } from "@mantine/core";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
import { BookingActionsMenu } from "./BookingActionsMenu"; import { BookingActionsMenu } from "./BookingActionsMenu";
import { bookingSurface } from "./booking-ui.styles"; import { SectionCard } from "./detail/SectionCard";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import type { useBookingMutations } from "@/hooks/bookings/useBookings"; import type { useBookingMutations } from "@/hooks/bookings/useBookings";
import { Button } from "@edr/ui-common";
import { cn } from "@/lib/utils";
type Mutations = ReturnType<typeof useBookingMutations>; type Mutations = ReturnType<typeof useBookingMutations>;
@@ -16,10 +15,7 @@ interface BookingActionsToolbarProps {
} }
/** Detail-page actions: primary toolbar + downloads. */ /** Detail-page actions: primary toolbar + downloads. */
export function BookingActionsToolbar({ export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
booking,
mutations,
}: BookingActionsToolbarProps) {
const row = toBookingListRow(booking); const row = toBookingListRow(booking);
const { status } = booking; const { status } = booking;
@@ -33,50 +29,62 @@ export function BookingActionsToolbar({
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
if ( if (status === "REJECTED" || status === "CANCELLED" || status === "COMPLETED") {
status === "REJECTED" ||
status === "CANCELLED" ||
status === "COMPLETED"
) {
return null; return null;
} }
if (status === "CHANGES_REQUESTED") { if (status === "CHANGES_REQUESTED") {
return ( return (
<PanelShell title="Awaiting customer" description="No staff actions until resubmit."> <SectionCard icon={Zap} title="Awaiting customer">
{booking.latestChangeRequestNote && ( <Stack gap="sm">
<p className="rounded-lg border border-border/50 bg-muted/15 p-3 text-sm leading-relaxed backdrop-blur-sm"> <Text size="sm" c="dimmed">
{booking.latestChangeRequestNote} No staff actions until resubmit.
</p> </Text>
)} {booking.latestChangeRequestNote && (
</PanelShell> <Text
size="sm"
p="sm"
style={{
borderRadius: 8,
border: "1px solid var(--mantine-color-gray-2)",
background: "var(--mantine-color-gray-0)",
lineHeight: 1.5,
}}
>
{booking.latestChangeRequestNote}
</Text>
)}
</Stack>
</SectionCard>
); );
} }
if (["DRAFT", "PENDING_CONSOLIDATION", "CONSOLIDATED"].includes(status)) { if (["DRAFT", "PENDING_CONSOLIDATION", "CONSOLIDATED"].includes(status)) {
return ( return (
<PanelShell <SectionCard icon={Zap} title="No staff actions">
title="No staff actions" <Text size="sm" c="dimmed">
description="Monitor until the customer or system advances status." Monitor until the customer or system advances status.
muted </Text>
/> </SectionCard>
); );
} }
return ( return (
<div className="space-y-4"> <Stack gap="lg">
<PanelShell <SectionCard icon={Zap} title="Staff actions">
title="Staff actions" <Stack gap="sm">
description="Confirm each step before it is applied." <Text size="xs" c="dimmed">
> Confirm each step before it is applied.
<BookingActionsMenu row={row} variant="toolbar" /> </Text>
</PanelShell> <BookingActionsMenu row={row} variant="toolbar" />
</Stack>
</SectionCard>
{status === "CONTRACT_READY" && ( {status === "CONTRACT_READY" && (
<PanelShell title="Documents" description="Download generated contract."> <SectionCard icon={FileText} title="Documents">
<Button <Button
variant="outline" variant="default"
className="gap-2 border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80" leftSection={<Download size={16} />}
onClick={() => onClick={() =>
downloadBlob( downloadBlob(
() => mutations.downloadContract(), () => mutations.downloadContract(),
@@ -84,38 +92,10 @@ export function BookingActionsToolbar({
) )
} }
> >
<Download className="size-4" />
Download contract Download contract
</Button> </Button>
</PanelShell> </SectionCard>
)} )}
</div> </Stack>
);
}
function PanelShell({
title,
description,
children,
muted,
}: {
title: string;
description: string;
children: React.ReactNode;
muted?: boolean;
}) {
return (
<div className={cn(bookingSurface.sectionCard, !muted && "ring-0")}>
<div className={bookingSurface.sectionHeader}>
<div className={bookingSurface.sectionIcon}>
<Zap className="size-4" strokeWidth={1.75} />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
<div className="flex flex-col gap-3 px-5 py-5">{children}</div>
</div>
); );
} }

View File

@@ -1,17 +1,16 @@
import { Loader2 } from "lucide-react"; import type { ReactNode } from "react";
import {
Modal,
Group,
Stack,
Text,
Box,
Button,
Textarea,
FileInput,
} from "@mantine/core";
import type { BookingActionDef } from "@/features/bookings/booking-actions.config"; import type { BookingActionDef } from "@/features/bookings/booking-actions.config";
import { cn } from "@/lib/utils";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Textarea,
} from "@edr/ui-common";
interface BookingConfirmDialogProps { interface BookingConfirmDialogProps {
open: boolean; open: boolean;
@@ -25,7 +24,7 @@ interface BookingConfirmDialogProps {
onConfirm: () => void; onConfirm: () => void;
isPending: boolean; isPending: boolean;
confirmDisabled?: boolean; confirmDisabled?: boolean;
extra?: React.ReactNode; extra?: ReactNode;
} }
export function BookingConfirmDialog({ export function BookingConfirmDialog({
@@ -45,129 +44,119 @@ export function BookingConfirmDialog({
if (!action || !action.confirmTitle) return null; if (!action || !action.confirmTitle) return null;
const Icon = action.icon; const Icon = action.icon;
const needsTextInput = const needsTextInput = action.input === "note" || action.input === "reason";
action.input === "note" || action.input === "reason";
const needsFileInput = action.input === "file"; const needsFileInput = action.input === "file";
const inputMissing = const inputMissing =
(needsTextInput && !inputValue.trim()) || (needsTextInput && !inputValue.trim()) || (needsFileInput && !selectedFile);
(needsFileInput && !selectedFile);
const isDestructive = action.variant === "destructive"; const isDestructive = action.variant === "destructive";
const accent = isDestructive ? "red" : "green";
const preventClickThrough = (event: React.MouseEvent) => {
event.preventDefault();
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Modal
<DialogContent opened={open}
className="gap-0 overflow-hidden p-0 sm:max-w-md" onClose={() => onOpenChange(false)}
showCloseButton={false} withCloseButton={false}
onCloseAutoFocus={(event) => event.preventDefault()} centered
radius="md"
size="md"
padding={0}
title={null}
>
{/* Header */}
<Box
px="lg"
py="md"
style={{
background: `var(--mantine-color-${accent}-0)`,
borderBottom: `1px solid var(--mantine-color-${accent}-1)`,
}}
> >
<div <Group align="flex-start" gap="sm" wrap="nowrap">
className={cn( <Box
"border-b px-6 py-5", style={{
isDestructive display: "flex",
? "bg-gradient-to-br from-red-500/10 via-background to-background" alignItems: "center",
: "bg-gradient-to-br from-primary/8 via-background to-background", justifyContent: "center",
)} width: 44,
> height: 44,
<DialogHeader className="gap-3 text-left"> borderRadius: 12,
<div className="flex items-start gap-3"> flexShrink: 0,
<div background: `var(--mantine-color-${accent}-1)`,
className={cn( color: `var(--mantine-color-${accent}-7)`,
"flex size-11 shrink-0 items-center justify-center rounded-xl shadow-sm", }}
isDestructive
? "bg-red-500/15 text-red-700 dark:text-red-300"
: "bg-primary/15 text-primary",
)}
>
<Icon className="size-5" />
</div>
<div className="min-w-0 space-y-1 pt-0.5">
<DialogTitle className="text-base leading-snug">
{action.confirmTitle}
</DialogTitle>
{reference && (
<p className="font-mono text-xs font-semibold text-muted-foreground">
{reference}
</p>
)}
</div>
</div>
<DialogDescription className="text-left text-sm leading-relaxed">
{action.confirmDescription}
</DialogDescription>
</DialogHeader>
</div>
<div className="space-y-4 px-6 py-5">
{needsTextInput && (
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{action.inputLabel}
<span className="text-red-600"> *</span>
</label>
<Textarea
value={inputValue}
onChange={(e) => onInputChange(e.target.value)}
placeholder={action.inputPlaceholder}
rows={4}
className="min-h-[100px] resize-y"
/>
</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>
<DialogFooter className="gap-2 border-t bg-muted/20 px-6 py-4 sm:justify-end">
<Button
type="button"
variant="outline"
disabled={isPending}
onMouseDown={preventClickThrough}
onClick={() => onOpenChange(false)}
> >
Cancel <Icon size={20} />
</Button> </Box>
<Button <Stack gap={2} style={{ minWidth: 0 }}>
type="button" <Text fw={700} size="md" style={{ lineHeight: 1.3 }}>
variant={isDestructive ? "destructive" : "default"} {action.confirmTitle}
disabled={isPending || inputMissing || confirmDisabled} </Text>
className="min-w-[7rem] gap-2" {reference && (
onMouseDown={preventClickThrough} <Text size="xs" c="dimmed" ff="monospace" fw={600}>
onClick={onConfirm} {reference}
> </Text>
{isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Icon className="size-4" />
)} )}
{action.shortLabel} </Stack>
</Button> </Group>
</DialogFooter> {action.confirmDescription && (
</DialogContent> <Text size="sm" c="dimmed" mt="sm" style={{ lineHeight: 1.5 }}>
</Dialog> {action.confirmDescription}
</Text>
)}
</Box>
{/* Body */}
<Stack gap="md" px="lg" py="lg">
{needsTextInput && (
<Textarea
label={action.inputLabel}
withAsterisk
value={inputValue}
onChange={(e) => onInputChange(e.currentTarget.value)}
placeholder={action.inputPlaceholder}
minRows={4}
autosize
/>
)}
{needsFileInput && (
<FileInput
label={action.inputLabel ?? "Bank slip file"}
withAsterisk
placeholder="Select a file"
accept=".pdf,.png,.jpg,.jpeg"
value={selectedFile}
onChange={(file) => onFileChange?.(file)}
clearable
/>
)}
{extra}
</Stack>
{/* Footer */}
<Group
justify="flex-end"
gap="sm"
px="lg"
py="md"
style={{
borderTop: "1px solid var(--mantine-color-gray-2)",
background: "var(--mantine-color-gray-0)",
}}
>
<Button variant="default" disabled={isPending} onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
color={accent}
loading={isPending}
disabled={inputMissing || confirmDisabled}
leftSection={<Icon size={16} />}
onClick={onConfirm}
miw={112}
>
{action.shortLabel}
</Button>
</Group>
</Modal>
); );
} }

View File

@@ -1,86 +1,93 @@
import { Banknote, Receipt } from "lucide-react"; import { Banknote, Receipt } from "lucide-react";
import { Paper, Stack, Group, Text, Divider } from "@mantine/core";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
import { Separator } from "@edr/ui-common";
import { bookingGlass, bookingSurface } from "./booking-ui.styles"; import { SectionCard } from "./detail/SectionCard";
import { detailStyles } from "./detail/booking-detail.styles";
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) { export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
const amount = Number(booking.totalAmount); const amount = Number(booking.totalAmount);
const modifiers = booking.cargoModifiers ?? []; const modifiers = booking.cargoModifiers ?? [];
return ( return (
<div className={bookingSurface.sectionCard}> <SectionCard icon={Banknote} title="Pricing & payment">
<div className={bookingSurface.sectionHeader}> <Stack gap="md">
<div className={bookingSurface.sectionIcon}> <Paper radius="md" withBorder p="md" style={detailStyles.highlightCard}>
<Banknote className="size-4" strokeWidth={1.75} /> <Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">
Pricing & payment
</h2>
<p className="text-xs text-muted-foreground">Commercial terms</p>
</div>
</div>
<div className="space-y-4 px-5 py-5">
<div className={bookingSurface.valueCard}>
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Total amount Total amount
</p> </Text>
<p className="mt-1 font-mono text-2xl font-semibold tabular-nums tracking-tight text-foreground"> <Text
size="xl"
fw={700}
c="green.9"
mt={4}
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
>
{booking.paymentCurrency}{" "} {booking.paymentCurrency}{" "}
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })} {amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</p> </Text>
</div> </Paper>
<Row label="Payment status" value={booking.paymentStatus} /> <Row label="Payment status" value={booking.paymentStatus} />
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />} {booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
{modifiers.length > 0 && ( {modifiers.length > 0 && (
<> <>
<Separator className="opacity-50" /> <Divider color="var(--mantine-color-gray-2)" />
<p className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"> <Group gap={6}>
<Receipt className="size-3" /> <Receipt size={13} color="var(--mantine-color-gray-5)" />
Surcharges applied <Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
</p> Surcharges applied
<ul className="space-y-2"> </Text>
</Group>
<Stack gap="xs">
{modifiers.map((m) => ( {modifiers.map((m) => (
<li <Group
key={m.id} key={m.id}
className="flex justify-between rounded-lg border border-border/50 bg-muted/15 px-3 py-2 text-sm backdrop-blur-sm" justify="space-between"
px="sm"
py={6}
style={{
borderRadius: 8,
border: "1px solid var(--mantine-color-gray-2)",
background: "var(--mantine-color-gray-0)",
}}
> >
<span className="text-muted-foreground">Modifier</span> <Text size="sm" c="dimmed">
<span className="font-mono font-semibold tabular-nums"> Modifier
</Text>
<Text size="sm" fw={600} style={{ fontVariantNumeric: "tabular-nums" }}>
{Number(m.calculatedAmount).toLocaleString()} {Number(m.calculatedAmount).toLocaleString()}
</span> </Text>
</li> </Group>
))} ))}
</ul> </Stack>
</> </>
)} )}
</div> </Stack>
</div> </SectionCard>
); );
} }
function Row({ function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
label,
value,
mono,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return ( return (
<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"> <Group
<span className="text-muted-foreground">{label}</span> justify="space-between"
<span px="sm"
className={ py="xs"
mono style={{
? "font-mono text-xs font-semibold text-foreground" borderRadius: 8,
: "font-medium text-foreground" border: "1px solid var(--mantine-color-gray-2)",
} background: "var(--mantine-color-gray-0)",
> }}
>
<Text size="sm" c="dimmed">
{label}
</Text>
<Text size="sm" fw={600} ff={mono ? "monospace" : undefined}>
{value} {value}
</span> </Text>
</div> </Group>
); );
} }

View File

@@ -1,5 +1,5 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Card, Group, Stack, Text, SimpleGrid } from "@mantine/core"; import { Card, Group, Stack, Text, Paper } from "@mantine/core";
export interface StatItem { export interface StatItem {
label: string; label: string;
@@ -18,67 +18,91 @@ const accentColors = {
export function BookingStatGrid({ items }: { items: StatItem[] }) { export function BookingStatGrid({ items }: { items: StatItem[] }) {
return ( return (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md"> <Paper
{items.map((item) => { p="md"
const Icon = item.icon; radius="lg"
const accent = item.accent ?? "default"; withBorder
const accentStyle = accentColors[accent]; style={{
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
overflowX: "auto",
overflowY: "hidden",
WebkitOverflowScrolling: "touch",
scrollBehavior: "smooth",
}}
>
<Group
gap="lg"
style={{
minWidth: "min-content",
display: "flex",
flexWrap: "nowrap",
}}
>
{items.map((item) => {
const Icon = item.icon;
const accent = item.accent ?? "default";
const accentStyle = accentColors[accent];
return ( return (
<Card <Card
key={item.label} key={item.label}
p="lg" p="lg"
radius="lg" radius="lg"
withBorder withBorder
style={{ style={{
background: "white", background: "white",
border: "1px solid var(--mantine-color-gray-2)", border: "1px solid var(--mantine-color-gray-2)",
transition: "all 0.2s ease", transition: "all 0.2s ease",
cursor: "pointer", cursor: "pointer",
}} minWidth: "280px",
onMouseEnter={(e) => { width: "280px",
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.08)"; flexShrink: 0,
e.currentTarget.style.borderColor = "var(--mantine-color-gray-3)"; }}
}} onMouseEnter={(e) => {
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = "0 4px 12px rgba(34, 197, 94, 0.12)";
e.currentTarget.style.boxShadow = "none"; e.currentTarget.style.borderColor = "var(--mantine-color-green-3)";
e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)"; }}
}} onMouseLeave={(e) => {
> e.currentTarget.style.boxShadow = "none";
<Group justify="space-between" align="flex-start"> e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)";
<Stack gap="xs" style={{ flex: 1 }}> }}
<Text size="xs" fw={600} c="dimmed" tt="uppercase"> >
{item.label} <Group justify="space-between" align="flex-start">
</Text> <Stack gap="xs" style={{ flex: 1 }}>
<Text size="32px" fw={700} style={{ lineHeight: 1, letterSpacing: "-0.02em" }}> <Text size="xs" fw={600} c="dimmed" tt="uppercase">
{item.value} {item.label}
</Text>
{item.hint && (
<Text size="xs" c="dimmed">
{item.hint}
</Text> </Text>
)} <Text size="32px" fw={700} style={{ lineHeight: 1, letterSpacing: "-0.02em" }}>
</Stack> {item.value}
<div </Text>
style={{ {item.hint && (
display: "flex", <Text size="xs" c="dimmed">
alignItems: "center", {item.hint}
justifyContent: "center", </Text>
width: "44px", )}
height: "44px", </Stack>
borderRadius: "10px", <div
background: accentStyle.bg, style={{
color: accentStyle.color, display: "flex",
flexShrink: 0, alignItems: "center",
transition: "transform 0.2s ease", justifyContent: "center",
}} width: "44px",
> height: "44px",
<Icon size={22} strokeWidth={1.75} /> borderRadius: "10px",
</div> background: accentStyle.bg,
</Group> color: accentStyle.color,
</Card> flexShrink: 0,
); transition: "transform 0.2s ease",
})} }}
</SimpleGrid> >
<Icon size={22} strokeWidth={1.75} />
</div>
</Group>
</Card>
);
})}
</Group>
</Paper>
); );
} }

View File

@@ -8,7 +8,7 @@ import {
Wallet, Wallet,
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import { Group, Badge, UnstyledButton, Stack, Text } from "@mantine/core"; import { Group, Badge, UnstyledButton, Text } from "@mantine/core";
import { import {
BOOKING_LIST_TABS, BOOKING_LIST_TABS,
@@ -57,12 +57,12 @@ export function BookingStatusTabs({
onClick={() => onChange(tab.key)} onClick={() => onChange(tab.key)}
style={{ style={{
background: isActive ? "white" : "transparent", background: isActive ? "white" : "transparent",
border: isActive ? "1px solid var(--mantine-color-blue-3)" : "1px solid var(--mantine-color-gray-2)", border: isActive ? "1px solid var(--mantine-color-green-3)" : "1px solid var(--mantine-color-gray-2)",
borderRadius: "10px", borderRadius: "10px",
padding: "10px 16px", padding: "10px 16px",
transition: "all 0.2s ease", transition: "all 0.2s ease",
cursor: "pointer", cursor: "pointer",
boxShadow: isActive ? "0 2px 8px rgba(59, 130, 246, 0.1)" : "none", boxShadow: isActive ? "0 2px 8px rgba(34, 197, 94, 0.1)" : "none",
}} }}
> >
<Group gap="sm" justify="space-between" wrap="nowrap"> <Group gap="sm" justify="space-between" wrap="nowrap">
@@ -75,8 +75,8 @@ export function BookingStatusTabs({
width: "32px", width: "32px",
height: "32px", height: "32px",
borderRadius: "8px", borderRadius: "8px",
background: isActive ? "var(--mantine-color-blue-1)" : "var(--mantine-color-gray-1)", background: isActive ? "var(--mantine-color-green-1)" : "var(--mantine-color-gray-1)",
color: isActive ? "var(--mantine-color-blue-7)" : "var(--mantine-color-gray-6)", color: isActive ? "var(--mantine-color-green-7)" : "var(--mantine-color-gray-6)",
}} }}
> >
{TAB_ICONS[tab.key]} {TAB_ICONS[tab.key]}
@@ -89,7 +89,7 @@ export function BookingStatusTabs({
<Badge <Badge
size="sm" size="sm"
variant={isActive ? "filled" : "light"} variant={isActive ? "filled" : "light"}
color={isActive ? "blue" : "gray"} color={isActive ? "green" : "gray"}
radius="lg" radius="lg"
> >
{count} {count}

View File

@@ -1,20 +1,28 @@
import { import {
Check, Check,
CheckCircle2,
FileSignature, FileSignature,
FileText, FileText,
Train, Train,
Wallet, Wallet,
type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { Paper, Group, Stack, Text, Box } from "@mantine/core";
import { cn } from "@/lib/utils";
import { import {
getWorkflowStageIndex, getWorkflowStageIndex,
WORKFLOW_STAGES, WORKFLOW_STAGES,
} from "@/features/bookings/booking-status.config"; } from "@/features/bookings/booking-status.config";
import { bookingGlass, bookingSurface } from "./booking-ui.styles"; import { SectionCard } from "./detail/SectionCard";
import { BRAND_GREEN } from "./detail/booking-detail.styles";
const STAGE_ICONS = [FileText, FileSignature, FileSignature, Wallet, Train, Check]; const STAGE_ICONS: LucideIcon[] = [
FileText,
FileSignature,
FileSignature,
Wallet,
Train,
Check,
];
interface BookingWorkflowStepperProps { interface BookingWorkflowStepperProps {
status: string; status: string;
@@ -27,104 +35,99 @@ export function BookingWorkflowStepper({
status, status,
title, title,
description, description,
titleColor,
}: BookingWorkflowStepperProps) { }: BookingWorkflowStepperProps) {
const currentStage = getWorkflowStageIndex(status); const currentStage = getWorkflowStageIndex(status);
const isTerminal = currentStage < 0; const isTerminal = currentStage < 0;
return ( return (
<div className={bookingSurface.sectionCard}> <SectionCard icon={Train} title="Workflow progress">
<div className={bookingSurface.sectionHeader}> <Group gap={0} wrap="nowrap" align="flex-start" mb="lg">
<div className={bookingSurface.sectionIcon}> {WORKFLOW_STAGES.map((stage, index) => {
<Train className="size-4" strokeWidth={1.75} /> const Icon = STAGE_ICONS[index] ?? FileText;
</div> const isComplete = !isTerminal && index < currentStage;
<div> const isActive = !isTerminal && index === currentStage;
<h2 className="text-sm font-semibold text-foreground"> const isLast = index === WORKFLOW_STAGES.length - 1;
Workflow progress
</h2> return (
<p className="text-xs text-muted-foreground"> <Box key={stage.label} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: 0 }}>
Customer submission through completion <Group gap={0} wrap="nowrap" align="center">
</p> <Stack gap={6} align="center" style={{ flexShrink: 0 }}>
</div> <Box
</div> style={{
<div className="space-y-8 px-5 py-6"> display: "flex",
<div className="relative px-2"> alignItems: "center",
<div className="absolute left-4 right-4 top-5 h-px bg-border/60" /> justifyContent: "center",
<div width: 34,
className="absolute left-4 top-5 h-px bg-emerald-500/40 transition-all duration-700 ease-out" height: 34,
style={{ borderRadius: "50%",
width: background: isComplete
!isTerminal && currentStage >= 0 ? BRAND_GREEN
? `calc(${(currentStage / (WORKFLOW_STAGES.length - 1)) * 100}% - 2rem)` : isActive
: "0%", ? "white"
}} : "var(--mantine-color-gray-1)",
/> border: isActive
<div className="relative flex justify-between"> ? `2px solid ${BRAND_GREEN}`
{WORKFLOW_STAGES.map((stage, idx) => { : isComplete
const Icon = STAGE_ICONS[idx] ?? FileText; ? "2px solid transparent"
const isCompleted = !isTerminal && idx < currentStage; : "2px solid var(--mantine-color-gray-2)",
const isActive = !isTerminal && idx === currentStage; color: isComplete
return ( ? "white"
<div : isActive
key={stage.label} ? "var(--mantine-color-green-7)"
className="flex max-w-[4.5rem] flex-col items-center gap-2.5 sm:max-w-none" : "var(--mantine-color-gray-5)",
> transition: "all 0.2s ease",
<div }}
className={cn(
"flex size-10 items-center justify-center rounded-full border-2 bg-card/80 backdrop-blur-sm transition-all duration-300",
isCompleted &&
cn(bookingGlass.iconWellGreen, "border-emerald-500/30 text-black"),
isActive &&
cn(
bookingGlass.activeTab,
"scale-105 border-emerald-500/30 text-black shadow-sm",
),
!isCompleted &&
!isActive &&
"border-border/60 text-muted-foreground",
)}
> >
{isCompleted ? ( {isComplete ? <Check size={16} strokeWidth={3} /> : <Icon size={15} />}
<CheckCircle2 className="size-4" /> </Box>
) : ( <Text
<Icon className="size-4" /> size="xs"
)} fw={isActive ? 600 : 500}
</div> c={isActive ? "green.7" : isComplete ? "dark" : "dimmed"}
<span ta="center"
className={cn( style={{ whiteSpace: "nowrap" }}
"text-center text-[10px] font-semibold uppercase leading-tight tracking-wide",
isActive ? "text-black" : "text-muted-foreground",
)}
> >
{stage.label} {stage.label}
</span> </Text>
</div> </Stack>
); {!isLast && (
})} <Box
</div> style={{
</div> flex: 1,
height: 2,
marginInline: 8,
marginBottom: 20,
borderRadius: 2,
background: isComplete ? BRAND_GREEN : "var(--mantine-color-gray-2)",
}}
/>
)}
</Group>
</Box>
);
})}
</Group>
<div <Paper
className={cn( radius="md"
"rounded-xl border px-5 py-4 backdrop-blur-sm", withBorder
isTerminal p="md"
? "border-destructive/20 bg-destructive/5" style={{
: bookingGlass.activeTab, background: isTerminal
)} ? "var(--mantine-color-red-0)"
> : "var(--mantine-color-green-0)",
<h4 borderColor: isTerminal
className={cn( ? "var(--mantine-color-red-2)"
"text-sm font-semibold tracking-tight", : "var(--mantine-color-green-2)",
isTerminal ? titleColor : "text-black", }}
)} >
> <Text size="sm" fw={600} c={isTerminal ? "red.7" : "green.8"}>
{title} {title}
</h4> </Text>
<p className="mt-1.5 text-sm leading-relaxed text-muted-foreground"> <Text size="sm" c="dimmed" mt={4}>
{description} {description}
</p> </Text>
</div> </Paper>
</div> </SectionCard>
</div>
); );
} }

View File

@@ -1,32 +1,29 @@
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { Alert, Text } from "@mantine/core";
import type { BookingNextStep } from "@/types/booking"; import type { BookingNextStep } from "@/types/booking";
import { bookingGlass } from "./booking-ui.styles";
import { cn } from "@/lib/utils";
interface NextStepBannerProps { interface NextStepBannerProps {
nextStep: BookingNextStep; nextStep: BookingNextStep;
className?: string;
} }
export function NextStepBanner({ nextStep, className }: NextStepBannerProps) { export function NextStepBanner({ nextStep }: NextStepBannerProps) {
return ( return (
<div <Alert
className={cn( variant="light"
"flex items-start gap-3 rounded-xl px-4 py-3 text-sm", color="green"
bookingGlass.activeTab, radius="md"
className, icon={<ArrowRight size={16} />}
)} title={
role="status" <Text size="sm" fw={600}>
>
<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, " ")} Next: {nextStep.action.replace(/_/g, " ")}
{nextStep.requiredRole ? ` (${nextStep.requiredRole})` : ""} {nextStep.requiredRole ? ` (${nextStep.requiredRole})` : ""}
</p> </Text>
<p className="text-muted-foreground">{nextStep.description}</p> }
</div> >
</div> <Text size="sm" c="dimmed">
{nextStep.description}
</Text>
</Alert>
); );
} }

View File

@@ -0,0 +1,68 @@
import { CheckCircle, Clock, XCircle } from "lucide-react";
import { Group, Text, Badge, Timeline } from "@mantine/core";
import { SectionCard } from "./SectionCard";
import {
approvalStatusColor,
formatDateTime,
type BookingApprovalStepView,
} from "./booking-detail.styles";
export interface BookingApprovalCardProps {
steps: BookingApprovalStepView[];
approvedCount: number;
}
/** Vertical timeline of the booking's approval chain. */
export function BookingApprovalCard({ steps, approvedCount }: BookingApprovalCardProps) {
return (
<SectionCard
icon={CheckCircle}
title="Approval Workflow"
extra={
<Badge color="green" variant="light" radius="sm">
{approvedCount} / {steps.length} approved
</Badge>
}
>
<Timeline active={approvedCount - 1} bulletSize={26} lineWidth={2} color="green">
{steps.map((step) => (
<Timeline.Item
key={step.id}
color={approvalStatusColor(step.status)}
bullet={
step.status === "APPROVED" ? (
<CheckCircle size={14} />
) : step.status === "REJECTED" ? (
<XCircle size={14} />
) : (
<Clock size={14} />
)
}
title={
<Group gap="sm">
<Text fw={600} size="sm">
{step.requiredRole.replace(/_/g, " ")}
</Text>
<Badge
color={approvalStatusColor(step.status)}
size="xs"
radius="sm"
variant="light"
>
{step.status}
</Badge>
</Group>
}
>
{step.actionedAt && (
<Text size="xs" c="dimmed">
{formatDateTime(step.actionedAt)}
</Text>
)}
</Timeline.Item>
))}
</Timeline>
</SectionCard>
);
}

View File

@@ -0,0 +1,63 @@
import { Package } from "lucide-react";
import { SimpleGrid, Divider, Box, Table, Text } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { SectionCard } from "./SectionCard";
import { MetricTile } from "./MetricTile";
export interface BookingCargoCardProps {
booking: BookingDetail;
}
/** Cargo specs + container manifest table. */
export function BookingCargoCard({ booking }: BookingCargoCardProps) {
const containers = booking.bookingContainers ?? [];
return (
<SectionCard icon={Package} title="Cargo specifications">
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<MetricTile
label="Cargo type"
value={booking.cargoType?.label ?? booking.freightType}
/>
<MetricTile label="Total VGM" value={`${booking.cargoTotalWeightVgm} tons`} />
<MetricTile
label="Hazardous"
value={booking.isHazardous ? "Yes" : "No"}
highlight={booking.isHazardous}
/>
</SimpleGrid>
{containers.length > 0 && (
<>
<Divider my="lg" color="var(--mantine-color-gray-2)" />
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Container type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>VGM / unit</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((c) => (
<Table.Tr key={c.id}>
<Table.Td>
<Text fw={600} size="sm">
{c.containerType?.label ?? c.containerType?.code ?? c.containerTypeId}
</Text>
</Table.Td>
<Table.Td>{c.quantity}</Table.Td>
<Table.Td>{c.vgmPerUnitTons} t</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
</>
)}
</SectionCard>
);
}

View File

@@ -0,0 +1,60 @@
import { Boxes } from "lucide-react";
import { Text, Badge, Box, Table } from "@mantine/core";
import { SectionCard } from "./SectionCard";
import type { BookingContainerView } from "./booking-detail.styles";
export interface BookingContainersCardProps {
containers: BookingContainerView[];
}
export function BookingContainersCard({ containers }: BookingContainersCardProps) {
return (
<SectionCard
icon={Boxes}
title="Containers & Cargo"
extra={
<Badge color="gray" variant="light" radius="sm">
{containers.length} line{containers.length === 1 ? "" : "s"}
</Badge>
}
>
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Container Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>VGM / Unit</Table.Th>
<Table.Th>Total VGM</Table.Th>
<Table.Th>Size</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((container) => (
<Table.Tr key={container.id}>
<Table.Td>
<Text fw={600} size="sm">
{container.containerType?.label}
</Text>
</Table.Td>
<Table.Td>{container.quantity}</Table.Td>
<Table.Td>{container.vgmPerUnitTons} t</Table.Td>
<Table.Td>
<Text fw={600} c="green.7" size="sm">
{(container.quantity * container.vgmPerUnitTons).toFixed(2)} t
</Text>
</Table.Td>
<Table.Td>
<Badge color="gray" variant="light" radius="sm">
{container.containerType?.sizeFt}FT
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
</SectionCard>
);
}

View File

@@ -0,0 +1,27 @@
import { Anchor } from "lucide-react";
import { Code } from "@mantine/core";
import { SectionCard } from "./SectionCard";
export interface BookingContractSummaryCardProps {
summary: string;
}
/** Generated contract terms, shown verbatim. */
export function BookingContractSummaryCard({ summary }: BookingContractSummaryCardProps) {
return (
<SectionCard icon={Anchor} title="Contract summary">
<Code
block
style={{
maxHeight: 256,
overflow: "auto",
whiteSpace: "pre-wrap",
background: "var(--mantine-color-gray-0)",
}}
>
{summary}
</Code>
</SectionCard>
);
}

View File

@@ -0,0 +1,76 @@
import { Building2, Calendar, CheckCircle, Boxes, Truck } from "lucide-react";
import { Paper, Group, Stack, Title, Text, Divider, SimpleGrid } from "@mantine/core";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { detailStyles, formatDate, type BookingDetailView } from "./booking-detail.styles";
export interface BookingDetailHeaderProps {
booking: BookingDetailView;
approvedCount: number;
totalSteps: number;
}
export function BookingDetailHeader({
booking,
approvedCount,
totalSteps,
}: BookingDetailHeaderProps) {
const kpis = [
{ icon: Truck, label: "Trade Direction", value: booking.tradeDirection },
{ icon: Calendar, label: "Scheduled", value: formatDate(booking.scheduledDate) },
{ icon: Boxes, label: "Freight Type", value: booking.freightType },
{
icon: CheckCircle,
label: "Approvals",
value: `${approvedCount} / ${totalSteps} complete`,
},
];
return (
<Paper radius="md" withBorder mt="sm" mb="lg" p="xl" style={detailStyles.card}>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={6}>
<Group gap="sm" align="center">
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
{booking.reference}
</Title>
<BookingStatusBadge status={booking.status} />
</Group>
<Group gap="xs">
<Building2 size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
{booking.company?.companyName}
</Text>
<Text size="sm" c="dimmed">
</Text>
<Text size="sm" c="dimmed">
Created {formatDate(booking.createdAt)}
</Text>
</Group>
</Stack>
<BookingPriorityBadge score={booking.priorityScore} />
</Group>
<Divider my="lg" color="var(--mantine-color-gray-2)" />
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="xl">
{kpis.map((kpi) => (
<Group key={kpi.label} gap="sm" wrap="nowrap" align="center">
<kpi.icon size={18} color="var(--mantine-color-gray-5)" />
<Stack gap={2}>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
{kpi.label}
</Text>
<Text size="sm" fw={600}>
{kpi.value}
</Text>
</Stack>
</Group>
))}
</SimpleGrid>
</Paper>
);
}

View File

@@ -0,0 +1,37 @@
import { ArrowLeft, Download, CheckCircle } from "lucide-react";
import { Group, Button } from "@mantine/core";
export interface BookingDetailToolbarProps {
onBack: () => void;
onExport?: () => void;
onAction?: () => void;
}
/** Top action bar for the booking detail page. */
export function BookingDetailToolbar({
onBack,
onExport,
onAction,
}: BookingDetailToolbarProps) {
return (
<Group justify="space-between" mb="md">
<Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={18} />}
onClick={onBack}
fw={600}
>
Back
</Button>
<Group gap="sm">
<Button variant="default" leftSection={<Download size={16} />} onClick={onExport}>
Export
</Button>
<Button color="green" leftSection={<CheckCircle size={16} />} onClick={onAction}>
Take Action
</Button>
</Group>
</Group>
);
}

View File

@@ -0,0 +1,69 @@
import { FileText, Download } from "lucide-react";
import { Group, Stack, Text, Badge, ThemeIcon, ActionIcon } from "@mantine/core";
import { SectionCard } from "./SectionCard";
import { detailStyles, type BookingFileView } from "./booking-detail.styles";
export interface BookingDocumentsCardProps {
files: BookingFileView[];
onDownload?: (file: BookingFileView) => void;
}
/** List of attached documents with per-file download actions. */
export function BookingDocumentsCard({ files, onDownload }: BookingDocumentsCardProps) {
return (
<SectionCard
icon={FileText}
title="Documents"
extra={
<Badge color="gray" variant="light" radius="sm">
{files.length}
</Badge>
}
>
{files.length === 0 ? (
<Text size="sm" c="dimmed">
No documents attached.
</Text>
) : (
<Stack gap="xs">
{files.map((file) => (
<Group
key={file.id}
justify="space-between"
wrap="nowrap"
p="xs"
style={detailStyles.fileRow}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--mantine-color-gray-0)";
e.currentTarget.style.borderColor = "var(--mantine-color-green-3)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)";
}}
>
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={32} radius="md" variant="light" color="red">
<FileText size={16} />
</ThemeIcon>
<Text size="sm" fw={500} truncate>
{file.name}
</Text>
</Group>
<ActionIcon
variant="subtle"
color="gray"
radius="md"
onClick={() => onDownload?.(file)}
aria-label={`Download ${file.name}`}
>
<Download size={16} />
</ActionIcon>
</Group>
))}
</Stack>
)}
</SectionCard>
);
}

View File

@@ -0,0 +1,57 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Hash, Package, Ship, Weight, Clock } from "lucide-react";
import { Group, Stack, Text, Divider } from "@mantine/core";
import { SectionCard } from "./SectionCard";
import { formatDate, type BookingDetailView } from "./booking-detail.styles";
interface FactRowProps {
icon: LucideIcon;
label: string;
value: ReactNode;
}
function FactRow({ icon: Icon, label, value }: FactRowProps) {
return (
<Group justify="space-between" wrap="nowrap" py={6}>
<Group gap="xs" wrap="nowrap">
<Icon size={15} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Text size="sm" fw={600} ta="right">
{value}
</Text>
</Group>
);
}
export interface BookingFactsCardProps {
booking: BookingDetailView;
}
/** Key/value summary of the booking's reference data. */
export function BookingFactsCard({ booking }: BookingFactsCardProps) {
const facts: FactRowProps[] = [
{ icon: Hash, label: "PNR Code", value: booking.pnrCode || "—" },
{ icon: Package, label: "Cargo Type", value: booking.cargoType?.label ?? "—" },
{ icon: Ship, label: "Shipping Line", value: booking.shippingLine?.label ?? "—" },
{ icon: Weight, label: "VGM Weight", value: `${booking.cargoTotalWeightVgm} tons` },
{ icon: Clock, label: "Last Updated", value: formatDate(booking.updatedAt) },
];
return (
<SectionCard icon={Hash} title="Booking Details">
<Stack gap={0}>
{facts.map((fact, index) => (
<div key={fact.label}>
{index > 0 && <Divider />}
<FactRow {...fact} />
</div>
))}
</Stack>
</SectionCard>
);
}

View File

@@ -0,0 +1,100 @@
import { Check } from "lucide-react";
import { Paper, Group, Stack, Text, Box } from "@mantine/core";
import {
WORKFLOW_STAGES,
getWorkflowStageIndex,
} from "@/features/bookings/booking-status.config";
import { detailStyles, BRAND_GREEN } from "./booking-detail.styles";
export interface BookingLifecycleStepperProps {
status: string;
}
/** Horizontal lifecycle tracker showing how far the booking has progressed. */
export function BookingLifecycleStepper({ status }: BookingLifecycleStepperProps) {
const currentStage = getWorkflowStageIndex(status);
return (
<Paper radius="md" withBorder p="xl" mb="lg" style={detailStyles.card}>
<Group gap={0} wrap="nowrap" align="flex-start">
{WORKFLOW_STAGES.map((stage, index) => {
const isComplete = currentStage >= 0 && index < currentStage;
const isActive = index === currentStage;
const isLast = index === WORKFLOW_STAGES.length - 1;
const circleBg = isComplete
? BRAND_GREEN
: isActive
? "white"
: "var(--mantine-color-gray-1)";
const circleBorder = isActive
? `2px solid ${BRAND_GREEN}`
: isComplete
? "2px solid transparent"
: "2px solid var(--mantine-color-gray-2)";
return (
<Box key={stage.label} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: 0 }}>
<Group gap={0} wrap="nowrap" align="center">
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "32px",
height: "32px",
borderRadius: "50%",
background: circleBg,
border: circleBorder,
color: isComplete
? "white"
: isActive
? "var(--mantine-color-green-7)"
: "var(--mantine-color-gray-5)",
transition: "all 0.2s ease",
}}
>
{isComplete ? (
<Check size={16} strokeWidth={3} />
) : (
<Text size="xs" fw={700}>
{index + 1}
</Text>
)}
</Box>
<Text
size="xs"
fw={isActive ? 600 : 500}
c={isActive ? "green.7" : isComplete ? "dark" : "dimmed"}
ta="center"
style={{ whiteSpace: "nowrap" }}
>
{stage.label}
</Text>
</Stack>
{!isLast && (
<Box
style={{
flex: 1,
height: "2px",
marginInline: "8px",
marginBottom: "20px",
borderRadius: "2px",
background: isComplete
? BRAND_GREEN
: "var(--mantine-color-gray-2)",
}}
/>
)}
</Group>
</Box>
);
})}
</Group>
</Paper>
);
}

View File

@@ -0,0 +1,31 @@
import { Truck } from "lucide-react";
import { SimpleGrid } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { SectionCard } from "./SectionCard";
import { MetricTile } from "./MetricTile";
export interface BookingMileServicesCardProps {
booking: BookingDetail;
}
/** First / last mile addresses. Renders nothing when neither is present. */
export function BookingMileServicesCard({ booking }: BookingMileServicesCardProps) {
if (!booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress) {
return null;
}
return (
<SectionCard icon={Truck} title="Mile services">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
{booking.firstMilePickupAddress && (
<MetricTile label="First mile pickup" value={booking.firstMilePickupAddress} />
)}
{booking.lastMileDeliveryAddress && (
<MetricTile label="Last mile delivery" value={booking.lastMileDeliveryAddress} />
)}
</SimpleGrid>
</SectionCard>
);
}

View File

@@ -0,0 +1,43 @@
import { Paper, Stack, Group, Text, Title, Badge } from "@mantine/core";
import { detailStyles } from "./booking-detail.styles";
export interface BookingPaymentCardProps {
totalAmount: number;
currency: string;
paymentStatus: string;
}
/** Key-figure card: total amount + payment status. Flat, lightly tinted. */
export function BookingPaymentCard({
totalAmount,
currency,
paymentStatus,
}: BookingPaymentCardProps) {
return (
<Paper radius="md" withBorder p="xl" style={detailStyles.highlightCard}>
<Stack gap={6}>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
Total Amount
</Text>
<Group align="flex-end" gap="xs">
<Title order={1} fw={700} c="green.9" style={{ letterSpacing: "-1px" }}>
{totalAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</Title>
<Text fw={600} c="green.7" mb={6}>
{currency}
</Text>
</Group>
<Badge
color={paymentStatus === "PAID" ? "green" : "yellow"}
variant="light"
radius="sm"
mt="xs"
w="fit-content"
>
{paymentStatus}
</Badge>
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,111 @@
import { Building2, Calendar, Clock, RefreshCw, ArrowLeft } from "lucide-react";
import { Paper, Group, Stack, Title, Text, Button, Box } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
import { detailStyles, formatDate } from "./booking-detail.styles";
export interface BookingRequestHeroProps {
booking: BookingDetail;
customerLabel: string;
onBack: () => void;
onRefresh: () => void;
isFetching?: boolean;
}
/** Top hero for the request detail page: identity, status, next step, total value. */
export function BookingRequestHero({
booking,
customerLabel,
onBack,
onRefresh,
isFetching,
}: BookingRequestHeroProps) {
const amount = Number(booking.totalAmount);
return (
<Paper radius="md" withBorder p="xl" style={detailStyles.card}>
<Button
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<ArrowLeft size={16} />}
onClick={onBack}
mb="md"
ml={-8}
fw={600}
>
Back to list
</Button>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lts="0.06em">
Booking reference
</Text>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
{booking.reference}
</Title>
<BookingStatusBadge status={booking.status} />
<BookingPriorityBadge score={booking.priorityScore} />
</Group>
{booking.nextStep && (
<Box maw={520}>
<NextStepBanner nextStep={booking.nextStep} />
</Box>
)}
<Group gap="lg" mt={4}>
<Group gap={6} wrap="nowrap">
<Building2 size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={500}>
{customerLabel}
</Text>
</Group>
<Group gap={6} wrap="nowrap">
<Calendar size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
Scheduled {booking.scheduledDate}
</Text>
</Group>
<Group gap={6} wrap="nowrap">
<Clock size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
Created {formatDate(booking.createdAt)}
</Text>
</Group>
</Group>
</Stack>
<Stack gap="sm" align="flex-end">
<Paper radius="md" withBorder p="md" miw={200} style={detailStyles.highlightCard}>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em" ta="right">
Total value
</Text>
<Text size="xl" fw={700} c="green.9" ta="right" mt={4} style={{ fontVariantNumeric: "tabular-nums" }}>
{booking.paymentCurrency}{" "}
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</Text>
<Text size="xs" c="dimmed" ta="right" mt={2}>
{booking.paymentStatus}
</Text>
</Paper>
<Button
variant="default"
size="sm"
leftSection={<RefreshCw size={15} />}
loading={isFetching}
onClick={onRefresh}
>
Refresh
</Button>
</Stack>
</Group>
</Paper>
);
}

View File

@@ -0,0 +1,49 @@
import { FileText, MessageSquare } from "lucide-react";
import { Group, Stack, Text, Badge, ThemeIcon } from "@mantine/core";
import { SectionCard } from "./SectionCard";
import { formatDateTime, type BookingReviewNoteView } from "./booking-detail.styles";
export interface BookingReviewNotesCardProps {
notes: BookingReviewNoteView[];
}
/** Chronological list of reviewer / compliance notes. */
export function BookingReviewNotesCard({ notes }: BookingReviewNotesCardProps) {
if (notes.length === 0) {
return (
<SectionCard icon={MessageSquare} title="Review Notes">
<Text size="sm" c="dimmed">
No review notes have been added yet.
</Text>
</SectionCard>
);
}
return (
<SectionCard icon={MessageSquare} title="Review Notes">
<Stack gap="md">
{notes.map((note) => (
<Group key={note.id} align="flex-start" gap="md" wrap="nowrap">
<ThemeIcon size={34} radius="xl" variant="light" color="green">
<FileText size={16} />
</ThemeIcon>
<Stack gap={2} style={{ flex: 1 }}>
<Group justify="space-between">
<Badge color="green" variant="light" size="sm" radius="sm">
{note.type}
</Badge>
<Text size="xs" c="dimmed">
{formatDateTime(note.createdAt)}
</Text>
</Group>
<Text size="sm" style={{ lineHeight: 1.5 }}>
{note.note}
</Text>
</Stack>
</Group>
))}
</Stack>
</SectionCard>
);
}

View File

@@ -0,0 +1,69 @@
import { MapPin } from "lucide-react";
import { Group, Stack, Text, Box } from "@mantine/core";
import { SectionCard } from "./SectionCard";
import { detailStyles, type BookingDetailView } from "./booking-detail.styles";
export interface BookingRouteCardProps {
booking: BookingDetailView;
}
export function BookingRouteCard({ booking }: BookingRouteCardProps) {
return (
<SectionCard icon={MapPin} title="Shipment Route">
<Group justify="space-between" align="center" wrap="nowrap" gap="xl">
{/* Origin */}
<Stack gap={2} style={{ flex: 1 }}>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
Origin
</Text>
<Text fw={600}>{booking.originYard?.label}</Text>
<Text size="xs" c="dimmed">
{booking.originYard?.code}
</Text>
</Stack>
{/* Connector */}
<Box style={{ flex: 1.4 }}>
<Group gap={6} wrap="nowrap" align="center">
<Box
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: "var(--mantine-color-green-6)",
flexShrink: 0,
}}
/>
<Box style={detailStyles.routeLine} />
<Box
style={{
width: 8,
height: 8,
borderRadius: "50%",
border: "2px solid var(--mantine-color-gray-4)",
flexShrink: 0,
}}
/>
</Group>
<Text size="xs" c="dimmed" ta="center" mt={6}>
{booking.shippingLine?.label} · {booking.serviceType?.label}
</Text>
</Box>
{/* Destination */}
<Stack gap={2} style={{ flex: 1 }} align="flex-end">
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
Destination
</Text>
<Text fw={600} ta="right">
{booking.destinationYard?.label}
</Text>
<Text size="xs" c="dimmed">
{booking.destinationYard?.code}
</Text>
</Stack>
</Group>
</SectionCard>
);
}

View File

@@ -0,0 +1,101 @@
import { Train, MapPin, ArrowRight } from "lucide-react";
import { Group, Stack, Text, Badge, Box, SimpleGrid } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { SectionCard } from "./SectionCard";
import { MetricTile } from "./MetricTile";
export interface BookingRouteServiceCardProps {
booking: BookingDetail;
originLabel: string;
destinationLabel: string;
}
function Endpoint({
label,
station,
align = "left",
}: {
label: string;
station: string;
align?: "left" | "right";
}) {
return (
<Stack gap={2} style={{ flex: 1, minWidth: 0 }} align={align === "right" ? "flex-end" : "flex-start"}>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
{label}
</Text>
<Group gap={6} wrap="nowrap">
<MapPin size={15} color="var(--mantine-color-green-6)" />
<Text fw={600} truncate>
{station}
</Text>
</Group>
</Stack>
);
}
export function BookingRouteServiceCard({
booking,
originLabel,
destinationLabel,
}: BookingRouteServiceCardProps) {
const serviceLabel =
booking.serviceType?.label ?? booking.serviceType?.code ?? "Rail service";
const metrics = [
{ label: "Trade direction", value: booking.tradeDirection },
{ label: "Freight type", value: booking.freightType },
{ label: "Equipment return", value: booking.equipmentReturn ?? "—" },
...(booking.shippingLine
? [
{
label: "Shipping line",
value:
booking.shippingLine.label ??
booking.shippingLine.name ??
booking.shippingLine.code ??
"—",
},
]
: []),
];
return (
<SectionCard icon={Train} title="Route & service">
<Group justify="space-between" align="center" wrap="nowrap" gap="md">
<Endpoint label="Origin" station={originLabel} />
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 36,
height: 36,
borderRadius: "50%",
background: "var(--mantine-color-green-0)",
border: "1px solid var(--mantine-color-green-2)",
}}
>
<Train size={18} color="var(--mantine-color-green-7)" />
</Box>
<Badge variant="light" color="gray" size="sm" radius="sm" tt="uppercase">
{serviceLabel}
</Badge>
</Stack>
<Group gap={6} wrap="nowrap" style={{ flex: 1, justifyContent: "flex-end" }}>
<ArrowRight size={16} color="var(--mantine-color-gray-4)" style={{ flexShrink: 0 }} />
<Endpoint label="Destination" station={destinationLabel} align="right" />
</Group>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="sm" mt="lg">
{metrics.map((m) => (
<MetricTile key={m.label} label={m.label} value={m.value} />
))}
</SimpleGrid>
</SectionCard>
);
}

View File

@@ -0,0 +1,32 @@
import { Paper, Text } from "@mantine/core";
export interface MetricTileProps {
label: string;
value: string;
highlight?: boolean;
}
/** Small flat label/value tile used across the detail sections. */
export function MetricTile({ label, value, highlight }: MetricTileProps) {
return (
<Paper
radius="md"
withBorder
px="md"
py="sm"
style={{
background: highlight ? "var(--mantine-color-yellow-0)" : "var(--mantine-color-gray-0)",
borderColor: highlight
? "var(--mantine-color-yellow-3)"
: "var(--mantine-color-gray-2)",
}}
>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
{label}
</Text>
<Text size="sm" fw={600} mt={4} style={{ lineHeight: 1.4 }}>
{value}
</Text>
</Paper>
);
}

View File

@@ -0,0 +1,32 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Paper, Group, Text, Box } from "@mantine/core";
import { detailStyles } from "./booking-detail.styles";
export interface SectionCardProps {
icon: LucideIcon;
title: string;
extra?: ReactNode;
children: ReactNode;
}
/** Consistent flat card with a minimal icon + title header used by every detail section. */
export function SectionCard({ icon: Icon, title, extra, children }: SectionCardProps) {
return (
<Paper radius="md" withBorder style={detailStyles.card}>
<Group justify="space-between" px="xl" py="md" style={detailStyles.cardHeader}>
<Group gap="sm">
<Icon size={16} color="var(--mantine-color-gray-6)" />
<Text fw={600} size="sm" c="dark">
{title}
</Text>
</Group>
{extra}
</Group>
<Box px="xl" py="lg">
{children}
</Box>
</Paper>
);
}

View File

@@ -0,0 +1,140 @@
import type { CSSProperties } from "react";
/** Single brand accent. Minimal design uses solid green sparingly, no gradients. */
export const BRAND_GREEN = "var(--mantine-color-green-6)";
/** Centralised style tokens for the booking detail page + cards. */
export const detailStyles = {
page: {
background: "var(--mantine-color-gray-0)",
minHeight: "100vh",
} satisfies CSSProperties,
/** Flat white card — thin border, no shadow. */
card: {
background: "white",
borderColor: "var(--mantine-color-gray-2)",
} satisfies CSSProperties,
cardHeader: {
borderBottom: "1px solid var(--mantine-color-gray-2)",
} satisfies CSSProperties,
/** Subtle key-figure card (e.g. payment) — tinted, still flat. */
highlightCard: {
background: "var(--mantine-color-green-0)",
borderColor: "var(--mantine-color-green-2)",
} satisfies CSSProperties,
routeLine: {
flex: 1,
height: "1px",
background: "var(--mantine-color-gray-3)",
} satisfies CSSProperties,
fileRow: {
borderRadius: "8px",
border: "1px solid var(--mantine-color-gray-2)",
transition: "background 0.12s ease, border-color 0.12s ease",
} satisfies CSSProperties,
} as const;
/** Map an approval-step status to a Mantine colour. */
export function approvalStatusColor(status: string): string {
switch (status) {
case "APPROVED":
return "green";
case "PENDING":
return "yellow";
case "REJECTED":
return "red";
default:
return "gray";
}
}
export function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
export function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
// ---- View model -----------------------------------------------------------
export interface BookingNamedRefView {
id: string;
label?: string;
code?: string;
companyName?: string;
name?: string;
}
export interface BookingContainerView {
id: string;
quantity: number;
vgmPerUnitTons: number;
containerType?: {
label?: string;
sizeFt?: number;
isReefer?: boolean;
};
}
export interface BookingApprovalStepView {
id: string;
stepOrder: number;
requiredRole: string;
status: string;
actionedAt?: string | null;
}
export interface BookingReviewNoteView {
id: string;
note: string;
type: string;
createdAt: string;
}
export interface BookingFileView {
id: string;
name: string;
mimeType?: string;
}
export interface BookingDetailView {
id: string;
reference: string;
status: string;
scheduledDate: string;
totalAmount: number;
paymentCurrency: string;
paymentStatus: string;
tradeDirection: string;
freightType: string;
priorityScore: number;
cargoTotalWeightVgm: number;
pnrCode?: string | null;
createdAt: string;
updatedAt: string;
company?: BookingNamedRefView;
originYard?: BookingNamedRefView;
destinationYard?: BookingNamedRefView;
serviceType?: BookingNamedRefView;
cargoType?: BookingNamedRefView;
shippingLine?: BookingNamedRefView;
bookingContainers?: BookingContainerView[];
approvalSteps?: BookingApprovalStepView[];
reviewNotes?: BookingReviewNoteView[];
files?: BookingFileView[];
}

View File

@@ -0,0 +1,18 @@
export * from "./booking-detail.styles";
export * from "./SectionCard";
export * from "./MetricTile";
export * from "./BookingDetailToolbar";
export * from "./BookingDetailHeader";
export * from "./BookingLifecycleStepper";
export * from "./BookingRouteCard";
export * from "./BookingContainersCard";
export * from "./BookingApprovalCard";
export * from "./BookingReviewNotesCard";
export * from "./BookingPaymentCard";
export * from "./BookingFactsCard";
export * from "./BookingDocumentsCard";
export * from "./BookingRequestHero";
export * from "./BookingRouteServiceCard";
export * from "./BookingMileServicesCard";
export * from "./BookingCargoCard";
export * from "./BookingContractSummaryCard";

View File

@@ -9,14 +9,10 @@ import {
Sun, Sun,
User, User,
} from "lucide-react"; } from "lucide-react";
import { Group, Stack, Text, Avatar, Menu, ActionIcon, Badge } from "@mantine/core";
import { cn } from "@/lib/utils";
import type { PageMeta } from "./types"; import type { PageMeta } from "./types";
const iconButtonClass =
"relative inline-flex h-10 w-10 items-center justify-center rounded-xl border border-gray-200 bg-white text-gray-600 shadow-sm transition hover:border-primary/30 hover:bg-gray-50 hover:text-gray-900";
export interface FreightDashboardHeaderProps { export interface FreightDashboardHeaderProps {
pageMeta: PageMeta; pageMeta: PageMeta;
headerRight?: ReactNode; headerRight?: ReactNode;
@@ -77,120 +73,145 @@ const FreightDashboardHeader = ({
}, [isUserMenuOpen]); }, [isUserMenuOpen]);
return ( return (
<header className="flex h-20 shrink-0 items-center justify-between gap-4 px-6"> <header
<div className="min-w-0"> style={{
<h1 className="truncate text-xl font-bold tracking-tight text-foreground"> display: "flex",
height: "80px",
alignItems: "center",
justifyContent: "space-between",
gap: "16px",
padding: "0 24px",
}}
>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="lg" fw={700} truncate>
{pageMeta.title} {pageMeta.title}
</h1> </Text>
<p className="mt-0.5 truncate text-sm text-secondary-foreground"> <Text size="sm" c="dimmed" truncate>
{pageMeta.subtitle} {pageMeta.subtitle}
</p> </Text>
</div> </Stack>
<div className="flex shrink-0 items-center gap-2"> <Group gap="sm" wrap="nowrap">
{enableThemeToggle ? ( {enableThemeToggle && (
<button <ActionIcon
type="button" variant="default"
size={40}
radius="lg"
onClick={onToggleTheme} onClick={onToggleTheme}
aria-label={ style={{
theme === "dark" ? "Switch to light mode" : "Switch to dark mode" background: "var(--mantine-color-gray-1)",
} border: "1px solid var(--mantine-color-gray-2)",
className={iconButtonClass} color: "var(--mantine-color-gray-7)",
}}
> >
{theme === "dark" ? ( {theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
<Sun className="h-5 w-5" /> </ActionIcon>
) : ( )}
<Moon className="h-5 w-5" />
)}
</button>
) : null}
<button <ActionIcon
type="button" variant="default"
aria-label="Change language" size={40}
className={iconButtonClass} radius="lg"
style={{
background: "var(--mantine-color-gray-1)",
border: "1px solid var(--mantine-color-gray-2)",
color: "var(--mantine-color-gray-7)",
}}
> >
<Languages className="h-5 w-5" /> <Languages size={18} />
</button> </ActionIcon>
<button type="button" aria-label="Messages" className={iconButtonClass}> <ActionIcon
<MessageSquare className="h-5 w-5" /> variant="default"
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" /> size={40}
</button> radius="lg"
style={{
<button background: "var(--mantine-color-gray-1)",
type="button" border: "1px solid var(--mantine-color-gray-2)",
aria-label="Notifications" color: "var(--mantine-color-gray-7)",
className={iconButtonClass} position: "relative",
}}
> >
<Bell className="h-5 w-5" /> <MessageSquare size={18} />
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" /> <Badge
</button> size="xs"
color="red"
circle
style={{
position: "absolute",
top: "-3px",
right: "-3px",
}}
/>
</ActionIcon>
<div ref={userMenuRef} className="relative ml-1"> <ActionIcon
<button variant="default"
type="button" size={40}
aria-haspopup="menu" radius="lg"
aria-expanded={isUserMenuOpen} style={{
onClick={() => setIsUserMenuOpen((open) => !open)} background: "var(--mantine-color-gray-1)",
className={cn( border: "1px solid var(--mantine-color-gray-2)",
"flex items-center gap-2 rounded-xl border border-transparent px-2 py-1.5 transition", color: "var(--mantine-color-gray-7)",
isUserMenuOpen position: "relative",
? "border-primary/30 bg-primary/5" }}
: "hover:border-primary/20 hover:bg-gray-50", >
)} <Bell size={18} />
> <Badge
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground"> size="xs"
{initials} color="red"
</div> circle
<ChevronDown style={{
className={cn( position: "absolute",
"hidden h-4 w-4 text-gray-400 transition sm:block", top: "-3px",
isUserMenuOpen && "rotate-180 text-primary", right: "-3px",
)} }}
/> />
</button> </ActionIcon>
{isUserMenuOpen ? ( <Menu position="bottom-end" shadow="md" opened={isUserMenuOpen} onOpen={() => setIsUserMenuOpen(true)} onClose={() => setIsUserMenuOpen(false)}>
<div <Menu.Target>
role="menu" <Group gap="sm" p="xs" style={{ cursor: "pointer", borderRadius: "12px" }}>
className="absolute right-0 top-full z-50 mt-2 w-52 overflow-hidden rounded-xl border border-gray-200 bg-white py-1 shadow-lg" <Avatar name={initials} color="green" size="md" />
> <ChevronDown size={16} style={{ transition: "transform 0.2s", transform: isUserMenuOpen ? "rotate(180deg)" : "rotate(0deg)" }} />
<div className="border-b border-gray-100 px-4 py-3"> </Group>
<p className="text-sm font-semibold text-gray-900"> </Menu.Target>
<Menu.Dropdown>
<Menu.Item disabled>
<Stack gap={0}>
<Text size="sm" fw={600}>
{userName} {userName}
</p> </Text>
{userEmail ? ( {userEmail && (
<p className="text-xs text-gray-500">{userEmail}</p> <Text size="xs" c="dimmed">
) : null} {userEmail}
</div> </Text>
<a )}
href="#profile" </Stack>
role="menuitem" </Menu.Item>
onClick={() => setIsUserMenuOpen(false)} <Menu.Divider />
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 transition hover:bg-gray-50" <Menu.Item
> leftSection={<User size={14} />}
<User className="h-4 w-4" /> onClick={() => setIsUserMenuOpen(false)}
Profile >
</a> Profile
<button </Menu.Item>
type="button" <Menu.Item
role="menuitem" leftSection={<LogOut size={14} />}
onClick={() => { color="red"
setIsUserMenuOpen(false); onClick={() => {
onLogout?.(); setIsUserMenuOpen(false);
}} onLogout?.();
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 transition hover:bg-red-50" }}
> >
<LogOut className="h-4 w-4" /> Logout
Logout </Menu.Item>
</button> </Menu.Dropdown>
</div> </Menu>
) : null}
</div>
{headerRight} {headerRight}
</div> </Group>
</header> </header>
); );
}; };

View File

@@ -1,4 +1,5 @@
import { type ReactNode, useEffect, useState } from "react"; import { type ReactNode, useEffect, useState } from "react";
import { Box, Paper, MantineProvider } from "@mantine/core";
import FreightDashboardHeader from "./FreightDashboardHeader"; import FreightDashboardHeader from "./FreightDashboardHeader";
import FreightSidebar from "./FreightSidebar"; import FreightSidebar from "./FreightSidebar";
@@ -30,9 +31,6 @@ export interface FreightDashboardLayoutProps {
children: ReactNode; children: ReactNode;
} }
const panelClass =
"rounded-lg border border-gray-200/80 bg-white shadow-[0_1px_3px_rgba(15,23,42,0.06)]";
const FreightDashboardLayout = ({ const FreightDashboardLayout = ({
sidebarSections, sidebarSections,
activeHref = "", activeHref = "",
@@ -73,40 +71,79 @@ const FreightDashboardLayout = ({
rel="stylesheet" rel="stylesheet"
/> />
<div <MantineProvider>
className="flex h-[100dvh] overflow-hidden bg-[#eceef2] p-2 antialiased" <Box
style={{ fontFamily: "'Outfit', var(--font-sans)" }} style={{
> display: "flex",
<div className="flex h-full min-h-0 w-full gap-2"> height: "100dvh",
<FreightSidebar overflow: "hidden",
sections={sidebarSections} background: "var(--mantine-color-gray-1)",
activeHref={activeHref} padding: "8px",
onNavigate={onNavigate} fontFamily: "'Outfit', var(--font-sans)",
/> }}
>
<Box style={{ display: "flex", height: "100%", minHeight: 0, width: "100%", gap: "8px" }}>
<FreightSidebar
sections={sidebarSections}
activeHref={activeHref}
onNavigate={onNavigate}
/>
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col gap-2"> <Box
<div className={`shrink-0 ${panelClass}`}> style={{
<FreightDashboardHeader display: "flex",
pageMeta={pageMeta} height: "100%",
headerRight={headerRight} minHeight: 0,
enableThemeToggle={enableThemeToggle} minWidth: 0,
userName={userName} flex: 1,
userEmail={userEmail} flexDirection: "column",
userInitials={userInitials} gap: "8px",
onLogout={onLogout} }}
theme={theme}
onToggleTheme={toggleTheme}
/>
</div>
<main
className={`min-h-0 flex-1 overflow-y-auto overscroll-contain ${panelClass} p-4 md:p-6`}
> >
{children} <Paper
</main> p={0}
</div> radius="lg"
</div> withBorder
</div> style={{
flexShrink: 0,
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
}}
>
<FreightDashboardHeader
pageMeta={pageMeta}
headerRight={headerRight}
enableThemeToggle={enableThemeToggle}
userName={userName}
userEmail={userEmail}
userInitials={userInitials}
onLogout={onLogout}
theme={theme}
onToggleTheme={toggleTheme}
/>
</Paper>
<Paper
p={{ base: 16, md: 24 }}
radius="lg"
withBorder
style={{
minHeight: 0,
flex: 1,
overflowY: "auto",
overscrollBehavior: "contain",
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
}}
>
{children}
</Paper>
</Box>
</Box>
</Box>
</MantineProvider>
</> </>
); );
}; };

View File

@@ -5,14 +5,11 @@ import {
useMemo, useMemo,
useState, useState,
} from "react"; } from "react";
import { ChevronDown, ChevronRight } from "lucide-react"; import { ChevronDown, ChevronRight, Train } from "lucide-react";
import { Stack, Group, Text, Box, UnstyledButton, NavLink } from "@mantine/core";
import { cn } from "@/lib/utils";
import type { SidebarItem, SidebarSection } from "./types"; import type { SidebarItem, SidebarSection } from "./types";
const EDR_LOGO = "/assets/logo.svg";
export interface FreightSidebarProps { export interface FreightSidebarProps {
sections: SidebarSection[]; sections: SidebarSection[];
activeHref?: string; activeHref?: string;
@@ -103,25 +100,6 @@ const FreightSidebar = ({
setExpanded((current) => ({ ...current, [key]: !current[key] })); setExpanded((current) => ({ ...current, [key]: !current[key] }));
}; };
const navLinkClass = (active: boolean, depth: number) =>
cn(
"flex items-center justify-between rounded-md px-3 py-2.5 text-base font-medium leading-snug transition-colors",
active
? "bg-primary text-primary-foreground shadow-sm"
: "text-gray-900 hover:bg-gray-100",
depth > 0 && "text-[15px]",
);
const iconClass = (active: boolean, sectionActive: boolean) =>
cn(
"flex h-5 w-5 shrink-0 items-center justify-center [&_svg]:h-5 [&_svg]:w-5",
active
? "text-primary-foreground"
: sectionActive
? "text-gray-900"
: "text-gray-900",
);
const renderNavBranch = ( const renderNavBranch = (
children: SidebarItem[], children: SidebarItem[],
depth: number, depth: number,
@@ -136,37 +114,37 @@ const FreightSidebar = ({
const groupActive = branchContainsActive(child.children!); const groupActive = branchContainsActive(child.children!);
return ( return (
<div key={key} className="flex flex-col gap-0.5"> <Stack key={key} gap={4}>
<button <UnstyledButton
type="button"
aria-expanded={isOpen}
onClick={() => toggleExpanded(key)} onClick={() => toggleExpanded(key)}
className={cn( style={{
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide transition-colors", background: groupActive ? "var(--mantine-color-green-1)" : "transparent",
groupActive padding: "8px 12px",
? "bg-gray-100 text-gray-900" borderRadius: "8px",
: "text-gray-900 hover:bg-gray-100", width: "100%",
)} cursor: "pointer",
}}
> >
<span className="truncate">{child.label}</span> <Group justify="space-between">
<ChevronDown <Text size="xs" fw={600} c={groupActive ? "green" : "dimmed"} tt="uppercase">
className={cn( {child.label}
"h-4 w-4 shrink-0 text-gray-900 transition-transform", </Text>
isOpen ? "rotate-0" : "-rotate-90", <ChevronDown
)} size={14}
/> style={{
</button> transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
{isOpen ? ( transition: "transform 0.2s",
<div color: groupActive ? "var(--mantine-color-green-6)" : "var(--mantine-color-gray-5)",
className={cn( }}
"flex flex-col gap-0.5 border-l border-gray-200", />
depth === 0 ? "ml-3 pl-2" : "ml-2 pl-2", </Group>
)} </UnstyledButton>
> {isOpen && (
<Stack gap={2} style={{ paddingLeft: "12px", borderLeft: "2px solid var(--mantine-color-green-2)" }}>
{renderNavBranch(child.children!, depth + 1, key)} {renderNavBranch(child.children!, depth + 1, key)}
</div> </Stack>
) : null} )}
</div> </Stack>
); );
} }
@@ -176,24 +154,49 @@ const FreightSidebar = ({
const childActiveHref = isHrefActive(childHref); const childActiveHref = isHrefActive(childHref);
return ( return (
<a <NavLink
key={key} key={key}
component="a"
href={child.href} href={child.href}
onClick={(event) => navigateTo(event, child.href!)} onClick={(e) => navigateTo(e as any, child.href!)}
aria-current={childActiveHref ? "page" : undefined} label={child.label}
className={navLinkClass(childActiveHref, depth)} active={childActiveHref}
> color="green"
<span className="truncate">{child.label}</span> style={{
<ChevronRight borderRadius: "8px",
className={cn( cursor: "pointer",
"h-4 w-4 shrink-0", fontSize: "14px",
childActiveHref ? "text-primary-foreground/80" : "text-gray-900", }}
)} rightSection={<ChevronRight size={16} />}
/> />
</a>
); );
}); });
const renderIconWell = (icon: React.ReactNode, active: boolean) => {
if (!icon) return null;
return (
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "30px",
height: "30px",
borderRadius: "8px",
flexShrink: 0,
background: active
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
: "var(--mantine-color-gray-1)",
color: active ? "white" : "var(--mantine-color-gray-6)",
boxShadow: active ? "0 2px 6px rgba(16, 185, 129, 0.25)" : "none",
transition: "all 0.2s ease",
}}
>
{icon}
</Box>
);
};
const renderTopLevelItem = (item: SidebarItem) => { const renderTopLevelItem = (item: SidebarItem) => {
if (!item.href) return null; if (!item.href) return null;
@@ -211,99 +214,127 @@ const FreightSidebar = ({
const leafActive = isCurrentItem && !hasChildren; const leafActive = isCurrentItem && !hasChildren;
return ( return (
<div key={item.href} className="flex flex-col gap-0.5"> <Stack key={item.href} gap={0}>
<div <NavLink
className={cn( component="a"
"group flex items-center rounded-md transition-colors", href={item.href}
leafActive onClick={(e) => navigateTo(e as any, item.href!)}
? "bg-primary text-primary-foreground shadow-sm" label={item.label}
: isSectionActive || (hasChildren && isCurrentItem) leftSection={renderIconWell(item.icon, isActive)}
? "bg-gray-100 text-gray-900" active={leafActive}
: "text-gray-900 hover:bg-gray-100", color="green"
)} variant="light"
> style={{
<a borderRadius: "10px",
href={item.href} cursor: "pointer",
onClick={(event) => navigateTo(event, item.href!)} fontSize: "14px",
aria-current={isCurrentItem ? "page" : undefined} fontWeight: 500,
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-sm leading-snug" padding: "8px 10px",
> }}
{item.icon ? ( rightSection={
<span className={iconClass(leafActive, isActive)}> hasChildren ? (
{item.icon}
</span>
) : null}
<span className="truncate">{item.label}</span>
</a>
{hasChildren ? (
<button
type="button"
aria-label={`Toggle ${item.label}`}
aria-expanded={isOpen}
onClick={() => toggleExpanded(item.href!)}
className={cn(
"mr-2 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md transition-colors",
leafActive
? "text-primary-foreground hover:bg-white/10"
: "text-gray-900 hover:bg-gray-200/80",
)}
>
<ChevronDown <ChevronDown
className={cn( size={16}
"h-4 w-4 transition-transform", style={{
isOpen ? "rotate-0" : "-rotate-90", transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
)} transition: "transform 0.2s",
}}
onClick={(e) => {
e.preventDefault();
toggleExpanded(item.href!);
}}
/> />
</button> ) : (
) : ( <ChevronRight size={16} />
<span )
className={cn( }
"mr-3 flex h-4 w-4 shrink-0 items-center justify-center", />
leafActive ? "text-primary-foreground/80" : "text-gray-900",
)}
aria-hidden
>
<ChevronRight className="h-4 w-4" />
</span>
)}
</div>
{hasChildren && isOpen ? ( {hasChildren && isOpen && (
<div className="ml-3 flex flex-col gap-1 border-l border-gray-200 pl-2"> <Stack gap={4} style={{ paddingLeft: "16px", borderLeft: "2px solid var(--mantine-color-green-2)" }}>
{renderNavBranch(item.children!, 0, item.href)} {renderNavBranch(item.children!, 0, item.href)}
</div> </Stack>
) : null} )}
</div> </Stack>
); );
}; };
return ( return (
<aside className="flex h-full max-h-full w-[280px] shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"> <Box
<div className="flex shrink-0 items-center gap-2.5 border-b border-gray-100 px-5 py-5"> component="aside"
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto" /> style={{
<span className="text-lg font-semibold tracking-tight text-gray-900"> height: "100%",
EDR Freight maxHeight: "100%",
</span> width: "280px",
</div> flexShrink: 0,
borderRadius: "12px",
border: "1px solid var(--mantine-color-gray-2)",
background: "white",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<Group
gap={12}
px="lg"
py="md"
style={{
borderBottom: "1px solid var(--mantine-color-gray-2)",
flexShrink: 0,
height: "80px",
}}
wrap="nowrap"
>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "44px",
height: "44px",
borderRadius: "12px",
background: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
boxShadow: "0 4px 12px rgba(16, 185, 129, 0.3)",
flexShrink: 0,
}}
>
<Train size={24} color="white" strokeWidth={2} />
</Box>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="md" fw={700} style={{ letterSpacing: "-0.3px", lineHeight: 1.2 }}>
EDR Freight
</Text>
<Text size="xs" c="dimmed" fw={500} style={{ letterSpacing: "0.3px" }}>
Backoffice
</Text>
</Stack>
</Group>
<nav className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto overscroll-contain px-3 py-4"> <Stack
component="nav"
gap="lg"
p="md"
style={{
flex: 1,
minHeight: 0,
overflowY: "auto",
overscrollBehavior: "contain",
}}
>
{sections.map((section) => ( {sections.map((section) => (
<div key={section.title} className="flex flex-col gap-1"> <Stack key={section.title} gap={8}>
<p <Text size="xs" fw={600} c="dimmed" tt="uppercase" style={{ letterSpacing: "0.5px", paddingLeft: "8px" }}>
className={cn(
"px-3 pb-1 text-xs font-semibold uppercase tracking-wide",
// Use a very light gray for ALL section titles, not just when mutedTitle is specified
"text-sidebar-secondary-foreground",
)}
>
{section.title} {section.title}
</p> </Text>
{section.items.map((item) => renderTopLevelItem(item))} <Stack gap={2}>
</div> {section.items.map((item) => renderTopLevelItem(item))}
</Stack>
</Stack>
))} ))}
</nav> </Stack>
</aside> </Box>
); );
}; };

View File

@@ -1,11 +1,155 @@
import FeaturePlaceholder from "@/components/FeaturePlaceholder"; import { useParams, useNavigate } from "react-router-dom";
import { Container, Stack, Grid } from "@mantine/core";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import {
detailStyles,
type BookingDetailView,
BookingDetailToolbar,
BookingDetailHeader,
BookingLifecycleStepper,
BookingRouteCard,
BookingContainersCard,
BookingApprovalCard,
BookingReviewNotesCard,
BookingPaymentCard,
BookingFactsCard,
BookingDocumentsCard,
} from "@/components/bookings/detail";
const BookingDetailPage = () => { const BookingDetailPage = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
// Mock data - replace with actual API call
const booking: BookingDetailView = {
id: id || "a61955b7-af21-4664-84d3-7e4e66293b6f",
reference: "BKG-2026-001456",
status: "IN_TRANSIT",
scheduledDate: "2026-06-15",
totalAmount: 15750.5,
paymentCurrency: "USD",
paymentStatus: "PAID",
tradeDirection: "IMPORT",
freightType: "CONTAINER",
priorityScore: 650,
company: {
id: "1",
companyName: "Global Logistics Inc.",
name: "Global Logistics Inc.",
},
originYard: { id: "1", label: "Port of Shanghai", code: "PVG" },
destinationYard: { id: "2", label: "Port of Addis Ababa", code: "AAA" },
serviceType: { id: "1", label: "Container Import Service", code: "CIS" },
cargoType: { id: "1", label: "Electronics", code: "ELEC" },
shippingLine: { id: "1", label: "Maersk Line", code: "MAE" },
cargoTotalWeightVgm: 22.5,
pnrCode: "PNR-2026-001456",
createdAt: "2026-06-05T10:30:00Z",
updatedAt: "2026-06-06T14:20:00Z",
bookingContainers: [
{
id: "1",
quantity: 2,
vgmPerUnitTons: 11.25,
containerType: { label: "20FT Standard", sizeFt: 20, isReefer: false },
},
],
approvalSteps: [
{
id: "1",
stepOrder: 1,
requiredRole: "LINE_STAFF",
status: "APPROVED",
actionedAt: "2026-06-05T11:00:00Z",
},
// {
// id: "2",
// stepOrder: 2,
// requiredRole: "DIRECTOR",
// status: "APPROVED",
// actionedAt: "2026-06-05T13:30:00Z",
// },
{
id: "3",
stepOrder: 3,
requiredRole: "CEO",
status: "APPROVED",
actionedAt: "2026-06-05T15:45:00Z",
},
],
reviewNotes: [
{
id: "1",
note: "Cargo declaration verified against shipping documents.",
type: "VERIFICATION",
createdAt: "2026-06-05T11:15:00Z",
},
{
id: "2",
note: "VGM documentation received and processed.",
type: "COMPLIANCE",
createdAt: "2026-06-05T12:00:00Z",
},
],
files: [
{ id: "1", name: "Bill_of_Lading.pdf", mimeType: "application/pdf" },
{ id: "2", name: "VGM_Certificate.pdf", mimeType: "application/pdf" },
{ id: "3", name: "Commercial_Invoice.pdf", mimeType: "application/pdf" },
],
};
const approvalSteps = booking.approvalSteps ?? [];
const approvedCount = approvalSteps.filter((s) => s.status === "APPROVED").length;
const totalSteps = approvalSteps.length;
return ( return (
<FeaturePlaceholder <div style={detailStyles.page}>
title="Booking Detail" <Container size="xxl" py="lg">
description="Inspect booking metadata, operational notes, and fulfillment progress for internal teams." <BookingDetailToolbar onBack={() => navigate(-1)} />
/>
<Breadcrumbs
items={[
{ label: "Operations" },
{ label: "Booking requests", href: "/dashboard/booking-requests" },
{ label: booking.reference },
]}
/>
{/*
<BookingDetailHeader
booking={booking}
approvedCount={approvedCount}
totalSteps={totalSteps}
/> */}
<BookingLifecycleStepper status={booking.status} />
<Grid gutter="lg">
{/* LEFT — primary content */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
<BookingRouteCard booking={booking} />
<BookingContainersCard containers={booking.bookingContainers ?? []} />
<BookingApprovalCard steps={approvalSteps} approvedCount={approvedCount} />
<BookingReviewNotesCard notes={booking.reviewNotes ?? []} />
</Stack>
</Grid.Col>
{/* RIGHT — summary sidebar */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="lg">
<BookingPaymentCard
totalAmount={booking.totalAmount}
currency={booking.paymentCurrency}
paymentStatus={booking.paymentStatus}
/>
<BookingFactsCard booking={booking} />
<BookingDocumentsCard files={booking.files ?? []} />
</Stack>
</Grid.Col>
</Grid>
</Container>
</div>
); );
}; };

View File

@@ -1,111 +1,110 @@
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, FileSignature, Package } from "lucide-react";
import { import {
Anchor, Container,
ArrowLeft, Stack,
ArrowRight, Grid,
Building2, Center,
Calendar, Loader,
Clock, Text,
Loader2, Paper,
MapPin, Button,
Package, Box,
FileSignature, } from "@mantine/core";
RefreshCw,
Train,
Truck,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs"; import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard"; import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar"; import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary"; 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 { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
import { import {
bookingGlass, detailStyles,
bookingSurface, BookingRequestHero,
} from "@/components/bookings/booking-ui.styles"; BookingRouteServiceCard,
BookingMileServicesCard,
BookingCargoCard,
BookingContractSummaryCard,
} from "@/components/bookings/detail";
import { getStatusMeta } from "@/features/bookings/booking-status.config"; import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
useBookingDetail,
useBookingMutations,
} from "@/hooks/bookings/useBookings";
import type { BookingDetail } from "@/types/booking";
import { cn } from "@/lib/utils";
import {
Badge,
Button,
Separator,
} from "@edr/ui-common";
export default function BookingRequestDetailPage() { export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: booking, isLoading, isError, refetch, isFetching } = const { data: booking, isLoading, isError, refetch, isFetching } = useBookingDetail(id);
useBookingDetail(id);
const mutations = useBookingMutations(id ?? ""); const mutations = useBookingMutations(id ?? "");
if (isLoading) { if (isLoading) {
return ( return (
<div className={bookingSurface.page}> <Box style={detailStyles.page}>
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 p-8"> <Center mih="60vh">
<Loader2 className="size-10 animate-spin text-muted-foreground" /> <Stack align="center" gap="md">
<p className="text-sm font-medium text-muted-foreground"> <Loader color="green" />
Loading booking <Text size="sm" c="dimmed" fw={500}>
</p> Loading booking
</div> </Text>
</div> </Stack>
</Center>
</Box>
); );
} }
if (isError || !booking) { if (isError || !booking) {
return ( return (
<div className={bookingSurface.page}> <Box style={detailStyles.page}>
<div className={bookingSurface.pageInner}> <Container size="sm" py="xl">
<div <Paper radius="md" withBorder p="xl" ta="center" style={detailStyles.card}>
className={cn( <Center>
bookingSurface.sectionCard, <Box
"mx-auto max-w-md p-12 text-center", style={{
)} display: "flex",
> alignItems: "center",
<div justifyContent: "center",
className={cn( width: 64,
"mx-auto flex size-16 items-center justify-center rounded-2xl", height: 64,
bookingGlass.iconWellGreen, borderRadius: 16,
)} background: "var(--mantine-color-green-0)",
> color: "var(--mantine-color-green-7)",
<Package className="size-8" /> }}
</div> >
<h1 className="mt-6 text-xl font-bold text-foreground"> <Package size={32} />
</Box>
</Center>
<Text fw={700} size="lg" mt="lg">
Booking not found Booking not found
</h1> </Text>
<p className="mt-2 text-sm text-muted-foreground"> <Text size="sm" c="dimmed" mt={4}>
This request may have been removed or the link is invalid. This request may have been removed or the link is invalid.
</p> </Text>
<Button <Button
className="mt-6 gap-2" variant="default"
variant="outline" mt="lg"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/dashboard/booking-requests")} onClick={() => navigate("/dashboard/booking-requests")}
> >
<ArrowLeft className="size-4" />
Back to booking requests Back to booking requests
</Button> </Button>
</div> </Paper>
</div> </Container>
</div> </Box>
); );
} }
const row = toBookingListRow(booking); const row = toBookingListRow(booking);
const statusMeta = getStatusMeta(booking.status); const statusMeta = getStatusMeta(booking.status);
const amount = Number(booking.totalAmount); const showContractButton = [
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
].includes(booking.status);
const showApprovalCard =
booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE";
return ( return (
<div className={bookingSurface.page}> <Box style={detailStyles.page}>
<div className={bookingSurface.pageInner}> <Container size="xxl" py="lg">
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: "Booking requests", href: "/dashboard/booking-requests" }, { label: "Booking requests", href: "/dashboard/booking-requests" },
@@ -113,381 +112,66 @@ export default function BookingRequestDetailPage() {
]} ]}
/> />
<div className={bookingSurface.detailHero}> <Stack gap="lg" mt="sm">
<div className={bookingSurface.heroGlow} /> <BookingRequestHero
<div className={bookingSurface.heroSheen} /> booking={booking}
<div className="relative p-6 sm:p-8"> customerLabel={row.customerLabel}
<div className="mb-4"> onBack={() => navigate("/dashboard/booking-requests")}
<Button onRefresh={() => refetch()}
variant="ghost" isFetching={isFetching}
size="sm" />
className="-ml-2 gap-2 text-muted-foreground hover:text-foreground"
onClick={() => navigate("/dashboard/booking-requests")} <BookingWorkflowStepper
> status={booking.status}
<ArrowLeft className="size-4" /> title={statusMeta.title}
Back to list description={statusMeta.description}
</Button> titleColor={statusMeta.color}
</div> />
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div className="flex gap-4"> <Grid gutter="lg">
<div {/* LEFT — primary content */}
className={cn( <Grid.Col span={{ base: 12, lg: 8 }}>
"flex size-16 shrink-0 items-center justify-center rounded-2xl", <Stack gap="lg">
bookingGlass.iconWellGreen, <BookingRouteServiceCard
booking={booking}
originLabel={row.originLabel}
destinationLabel={row.destinationLabel}
/>
<BookingMileServicesCard booking={booking} />
<BookingCargoCard booking={booking} />
{booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} />
)}
</Stack>
</Grid.Col>
{/* RIGHT — sticky action / summary rail */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
<BookingPricingSummary booking={booking} />
<BookingActionsToolbar booking={booking} mutations={mutations} />
{showContractButton && (
<Button
fullWidth
color="green"
leftSection={<FileSignature size={16} />}
onClick={() =>
navigate(`/dashboard/booking-requests/${booking.id}/contract`)
}
>
View & sign contract
</Button>
)} )}
> {showApprovalCard && (
<Package className="size-7" strokeWidth={1.75} /> <ApprovalStepsCard booking={booking} mutations={mutations} />
</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-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"> </Stack>
<span className="inline-flex items-center gap-1.5 font-medium text-foreground"> </Box>
<Building2 className="size-4 opacity-70" /> </Grid.Col>
{row.customerLabel} </Grid>
</span> </Stack>
<span className="inline-flex items-center gap-1.5"> </Container>
<Calendar className="size-4 opacity-70" /> </Box>
Scheduled {booking.scheduledDate}
</span>
<span className="inline-flex items-center gap-1.5">
<Clock className="size-4 opacity-70" />
Created{" "}
{new Date(booking.createdAt).toLocaleDateString(undefined, {
dateStyle: "medium",
})}
</span>
</div>
</div>
</div>
<div className="flex flex-col items-stretch gap-3 sm:items-end">
<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-semibold tabular-nums text-foreground">
{booking.paymentCurrency}{" "}
{amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
})}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{booking.paymentStatus}
</p>
</div>
<Button
variant="outline"
size="sm"
className="gap-2 self-end border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
disabled={isFetching}
onClick={() => refetch()}
>
<RefreshCw
className={cn("size-4", isFetching && "animate-spin")}
/>
Refresh
</Button>
</div>
</div>
</div>
</div>
<BookingWorkflowStepper
status={booking.status}
title={statusMeta.title}
description={statusMeta.description}
titleColor={statusMeta.color}
/>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-12 xl:gap-8">
<div className="flex flex-col gap-6 xl:col-span-8">
<RouteCard booking={booking} row={row} />
<MileCard booking={booking} />
<CargoCard booking={booking} />
{booking.contractSummary && (
<SectionShell
icon={<Anchor className="size-4" />}
title="Contract summary"
subtitle="Generated terms"
>
<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>
)}
</div>
<div
className={cn(
"flex flex-col gap-6",
bookingSurface.stickySidebar,
"xl:col-span-4",
)}
>
<BookingPricingSummary booking={booking} />
<BookingActionsToolbar booking={booking} mutations={mutations} />
{["CONTRACT_READY", "SIGNED_CUSTOMER", "FULLY_EXECUTED"].includes(
booking.status,
) && (
<div className={bookingSurface.sectionCard}>
<div className="px-5 py-4">
<Button
className="w-full gap-2 shadow-sm"
variant="default"
onClick={() =>
navigate(`/dashboard/booking-requests/${booking.id}/contract`)
}
>
<FileSignature className="size-4" />
View &amp; sign contract
</Button>
</div>
</div>
)}
{(booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE") && (
<ApprovalStepsCard booking={booking} mutations={mutations} />
)}
</div>
</div>
</div>
</div>
);
}
function SectionShell({
icon,
title,
subtitle,
children,
}: {
icon: React.ReactNode;
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
return (
<div className={bookingSurface.sectionCard}>
<div className={bookingSurface.sectionHeader}>
<div className={bookingSurface.sectionIcon}>{icon}</div>
<div>
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
{subtitle && (
<p className="text-xs text-muted-foreground">{subtitle}</p>
)}
</div>
</div>
<div className={bookingSurface.sectionBody}>{children}</div>
</div>
);
}
function RouteCard({
booking,
row,
}: {
booking: BookingDetail;
row: ReturnType<typeof toBookingListRow>;
}) {
return (
<SectionShell
icon={<Train className="size-4" />}
title="Route & service"
subtitle="Corridor and service level"
>
<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={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="border-border/50 bg-background/50 text-[10px] font-medium uppercase backdrop-blur-sm"
>
{booking.serviceType?.label ??
booking.serviceType?.code ??
"Rail service"}
</Badge>
</div>
<RouteEndpoint label="Destination" station={row.destinationLabel} />
</div>
<div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<MetricTile label="Trade direction" value={booking.tradeDirection} />
<MetricTile label="Freight type" value={booking.freightType} />
<MetricTile
label="Equipment return"
value={booking.equipmentReturn ?? "—"}
/>
{booking.shippingLine && (
<MetricTile
label="Shipping line"
value={
booking.shippingLine.label ??
booking.shippingLine.name ??
booking.shippingLine.code ??
"—"
}
/>
)}
</div>
</SectionShell>
);
}
function MileCard({ booking }: { booking: BookingDetail }) {
if (!booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress) {
return null;
}
return (
<SectionShell
icon={<Truck className="size-4" />}
title="Mile services"
subtitle="First and last mile"
>
<div className="grid gap-4 md:grid-cols-2">
{booking.firstMilePickupAddress && (
<MetricTile
label="First mile pickup"
value={booking.firstMilePickupAddress}
/>
)}
{booking.lastMileDeliveryAddress && (
<MetricTile
label="Last mile delivery"
value={booking.lastMileDeliveryAddress}
/>
)}
</div>
</SectionShell>
);
}
function CargoCard({ booking }: { booking: BookingDetail }) {
const containers = booking.bookingContainers ?? [];
return (
<SectionShell
icon={<Package className="size-4" />}
title="Cargo specifications"
subtitle="Freight and containers"
>
<div className="grid gap-3 sm:grid-cols-3">
<MetricTile
label="Cargo type"
value={booking.cargoType?.label ?? booking.freightType}
/>
<MetricTile
label="Total VGM"
value={`${booking.cargoTotalWeightVgm} tons`}
/>
<MetricTile
label="Hazardous"
value={booking.isHazardous ? "Yes" : "No"}
highlight={booking.isHazardous}
/>
</div>
{containers.length > 0 && (
<>
<Separator className="my-5" />
<div className="overflow-hidden rounded-lg border border-border/50 backdrop-blur-sm">
<table className="w-full text-sm">
<thead>
<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>
</tr>
</thead>
<tbody>
{containers.map((c) => (
<tr
key={c.id}
className="border-b border-border/60 last:border-0"
>
<td className="px-4 py-3 font-medium text-foreground">
{c.containerType?.label ??
c.containerType?.code ??
c.containerTypeId}
</td>
<td className="px-4 py-3 tabular-nums text-muted-foreground">
{c.quantity}
</td>
<td className="px-4 py-3 tabular-nums text-muted-foreground">
{c.vgmPerUnitTons} t
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</SectionShell>
);
}
function RouteEndpoint({
label,
station,
}: {
label: string;
station: string;
}) {
return (
<div className="flex min-w-0 items-center gap-3 md:max-w-[14rem]">
<div className={bookingSurface.sectionIconLg}>
<MapPin className="size-5" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</p>
<p className="truncate text-sm font-semibold text-foreground">{station}</p>
</div>
</div>
);
}
function MetricTile({
label,
value,
highlight,
}: {
label: string;
value: string;
highlight?: boolean;
}) {
return (
<div
className={cn(
bookingSurface.metricTile,
highlight && "border-amber-300/50 bg-amber-50/50 dark:bg-amber-950/20",
)}
>
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</p>
<p className="mt-1.5 text-sm font-medium leading-snug text-foreground">
{value}
</p>
</div>
); );
} }

View File

@@ -14,6 +14,20 @@ import {
User, User,
X, X,
} from "lucide-react"; } from "lucide-react";
import {
Container,
Stack,
Group,
Title,
Text,
Card,
TextInput,
ActionIcon,
Badge as MantineBadge,
Button as MantineButton,
ThemeIcon,
Paper,
} from "@mantine/core";
import Breadcrumbs from "@/components/ui/Breadcrumbs"; import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
@@ -26,12 +40,7 @@ import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell"; import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty"; import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
import { import { bookingTable } from "@/components/bookings/booking-ui.styles";
bookingGlass,
bookingInput,
bookingSurface,
bookingTable,
} from "@/components/bookings/booking-ui.styles";
import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config"; import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { useBookingList, useBookingListSummary } from "@/hooks/bookings/useBookings"; import { useBookingList, useBookingListSummary } from "@/hooks/bookings/useBookings";
@@ -237,168 +246,195 @@ export default function BookingRequestsPage() {
]; ];
return ( return (
<div className={bookingSurface.page}> <div style={{ background: "var(--mantine-color-gray-0)", minHeight: "100vh" }}>
<div className={bookingSurface.pageInner}> <Container size="xxl" py="xl">
<Breadcrumbs items={[{ label: "Operations" }, { label: "Booking requests" }]} /> <Breadcrumbs items={[{ label: "Operations" }, { label: "Booking requests" }]} />
{/*
<div className={bookingSurface.hero}> <Card
<div className={bookingSurface.heroGlow} /> p="lg"
<div className={bookingSurface.heroSheen} /> radius="lg"
<div className="relative flex flex-col gap-6 p-6 sm:flex-row sm:items-center sm:justify-between sm:p-8"> withBorder
<div className="flex items-start gap-4"> mb="xl"
<div style={{
className={cn( background: "white",
"flex size-14 shrink-0 items-center justify-center rounded-2xl", border: "1px solid var(--mantine-color-gray-2)",
bookingGlass.iconWellGreen, boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
)}
>
<Inbox className="size-6" strokeWidth={1.75} />
</div>
<div>
<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">
Track bookings from submission through payment and operations.
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
className="gap-2 border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
disabled={isFetching}
onClick={handleRefresh}
>
<RefreshCw
className={cn("size-4", isFetching && "animate-spin")}
/>
Refresh
</Button>
</div>
</div>
</div>
<BookingStatGrid
items={[
{
label: "In queue",
value: statValue(metrics?.inQueue),
hint: "Total matching filter",
icon: LayoutList,
},
{
label: "On this page",
value: statValue(metrics?.onThisPage),
hint: "Current page",
icon: FileText,
},
{
label: "Needs action",
value: statValue(metrics?.needsAction),
hint: "Submitted or pending approval",
icon: Clock,
accent:
!summaryLoading && (metrics?.needsAction ?? 0) > 0
? "amber"
: "default",
},
{
label: "Urgent",
value: statValue(metrics?.urgent),
hint: "High priority score",
icon: AlertCircle,
accent:
!summaryLoading && (metrics?.urgent ?? 0) > 0 ? "rose" : "default",
},
]}
/>
<BookingStatusTabs
active={activeTab}
onChange={(tab) => {
setActiveTab(tab);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}} }}
counts={tabCounts} >
/> <Group justify="space-between" align="flex-start">
<Group gap="md" align="flex-start">
<div className={bookingSurface.panel}> <ThemeIcon
<div className={bookingSurface.panelToolbar}> size="lg"
<div className="relative min-w-[12rem] flex-1 sm:max-w-sm"> radius="lg"
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /> color="green"
<Input variant="light"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search reference or customer…"
className={bookingInput.search}
/>
{query && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={() => setQuery("")}
aria-label="Clear search"
>
<X className="size-3.5" />
</button>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<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" : ""} <Inbox size={28} />
</span> </ThemeIcon>
</div> <Stack gap={8}>
</div> <Text size="xs" fw={600} c="dimmed" tt="uppercase">
Operations
</Text>
<Title order={1} size="h2">
Booking Requests
</Title>
<Text size="sm" c="dimmed" maw="500px">
Track bookings from submission through payment and operations. Monitor status, prioritize urgent bookings, and manage approvals.
</Text>
</Stack>
</Group>
<MantineButton
variant="light"
color="green"
leftSection={<RefreshCw size={18} />}
disabled={isFetching}
onClick={handleRefresh}
loading={isFetching}
>
Refresh
</MantineButton>
</Group>
</Card> */}
{showEmpty ? ( <div className="mt-6"></div>
<BookingTableEmpty <Stack gap="lg">
isError={isError} <BookingStatGrid
hasSearch={hasSearch} items={[
onRetry={handleRefresh} {
/> label: "In queue",
) : ( value: statValue(metrics?.inQueue),
<div className={bookingSurface.tableWrap}> hint: "Total matching filter",
<DataTable icon: LayoutList,
columns={columns} },
data={rows} {
status={isLoading ? "loading" : isError ? "error" : "success"} label: "On this page",
onRowClick={handleRowClick} value: statValue(metrics?.onThisPage),
pagination={{ hint: "Current page",
pageIndex: pagination.pageIndex, icon: FileText,
pageSize: pagination.pageSize, },
pageCount, {
totalCount: total, label: "Needs action",
value: statValue(metrics?.needsAction),
hint: "Submitted or pending approval",
icon: Clock,
accent:
!summaryLoading && (metrics?.needsAction ?? 0) > 0
? "amber"
: "default",
},
{
label: "Urgent",
value: statValue(metrics?.urgent),
hint: "High priority score",
icon: AlertCircle,
accent:
!summaryLoading && (metrics?.urgent ?? 0) > 0 ? "rose" : "default",
},
]}
/>
<Paper
p="md"
radius="lg"
withBorder
style={{
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
overflowX: "auto",
overflowY: "hidden",
WebkitOverflowScrolling: "touch",
scrollBehavior: "smooth",
}}
>
<div style={{ minWidth: "min-content", display: "inline-block", width: "100%" }}>
<BookingStatusTabs
active={activeTab}
onChange={(tab) => {
setActiveTab(tab);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}} }}
tableOptions={{ counts={tabCounts}
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName={cn(
"border-0 shadow-none",
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
"[&_thead_th]:bg-muted/20 [&_thead_th]:backdrop-blur-sm",
"[&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
"[&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/20",
)}
footer={DataTableFooter}
/> />
</div> </div>
)} </Paper>
</div>
</div> <Card
p="md"
radius="lg"
withBorder
style={{
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Stack gap="md">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search reference or customer…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
{showEmpty ? (
<BookingTableEmpty
isError={isError}
hasSearch={hasSearch}
onRetry={handleRefresh}
/>
) : (
<div style={{ overflowX: "auto" }}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={handleRowClick}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName={cn(
"border-0 shadow-none",
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
"[&_thead_th]:bg-muted/20 [&_thead_th]:backdrop-blur-sm",
"[&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
"[&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/20",
)}
footer={DataTableFooter}
/>
</div>
)}
</Stack>
</Card>
</Stack>
</Container>
</div> </div>
); );
} }