mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 07:22:53 +00:00
ui improvemnt
This commit is contained in:
@@ -18,6 +18,7 @@ async function bootstrap() {
|
||||
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
|
||||
// and any other dev port can call the API with cookies + Authorization.
|
||||
// For production, restrict `origin` to known FQDNs.
|
||||
|
||||
app.enableCors({
|
||||
origin: true, // reflect request origin
|
||||
credentials: true,
|
||||
@@ -52,8 +53,10 @@ async function bootstrap() {
|
||||
SwaggerModule.setup("api/docs", app, document);
|
||||
|
||||
const port = parseInt(process.env.PORT ?? "3001", 10);
|
||||
await app.listen(port, "0.0.0.0");
|
||||
// await app.listen(port)
|
||||
// await app.listen(port, "0.0.0.0");
|
||||
await app.listen(
|
||||
|
||||
port)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[freight-api] listening on port ${port}`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Check, ShieldCheck } from "lucide-react";
|
||||
import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core";
|
||||
|
||||
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
@@ -11,9 +12,7 @@ import {
|
||||
} from "@/features/bookings/booking-actions.config";
|
||||
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||
import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
|
||||
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
|
||||
import { Badge, Button } from "@edr/ui-common";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SectionCard } from "./detail/SectionCard";
|
||||
|
||||
type Mutations = ReturnType<typeof useBookingMutations>;
|
||||
|
||||
@@ -26,23 +25,16 @@ interface ApprovalStepsCardProps {
|
||||
export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) {
|
||||
const { user } = useAuth();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingStep, setPendingStep] = useState<BookingApprovalStep | null>(
|
||||
null,
|
||||
);
|
||||
const [pendingStep, setPendingStep] = useState<BookingApprovalStep | null>(null);
|
||||
|
||||
const steps = useMemo(
|
||||
() =>
|
||||
[...(booking.approvalSteps ?? [])].sort(
|
||||
(a, b) => a.stepOrder - b.stepOrder,
|
||||
),
|
||||
() => [...(booking.approvalSteps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder),
|
||||
[booking.approvalSteps],
|
||||
);
|
||||
|
||||
const nextPending = getNextPendingApprovalStep(steps);
|
||||
const summary = formatApprovalProgress(booking.status, steps);
|
||||
const pendingAction = pendingStep
|
||||
? buildApproveActionForStep(pendingStep)
|
||||
: null;
|
||||
const pendingAction = pendingStep ? buildApproveActionForStep(pendingStep) : null;
|
||||
|
||||
const openApprove = (step: BookingApprovalStep) => {
|
||||
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 (
|
||||
<>
|
||||
<div className={cn(bookingSurface.sectionCard, bookingGlass.activeTab)}>
|
||||
<div className={bookingSurface.sectionHeader}>
|
||||
<div className={bookingSurface.sectionIcon}>
|
||||
<ShieldCheck className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
Approval chain
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{summary.detail ||
|
||||
(nextPending
|
||||
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
|
||||
: steps.length
|
||||
? "All steps complete"
|
||||
: "Accept submission to begin")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<SectionCard
|
||||
icon={ShieldCheck}
|
||||
title="Approval chain"
|
||||
extra={
|
||||
<Badge color="green" variant="light" radius="sm">
|
||||
{steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
{subtitle}
|
||||
</Text>
|
||||
|
||||
<div className="px-5 py-5">
|
||||
{steps.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-border/60 bg-muted/10 px-4 py-6 text-center text-sm text-muted-foreground backdrop-blur-sm">
|
||||
Use{" "}
|
||||
<strong className="font-semibold text-foreground">
|
||||
Accept for approval
|
||||
</strong>{" "}
|
||||
in staff actions to instantiate steps.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{steps.map((step) => (
|
||||
<StepRow
|
||||
key={step.id}
|
||||
step={step}
|
||||
steps={steps}
|
||||
user={user}
|
||||
isNext={nextPending?.id === step.id}
|
||||
isPending={mutations.approveStep.isPending}
|
||||
onApprove={openApprove}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{steps.length === 0 ? (
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
ta="center"
|
||||
py="lg"
|
||||
px="md"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px dashed var(--mantine-color-gray-3)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
>
|
||||
Use <strong>Accept for approval</strong> in staff actions to instantiate steps.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{steps.map((step) => (
|
||||
<StepRow
|
||||
key={step.id}
|
||||
step={step}
|
||||
steps={steps}
|
||||
user={user}
|
||||
isNext={nextPending?.id === step.id}
|
||||
isPending={mutations.approveStep.isPending}
|
||||
onApprove={openApprove}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<BookingConfirmDialog
|
||||
open={confirmOpen}
|
||||
@@ -144,64 +142,74 @@ function StepRow({
|
||||
onApprove: (step: BookingApprovalStep) => void;
|
||||
}) {
|
||||
const canApprove = canActOnApprovalStep(user, step, steps);
|
||||
const statusStyles =
|
||||
const statusColor =
|
||||
step.status === "APPROVED"
|
||||
? "border-emerald-500/25 bg-emerald-500/10 text-black"
|
||||
? "green"
|
||||
: step.status === "REJECTED"
|
||||
? "bg-red-500/10 text-red-800 dark:text-red-300"
|
||||
? "red"
|
||||
: isNext
|
||||
? "border-emerald-500/25 bg-emerald-500/10 text-black"
|
||||
: "bg-muted/40 text-muted-foreground";
|
||||
? "green"
|
||||
: "gray";
|
||||
const highlighted = isNext || step.status === "APPROVED";
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors backdrop-blur-sm",
|
||||
isNext ? bookingGlass.activeTab : "border-border/50 bg-card/60",
|
||||
)}
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
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">
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-lg text-xs font-bold",
|
||||
isNext
|
||||
? cn(bookingGlass.iconWellGreen, "text-black")
|
||||
: "bg-muted/40 text-muted-foreground",
|
||||
)}
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
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}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600}>
|
||||
{step.requiredRole}
|
||||
</p>
|
||||
</Text>
|
||||
{step.remarks && (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{step.remarks}
|
||||
</p>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{canApprove && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 shadow-sm"
|
||||
size="compact-sm"
|
||||
color="green"
|
||||
leftSection={<Check size={14} />}
|
||||
disabled={isPending}
|
||||
onClick={() => onApprove(step)}
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("shrink-0 border text-[9px] uppercase", statusStyles)}
|
||||
>
|
||||
<Badge variant="light" color={statusColor} size="sm" radius="sm" tt="uppercase">
|
||||
{step.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</li>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
import { ChevronRight, ExternalLink, MoreHorizontal } from "lucide-react";
|
||||
import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
|
||||
|
||||
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
||||
import { useBookingActionDialog } from "./useBookingActionDialog";
|
||||
@@ -16,16 +12,6 @@ import {
|
||||
type BookingActionContext,
|
||||
} from "@/features/bookings/booking-actions.config";
|
||||
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 {
|
||||
row: BookingListRow;
|
||||
@@ -39,7 +25,6 @@ interface BookingActionsMenuProps {
|
||||
export function BookingActionsMenu({
|
||||
row,
|
||||
variant = "table",
|
||||
className,
|
||||
onSuppressRowClick,
|
||||
}: BookingActionsMenuProps) {
|
||||
const navigate = useNavigate();
|
||||
@@ -57,184 +42,179 @@ export function BookingActionsMenu({
|
||||
const goToContract = () =>
|
||||
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];
|
||||
|
||||
if (!hasMenu && variant === "table") {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-primary"
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => navigate(`/dashboard/booking-requests/${row.id}`)}
|
||||
aria-label="View booking"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
<ChevronRight size={16} />
|
||||
</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 (
|
||||
<>
|
||||
<div
|
||||
data-stop-row-click
|
||||
className={cn(
|
||||
"flex w-full min-h-[2.5rem] items-center justify-end gap-1",
|
||||
variant === "table" && "opacity-80 transition-opacity group-hover/tr:opacity-100",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{variant === "table" && primary && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="hidden h-8 gap-1.5 px-2.5 shadow-sm lg:inline-flex"
|
||||
disabled={mutations.isPending}
|
||||
onClick={() =>
|
||||
isContractNavAction(primary.id)
|
||||
? goToContract()
|
||||
: flow.openAction(primary)
|
||||
}
|
||||
<Group
|
||||
gap={4}
|
||||
justify="flex-end"
|
||||
wrap="nowrap"
|
||||
data-stop-row-click
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{variant === "table" && primary && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="green"
|
||||
visibleFrom="lg"
|
||||
leftSection={<primary.icon size={14} />}
|
||||
disabled={mutations.isPending}
|
||||
onClick={() => handleAction(primary)}
|
||||
>
|
||||
{primary.shortLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<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" />
|
||||
{primary.shortLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{variant === "toolbar" && actions.length > 0 ? (
|
||||
<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">
|
||||
<MoreHorizontal size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>
|
||||
<Text size="xs" ff="monospace" c="dimmed">
|
||||
{row.reference}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={action.id}
|
||||
className={cn(
|
||||
"gap-2 cursor-pointer",
|
||||
action.variant === "destructive" && "text-red-700 focus:text-red-700",
|
||||
)}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onSuppressRowClick?.();
|
||||
if (isContractNavAction(action.id)) {
|
||||
goToContract();
|
||||
} else {
|
||||
flow.openAction(action);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon className="size-4 opacity-70" />
|
||||
<span>{action.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{actions.length > 0 && <DropdownMenuSeparator />}
|
||||
<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>
|
||||
</Text>
|
||||
</Menu.Label>
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Menu.Item
|
||||
key={action.id}
|
||||
color={action.variant === "destructive" ? "red" : undefined}
|
||||
leftSection={<Icon size={15} />}
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
{action.label}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
{actions.length > 0 && <Menu.Divider />}
|
||||
<Menu.Item
|
||||
leftSection={<ExternalLink size={15} />}
|
||||
onClick={() => {
|
||||
onSuppressRowClick?.();
|
||||
navigate(`/dashboard/booking-requests/${row.id}`);
|
||||
}}
|
||||
>
|
||||
Open full details
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<BookingConfirmDialog
|
||||
open={flow.dialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onSuppressRowClick?.();
|
||||
flow.setDialogOpen(open);
|
||||
}}
|
||||
action={pendingAction}
|
||||
reference={flow.mergedContext.reference}
|
||||
inputValue={flow.inputValue}
|
||||
onInputChange={flow.setInputValue}
|
||||
selectedFile={flow.selectedFile}
|
||||
onFileChange={flow.setSelectedFile}
|
||||
onConfirm={() => {
|
||||
onSuppressRowClick?.();
|
||||
flow.runAction();
|
||||
}}
|
||||
isPending={mutations.isPending || flow.detailLoading}
|
||||
confirmDisabled={flow.confirmDisabled}
|
||||
extra={
|
||||
flow.detailLoading ? (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading approval steps…
|
||||
</p>
|
||||
) : pendingAction?.id === "approve" &&
|
||||
!getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? (
|
||||
<p className="rounded-lg border border-amber-200/80 bg-amber-50/50 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
No pending approval step. Refresh the page after staff accept, or
|
||||
reject the booking.
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
<ActionDialog flow={flow} pendingAction={pendingAction} onSuppressRowClick={onSuppressRowClick} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionDialog({
|
||||
flow,
|
||||
pendingAction,
|
||||
onSuppressRowClick,
|
||||
}: {
|
||||
flow: ReturnType<typeof useBookingActionDialog>;
|
||||
pendingAction: ReturnType<typeof useBookingActionDialog>["pendingAction"];
|
||||
onSuppressRowClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<BookingConfirmDialog
|
||||
open={flow.dialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onSuppressRowClick?.();
|
||||
flow.setDialogOpen(open);
|
||||
}}
|
||||
action={pendingAction}
|
||||
reference={flow.mergedContext.reference}
|
||||
inputValue={flow.inputValue}
|
||||
onInputChange={flow.setInputValue}
|
||||
selectedFile={flow.selectedFile}
|
||||
onFileChange={flow.setSelectedFile}
|
||||
onConfirm={() => {
|
||||
onSuppressRowClick?.();
|
||||
flow.runAction();
|
||||
}}
|
||||
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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { BookingActionsMenu } from "./BookingActionsMenu";
|
||||
import { bookingSurface } from "./booking-ui.styles";
|
||||
import { SectionCard } from "./detail/SectionCard";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||
import { Button } from "@edr/ui-common";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Mutations = ReturnType<typeof useBookingMutations>;
|
||||
|
||||
@@ -16,10 +15,7 @@ interface BookingActionsToolbarProps {
|
||||
}
|
||||
|
||||
/** Detail-page actions: primary toolbar + downloads. */
|
||||
export function BookingActionsToolbar({
|
||||
booking,
|
||||
mutations,
|
||||
}: BookingActionsToolbarProps) {
|
||||
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
|
||||
const row = toBookingListRow(booking);
|
||||
const { status } = booking;
|
||||
|
||||
@@ -33,50 +29,62 @@ export function BookingActionsToolbar({
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
if (
|
||||
status === "REJECTED" ||
|
||||
status === "CANCELLED" ||
|
||||
status === "COMPLETED"
|
||||
) {
|
||||
if (status === "REJECTED" || status === "CANCELLED" || status === "COMPLETED") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === "CHANGES_REQUESTED") {
|
||||
return (
|
||||
<PanelShell title="Awaiting customer" description="No staff actions until resubmit.">
|
||||
{booking.latestChangeRequestNote && (
|
||||
<p className="rounded-lg border border-border/50 bg-muted/15 p-3 text-sm leading-relaxed backdrop-blur-sm">
|
||||
{booking.latestChangeRequestNote}
|
||||
</p>
|
||||
)}
|
||||
</PanelShell>
|
||||
<SectionCard icon={Zap} title="Awaiting customer">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
No staff actions until resubmit.
|
||||
</Text>
|
||||
{booking.latestChangeRequestNote && (
|
||||
<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)) {
|
||||
return (
|
||||
<PanelShell
|
||||
title="No staff actions"
|
||||
description="Monitor until the customer or system advances status."
|
||||
muted
|
||||
/>
|
||||
<SectionCard icon={Zap} title="No staff actions">
|
||||
<Text size="sm" c="dimmed">
|
||||
Monitor until the customer or system advances status.
|
||||
</Text>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PanelShell
|
||||
title="Staff actions"
|
||||
description="Confirm each step before it is applied."
|
||||
>
|
||||
<BookingActionsMenu row={row} variant="toolbar" />
|
||||
</PanelShell>
|
||||
<Stack gap="lg">
|
||||
<SectionCard icon={Zap} title="Staff actions">
|
||||
<Stack gap="sm">
|
||||
<Text size="xs" c="dimmed">
|
||||
Confirm each step before it is applied.
|
||||
</Text>
|
||||
<BookingActionsMenu row={row} variant="toolbar" />
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
|
||||
{status === "CONTRACT_READY" && (
|
||||
<PanelShell title="Documents" description="Download generated contract.">
|
||||
<SectionCard icon={FileText} title="Documents">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
|
||||
variant="default"
|
||||
leftSection={<Download size={16} />}
|
||||
onClick={() =>
|
||||
downloadBlob(
|
||||
() => mutations.downloadContract(),
|
||||
@@ -84,38 +92,10 @@ export function BookingActionsToolbar({
|
||||
)
|
||||
}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Download contract
|
||||
</Button>
|
||||
</PanelShell>
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { cn } from "@/lib/utils";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Textarea,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
interface BookingConfirmDialogProps {
|
||||
open: boolean;
|
||||
@@ -25,7 +24,7 @@ interface BookingConfirmDialogProps {
|
||||
onConfirm: () => void;
|
||||
isPending: boolean;
|
||||
confirmDisabled?: boolean;
|
||||
extra?: React.ReactNode;
|
||||
extra?: ReactNode;
|
||||
}
|
||||
|
||||
export function BookingConfirmDialog({
|
||||
@@ -45,129 +44,119 @@ export function BookingConfirmDialog({
|
||||
if (!action || !action.confirmTitle) return null;
|
||||
|
||||
const Icon = action.icon;
|
||||
const needsTextInput =
|
||||
action.input === "note" || action.input === "reason";
|
||||
const needsTextInput = action.input === "note" || action.input === "reason";
|
||||
const needsFileInput = action.input === "file";
|
||||
const inputMissing =
|
||||
(needsTextInput && !inputValue.trim()) ||
|
||||
(needsFileInput && !selectedFile);
|
||||
(needsTextInput && !inputValue.trim()) || (needsFileInput && !selectedFile);
|
||||
const isDestructive = action.variant === "destructive";
|
||||
|
||||
const preventClickThrough = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
const accent = isDestructive ? "red" : "green";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="gap-0 overflow-hidden p-0 sm:max-w-md"
|
||||
showCloseButton={false}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
withCloseButton={false}
|
||||
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
|
||||
className={cn(
|
||||
"border-b px-6 py-5",
|
||||
isDestructive
|
||||
? "bg-gradient-to-br from-red-500/10 via-background to-background"
|
||||
: "bg-gradient-to-br from-primary/8 via-background to-background",
|
||||
)}
|
||||
>
|
||||
<DialogHeader className="gap-3 text-left">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"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)}
|
||||
<Group align="flex-start" gap="sm" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
flexShrink: 0,
|
||||
background: `var(--mantine-color-${accent}-1)`,
|
||||
color: `var(--mantine-color-${accent}-7)`,
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={isDestructive ? "destructive" : "default"}
|
||||
disabled={isPending || inputMissing || confirmDisabled}
|
||||
className="min-w-[7rem] gap-2"
|
||||
onMouseDown={preventClickThrough}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Icon className="size-4" />
|
||||
<Icon size={20} />
|
||||
</Box>
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Text fw={700} size="md" style={{ lineHeight: 1.3 }}>
|
||||
{action.confirmTitle}
|
||||
</Text>
|
||||
{reference && (
|
||||
<Text size="xs" c="dimmed" ff="monospace" fw={600}>
|
||||
{reference}
|
||||
</Text>
|
||||
)}
|
||||
{action.shortLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Stack>
|
||||
</Group>
|
||||
{action.confirmDescription && (
|
||||
<Text size="sm" c="dimmed" mt="sm" style={{ lineHeight: 1.5 }}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,86 +1,93 @@
|
||||
import { Banknote, Receipt } from "lucide-react";
|
||||
import { Paper, Stack, Group, Text, Divider } from "@mantine/core";
|
||||
|
||||
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 }) {
|
||||
const amount = Number(booking.totalAmount);
|
||||
const modifiers = booking.cargoModifiers ?? [];
|
||||
|
||||
return (
|
||||
<div className={bookingSurface.sectionCard}>
|
||||
<div className={bookingSurface.sectionHeader}>
|
||||
<div className={bookingSurface.sectionIcon}>
|
||||
<Banknote className="size-4" strokeWidth={1.75} />
|
||||
</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">
|
||||
<SectionCard icon={Banknote} title="Pricing & payment">
|
||||
<Stack gap="md">
|
||||
<Paper radius="md" withBorder p="md" style={detailStyles.highlightCard}>
|
||||
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
||||
Total amount
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-2xl font-semibold tabular-nums tracking-tight text-foreground">
|
||||
</Text>
|
||||
<Text
|
||||
size="xl"
|
||||
fw={700}
|
||||
c="green.9"
|
||||
mt={4}
|
||||
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
|
||||
>
|
||||
{booking.paymentCurrency}{" "}
|
||||
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
<Row label="Payment status" value={booking.paymentStatus} />
|
||||
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
|
||||
|
||||
{modifiers.length > 0 && (
|
||||
<>
|
||||
<Separator className="opacity-50" />
|
||||
<p className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<Receipt className="size-3" />
|
||||
Surcharges applied
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
<Divider color="var(--mantine-color-gray-2)" />
|
||||
<Group gap={6}>
|
||||
<Receipt size={13} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
||||
Surcharges applied
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
{modifiers.map((m) => (
|
||||
<li
|
||||
<Group
|
||||
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>
|
||||
<span className="font-mono font-semibold tabular-nums">
|
||||
<Text size="sm" c="dimmed">
|
||||
Modifier
|
||||
</Text>
|
||||
<Text size="sm" fw={600} style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{Number(m.calculatedAmount).toLocaleString()}
|
||||
</span>
|
||||
</li>
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</ul>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
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">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={
|
||||
mono
|
||||
? "font-mono text-xs font-semibold text-foreground"
|
||||
: "font-medium text-foreground"
|
||||
}
|
||||
>
|
||||
<Group
|
||||
justify="space-between"
|
||||
px="sm"
|
||||
py="xs"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
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}
|
||||
</span>
|
||||
</div>
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
label: string;
|
||||
@@ -18,67 +18,91 @@ const accentColors = {
|
||||
|
||||
export function BookingStatGrid({ items }: { items: StatItem[] }) {
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const accent = item.accent ?? "default";
|
||||
const accentStyle = accentColors[accent];
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
<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 (
|
||||
<Card
|
||||
key={item.label}
|
||||
p="lg"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
background: "white",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
transition: "all 0.2s ease",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.08)";
|
||||
e.currentTarget.style.borderColor = "var(--mantine-color-gray-3)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)";
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Stack gap="xs" style={{ flex: 1 }}>
|
||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text size="32px" fw={700} style={{ lineHeight: 1, letterSpacing: "-0.02em" }}>
|
||||
{item.value}
|
||||
</Text>
|
||||
{item.hint && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{item.hint}
|
||||
return (
|
||||
<Card
|
||||
key={item.label}
|
||||
p="lg"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
background: "white",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
transition: "all 0.2s ease",
|
||||
cursor: "pointer",
|
||||
minWidth: "280px",
|
||||
width: "280px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.boxShadow = "0 4px 12px rgba(34, 197, 94, 0.12)";
|
||||
e.currentTarget.style.borderColor = "var(--mantine-color-green-3)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)";
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Stack gap="xs" style={{ flex: 1 }}>
|
||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
|
||||
{item.label}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "44px",
|
||||
height: "44px",
|
||||
borderRadius: "10px",
|
||||
background: accentStyle.bg,
|
||||
color: accentStyle.color,
|
||||
flexShrink: 0,
|
||||
transition: "transform 0.2s ease",
|
||||
}}
|
||||
>
|
||||
<Icon size={22} strokeWidth={1.75} />
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
<Text size="32px" fw={700} style={{ lineHeight: 1, letterSpacing: "-0.02em" }}>
|
||||
{item.value}
|
||||
</Text>
|
||||
{item.hint && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{item.hint}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "44px",
|
||||
height: "44px",
|
||||
borderRadius: "10px",
|
||||
background: accentStyle.bg,
|
||||
color: accentStyle.color,
|
||||
flexShrink: 0,
|
||||
transition: "transform 0.2s ease",
|
||||
}}
|
||||
>
|
||||
<Icon size={22} strokeWidth={1.75} />
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Wallet,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { Group, Badge, UnstyledButton, Stack, Text } from "@mantine/core";
|
||||
import { Group, Badge, UnstyledButton, Text } from "@mantine/core";
|
||||
|
||||
import {
|
||||
BOOKING_LIST_TABS,
|
||||
@@ -57,12 +57,12 @@ export function BookingStatusTabs({
|
||||
onClick={() => onChange(tab.key)}
|
||||
style={{
|
||||
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",
|
||||
padding: "10px 16px",
|
||||
transition: "all 0.2s ease",
|
||||
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">
|
||||
@@ -75,8 +75,8 @@ export function BookingStatusTabs({
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
borderRadius: "8px",
|
||||
background: isActive ? "var(--mantine-color-blue-1)" : "var(--mantine-color-gray-1)",
|
||||
color: isActive ? "var(--mantine-color-blue-7)" : "var(--mantine-color-gray-6)",
|
||||
background: isActive ? "var(--mantine-color-green-1)" : "var(--mantine-color-gray-1)",
|
||||
color: isActive ? "var(--mantine-color-green-7)" : "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
>
|
||||
{TAB_ICONS[tab.key]}
|
||||
@@ -89,7 +89,7 @@ export function BookingStatusTabs({
|
||||
<Badge
|
||||
size="sm"
|
||||
variant={isActive ? "filled" : "light"}
|
||||
color={isActive ? "blue" : "gray"}
|
||||
color={isActive ? "green" : "gray"}
|
||||
radius="lg"
|
||||
>
|
||||
{count}
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import {
|
||||
Check,
|
||||
CheckCircle2,
|
||||
FileSignature,
|
||||
FileText,
|
||||
Train,
|
||||
Wallet,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Paper, Group, Stack, Text, Box } from "@mantine/core";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getWorkflowStageIndex,
|
||||
WORKFLOW_STAGES,
|
||||
} 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 {
|
||||
status: string;
|
||||
@@ -27,104 +35,99 @@ export function BookingWorkflowStepper({
|
||||
status,
|
||||
title,
|
||||
description,
|
||||
titleColor,
|
||||
}: BookingWorkflowStepperProps) {
|
||||
const currentStage = getWorkflowStageIndex(status);
|
||||
const isTerminal = currentStage < 0;
|
||||
|
||||
return (
|
||||
<div className={bookingSurface.sectionCard}>
|
||||
<div className={bookingSurface.sectionHeader}>
|
||||
<div className={bookingSurface.sectionIcon}>
|
||||
<Train className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
Workflow progress
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Customer submission through completion
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-8 px-5 py-6">
|
||||
<div className="relative px-2">
|
||||
<div className="absolute left-4 right-4 top-5 h-px bg-border/60" />
|
||||
<div
|
||||
className="absolute left-4 top-5 h-px bg-emerald-500/40 transition-all duration-700 ease-out"
|
||||
style={{
|
||||
width:
|
||||
!isTerminal && currentStage >= 0
|
||||
? `calc(${(currentStage / (WORKFLOW_STAGES.length - 1)) * 100}% - 2rem)`
|
||||
: "0%",
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex justify-between">
|
||||
{WORKFLOW_STAGES.map((stage, idx) => {
|
||||
const Icon = STAGE_ICONS[idx] ?? FileText;
|
||||
const isCompleted = !isTerminal && idx < currentStage;
|
||||
const isActive = !isTerminal && idx === currentStage;
|
||||
return (
|
||||
<div
|
||||
key={stage.label}
|
||||
className="flex max-w-[4.5rem] flex-col items-center gap-2.5 sm:max-w-none"
|
||||
>
|
||||
<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",
|
||||
)}
|
||||
<SectionCard icon={Train} title="Workflow progress">
|
||||
<Group gap={0} wrap="nowrap" align="flex-start" mb="lg">
|
||||
{WORKFLOW_STAGES.map((stage, index) => {
|
||||
const Icon = STAGE_ICONS[index] ?? FileText;
|
||||
const isComplete = !isTerminal && index < currentStage;
|
||||
const isActive = !isTerminal && index === currentStage;
|
||||
const isLast = index === WORKFLOW_STAGES.length - 1;
|
||||
|
||||
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: 34,
|
||||
height: 34,
|
||||
borderRadius: "50%",
|
||||
background: isComplete
|
||||
? BRAND_GREEN
|
||||
: isActive
|
||||
? "white"
|
||||
: "var(--mantine-color-gray-1)",
|
||||
border: isActive
|
||||
? `2px solid ${BRAND_GREEN}`
|
||||
: isComplete
|
||||
? "2px solid transparent"
|
||||
: "2px solid var(--mantine-color-gray-2)",
|
||||
color: isComplete
|
||||
? "white"
|
||||
: isActive
|
||||
? "var(--mantine-color-green-7)"
|
||||
: "var(--mantine-color-gray-5)",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle2 className="size-4" />
|
||||
) : (
|
||||
<Icon className="size-4" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-center text-[10px] font-semibold uppercase leading-tight tracking-wide",
|
||||
isActive ? "text-black" : "text-muted-foreground",
|
||||
)}
|
||||
{isComplete ? <Check size={16} strokeWidth={3} /> : <Icon size={15} />}
|
||||
</Box>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={isActive ? 600 : 500}
|
||||
c={isActive ? "green.7" : isComplete ? "dark" : "dimmed"}
|
||||
ta="center"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{stage.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Text>
|
||||
</Stack>
|
||||
{!isLast && (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 2,
|
||||
marginInline: 8,
|
||||
marginBottom: 20,
|
||||
borderRadius: 2,
|
||||
background: isComplete ? BRAND_GREEN : "var(--mantine-color-gray-2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border px-5 py-4 backdrop-blur-sm",
|
||||
isTerminal
|
||||
? "border-destructive/20 bg-destructive/5"
|
||||
: bookingGlass.activeTab,
|
||||
)}
|
||||
>
|
||||
<h4
|
||||
className={cn(
|
||||
"text-sm font-semibold tracking-tight",
|
||||
isTerminal ? titleColor : "text-black",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h4>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Paper
|
||||
radius="md"
|
||||
withBorder
|
||||
p="md"
|
||||
style={{
|
||||
background: isTerminal
|
||||
? "var(--mantine-color-red-0)"
|
||||
: "var(--mantine-color-green-0)",
|
||||
borderColor: isTerminal
|
||||
? "var(--mantine-color-red-2)"
|
||||
: "var(--mantine-color-green-2)",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={600} c={isTerminal ? "red.7" : "green.8"}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{description}
|
||||
</Text>
|
||||
</Paper>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Alert, Text } from "@mantine/core";
|
||||
|
||||
import type { BookingNextStep } from "@/types/booking";
|
||||
import { bookingGlass } from "./booking-ui.styles";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NextStepBannerProps {
|
||||
nextStep: BookingNextStep;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NextStepBanner({ nextStep, className }: NextStepBannerProps) {
|
||||
export function NextStepBanner({ nextStep }: NextStepBannerProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-xl px-4 py-3 text-sm",
|
||||
bookingGlass.activeTab,
|
||||
className,
|
||||
)}
|
||||
role="status"
|
||||
>
|
||||
<ArrowRight className="mt-0.5 size-4 shrink-0 text-black" aria-hidden />
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="font-semibold text-black">
|
||||
<Alert
|
||||
variant="light"
|
||||
color="green"
|
||||
radius="md"
|
||||
icon={<ArrowRight size={16} />}
|
||||
title={
|
||||
<Text size="sm" fw={600}>
|
||||
Next: {nextStep.action.replace(/_/g, " ")}
|
||||
{nextStep.requiredRole ? ` (${nextStep.requiredRole})` : ""}
|
||||
</p>
|
||||
<p className="text-muted-foreground">{nextStep.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
{nextStep.description}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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";
|
||||
@@ -9,14 +9,10 @@ import {
|
||||
Sun,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Group, Stack, Text, Avatar, Menu, ActionIcon, Badge } from "@mantine/core";
|
||||
|
||||
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 {
|
||||
pageMeta: PageMeta;
|
||||
headerRight?: ReactNode;
|
||||
@@ -77,120 +73,145 @@ const FreightDashboardHeader = ({
|
||||
}, [isUserMenuOpen]);
|
||||
|
||||
return (
|
||||
<header className="flex h-20 shrink-0 items-center justify-between gap-4 px-6">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-xl font-bold tracking-tight text-foreground">
|
||||
<header
|
||||
style={{
|
||||
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}
|
||||
</h1>
|
||||
<p className="mt-0.5 truncate text-sm text-secondary-foreground">
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" truncate>
|
||||
{pageMeta.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{enableThemeToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{enableThemeToggle && (
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={40}
|
||||
radius="lg"
|
||||
onClick={onToggleTheme}
|
||||
aria-label={
|
||||
theme === "dark" ? "Switch to light mode" : "Switch to dark mode"
|
||||
}
|
||||
className={iconButtonClass}
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
}}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Change language"
|
||||
className={iconButtonClass}
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={40}
|
||||
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" />
|
||||
</button>
|
||||
<Languages size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
<button type="button" aria-label="Messages" className={iconButtonClass}>
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Notifications"
|
||||
className={iconButtonClass}
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={40}
|
||||
radius="lg"
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" />
|
||||
</button>
|
||||
<MessageSquare size={18} />
|
||||
<Badge
|
||||
size="xs"
|
||||
color="red"
|
||||
circle
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-3px",
|
||||
right: "-3px",
|
||||
}}
|
||||
/>
|
||||
</ActionIcon>
|
||||
|
||||
<div ref={userMenuRef} className="relative ml-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isUserMenuOpen}
|
||||
onClick={() => setIsUserMenuOpen((open) => !open)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-xl border border-transparent px-2 py-1.5 transition",
|
||||
isUserMenuOpen
|
||||
? "border-primary/30 bg-primary/5"
|
||||
: "hover:border-primary/20 hover:bg-gray-50",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
|
||||
{initials}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"hidden h-4 w-4 text-gray-400 transition sm:block",
|
||||
isUserMenuOpen && "rotate-180 text-primary",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={40}
|
||||
radius="lg"
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Bell size={18} />
|
||||
<Badge
|
||||
size="xs"
|
||||
color="red"
|
||||
circle
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-3px",
|
||||
right: "-3px",
|
||||
}}
|
||||
/>
|
||||
</ActionIcon>
|
||||
|
||||
{isUserMenuOpen ? (
|
||||
<div
|
||||
role="menu"
|
||||
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"
|
||||
>
|
||||
<div className="border-b border-gray-100 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
<Menu position="bottom-end" shadow="md" opened={isUserMenuOpen} onOpen={() => setIsUserMenuOpen(true)} onClose={() => setIsUserMenuOpen(false)}>
|
||||
<Menu.Target>
|
||||
<Group gap="sm" p="xs" style={{ cursor: "pointer", borderRadius: "12px" }}>
|
||||
<Avatar name={initials} color="green" size="md" />
|
||||
<ChevronDown size={16} style={{ transition: "transform 0.2s", transform: isUserMenuOpen ? "rotate(180deg)" : "rotate(0deg)" }} />
|
||||
</Group>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item disabled>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600}>
|
||||
{userName}
|
||||
</p>
|
||||
{userEmail ? (
|
||||
<p className="text-xs text-gray-500">{userEmail}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<a
|
||||
href="#profile"
|
||||
role="menuitem"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 transition hover:bg-gray-50"
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
Profile
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Text>
|
||||
{userEmail && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{userEmail}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<User size={14} />}
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
>
|
||||
Profile
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<LogOut size={14} />}
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setIsUserMenuOpen(false);
|
||||
onLogout?.();
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
{headerRight}
|
||||
</div>
|
||||
</Group>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { Box, Paper, MantineProvider } from "@mantine/core";
|
||||
|
||||
import FreightDashboardHeader from "./FreightDashboardHeader";
|
||||
import FreightSidebar from "./FreightSidebar";
|
||||
@@ -30,9 +31,6 @@ export interface FreightDashboardLayoutProps {
|
||||
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 = ({
|
||||
sidebarSections,
|
||||
activeHref = "",
|
||||
@@ -73,40 +71,79 @@ const FreightDashboardLayout = ({
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="flex h-[100dvh] overflow-hidden bg-[#eceef2] p-2 antialiased"
|
||||
style={{ fontFamily: "'Outfit', var(--font-sans)" }}
|
||||
>
|
||||
<div className="flex h-full min-h-0 w-full gap-2">
|
||||
<FreightSidebar
|
||||
sections={sidebarSections}
|
||||
activeHref={activeHref}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
<MantineProvider>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
height: "100dvh",
|
||||
overflow: "hidden",
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
padding: "8px",
|
||||
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">
|
||||
<div className={`shrink-0 ${panelClass}`}>
|
||||
<FreightDashboardHeader
|
||||
pageMeta={pageMeta}
|
||||
headerRight={headerRight}
|
||||
enableThemeToggle={enableThemeToggle}
|
||||
userName={userName}
|
||||
userEmail={userEmail}
|
||||
userInitials={userInitials}
|
||||
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`}
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Paper
|
||||
p={0}
|
||||
radius="lg"
|
||||
withBorder
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,14 +5,11 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDown, ChevronRight, Train } from "lucide-react";
|
||||
import { Stack, Group, Text, Box, UnstyledButton, NavLink } from "@mantine/core";
|
||||
|
||||
import type { SidebarItem, SidebarSection } from "./types";
|
||||
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
|
||||
export interface FreightSidebarProps {
|
||||
sections: SidebarSection[];
|
||||
activeHref?: string;
|
||||
@@ -103,25 +100,6 @@ const FreightSidebar = ({
|
||||
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 = (
|
||||
children: SidebarItem[],
|
||||
depth: number,
|
||||
@@ -136,37 +114,37 @@ const FreightSidebar = ({
|
||||
const groupActive = branchContainsActive(child.children!);
|
||||
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isOpen}
|
||||
<Stack key={key} gap={4}>
|
||||
<UnstyledButton
|
||||
onClick={() => toggleExpanded(key)}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide transition-colors",
|
||||
groupActive
|
||||
? "bg-gray-100 text-gray-900"
|
||||
: "text-gray-900 hover:bg-gray-100",
|
||||
)}
|
||||
style={{
|
||||
background: groupActive ? "var(--mantine-color-green-1)" : "transparent",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "8px",
|
||||
width: "100%",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{child.label}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-gray-900 transition-transform",
|
||||
isOpen ? "rotate-0" : "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 border-l border-gray-200",
|
||||
depth === 0 ? "ml-3 pl-2" : "ml-2 pl-2",
|
||||
)}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" fw={600} c={groupActive ? "green" : "dimmed"} tt="uppercase">
|
||||
{child.label}
|
||||
</Text>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
style={{
|
||||
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
|
||||
transition: "transform 0.2s",
|
||||
color: groupActive ? "var(--mantine-color-green-6)" : "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
{isOpen && (
|
||||
<Stack gap={2} style={{ paddingLeft: "12px", borderLeft: "2px solid var(--mantine-color-green-2)" }}>
|
||||
{renderNavBranch(child.children!, depth + 1, key)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -176,24 +154,49 @@ const FreightSidebar = ({
|
||||
const childActiveHref = isHrefActive(childHref);
|
||||
|
||||
return (
|
||||
<a
|
||||
<NavLink
|
||||
key={key}
|
||||
component="a"
|
||||
href={child.href}
|
||||
onClick={(event) => navigateTo(event, child.href!)}
|
||||
aria-current={childActiveHref ? "page" : undefined}
|
||||
className={navLinkClass(childActiveHref, depth)}
|
||||
>
|
||||
<span className="truncate">{child.label}</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
childActiveHref ? "text-primary-foreground/80" : "text-gray-900",
|
||||
)}
|
||||
/>
|
||||
</a>
|
||||
onClick={(e) => navigateTo(e as any, child.href!)}
|
||||
label={child.label}
|
||||
active={childActiveHref}
|
||||
color="green"
|
||||
style={{
|
||||
borderRadius: "8px",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
rightSection={<ChevronRight size={16} />}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
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) => {
|
||||
if (!item.href) return null;
|
||||
|
||||
@@ -211,99 +214,127 @@ const FreightSidebar = ({
|
||||
const leafActive = isCurrentItem && !hasChildren;
|
||||
|
||||
return (
|
||||
<div key={item.href} className="flex flex-col gap-0.5">
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center rounded-md transition-colors",
|
||||
leafActive
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: isSectionActive || (hasChildren && isCurrentItem)
|
||||
? "bg-gray-100 text-gray-900"
|
||||
: "text-gray-900 hover:bg-gray-100",
|
||||
)}
|
||||
>
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(event) => navigateTo(event, item.href!)}
|
||||
aria-current={isCurrentItem ? "page" : undefined}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-sm leading-snug"
|
||||
>
|
||||
{item.icon ? (
|
||||
<span className={iconClass(leafActive, isActive)}>
|
||||
{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",
|
||||
)}
|
||||
>
|
||||
<Stack key={item.href} gap={0}>
|
||||
<NavLink
|
||||
component="a"
|
||||
href={item.href}
|
||||
onClick={(e) => navigateTo(e as any, item.href!)}
|
||||
label={item.label}
|
||||
leftSection={renderIconWell(item.icon, isActive)}
|
||||
active={leafActive}
|
||||
color="green"
|
||||
variant="light"
|
||||
style={{
|
||||
borderRadius: "10px",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
fontWeight: 500,
|
||||
padding: "8px 10px",
|
||||
}}
|
||||
rightSection={
|
||||
hasChildren ? (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform",
|
||||
isOpen ? "rotate-0" : "-rotate-90",
|
||||
)}
|
||||
size={16}
|
||||
style={{
|
||||
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
|
||||
transition: "transform 0.2s",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
toggleExpanded(item.href!);
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<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>
|
||||
) : (
|
||||
<ChevronRight size={16} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{hasChildren && isOpen ? (
|
||||
<div className="ml-3 flex flex-col gap-1 border-l border-gray-200 pl-2">
|
||||
{hasChildren && isOpen && (
|
||||
<Stack gap={4} style={{ paddingLeft: "16px", borderLeft: "2px solid var(--mantine-color-green-2)" }}>
|
||||
{renderNavBranch(item.children!, 0, item.href)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
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">
|
||||
<div className="flex shrink-0 items-center gap-2.5 border-b border-gray-100 px-5 py-5">
|
||||
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto" />
|
||||
<span className="text-lg font-semibold tracking-tight text-gray-900">
|
||||
EDR Freight
|
||||
</span>
|
||||
</div>
|
||||
<Box
|
||||
component="aside"
|
||||
style={{
|
||||
height: "100%",
|
||||
maxHeight: "100%",
|
||||
width: "280px",
|
||||
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) => (
|
||||
<div key={section.title} className="flex flex-col gap-1">
|
||||
<p
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<Stack key={section.title} gap={8}>
|
||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase" style={{ letterSpacing: "0.5px", paddingLeft: "8px" }}>
|
||||
{section.title}
|
||||
</p>
|
||||
{section.items.map((item) => renderTopLevelItem(item))}
|
||||
</div>
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
{section.items.map((item) => renderTopLevelItem(item))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 { 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 (
|
||||
<FeaturePlaceholder
|
||||
title="Booking Detail"
|
||||
description="Inspect booking metadata, operational notes, and fulfillment progress for internal teams."
|
||||
/>
|
||||
<div style={detailStyles.page}>
|
||||
<Container size="xxl" py="lg">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,111 +1,110 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, FileSignature, Package } from "lucide-react";
|
||||
import {
|
||||
Anchor,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
Calendar,
|
||||
Clock,
|
||||
Loader2,
|
||||
MapPin,
|
||||
Package,
|
||||
FileSignature,
|
||||
RefreshCw,
|
||||
Train,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
Container,
|
||||
Stack,
|
||||
Grid,
|
||||
Center,
|
||||
Loader,
|
||||
Text,
|
||||
Paper,
|
||||
Button,
|
||||
Box,
|
||||
} from "@mantine/core";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
|
||||
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
|
||||
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
|
||||
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
|
||||
import {
|
||||
bookingGlass,
|
||||
bookingSurface,
|
||||
} from "@/components/bookings/booking-ui.styles";
|
||||
detailStyles,
|
||||
BookingRequestHero,
|
||||
BookingRouteServiceCard,
|
||||
BookingMileServicesCard,
|
||||
BookingCargoCard,
|
||||
BookingContractSummaryCard,
|
||||
} from "@/components/bookings/detail";
|
||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import {
|
||||
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";
|
||||
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||
|
||||
export default function BookingRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: booking, isLoading, isError, refetch, isFetching } =
|
||||
useBookingDetail(id);
|
||||
const { data: booking, isLoading, isError, refetch, isFetching } = useBookingDetail(id);
|
||||
const mutations = useBookingMutations(id ?? "");
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={bookingSurface.page}>
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 p-8">
|
||||
<Loader2 className="size-10 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Loading booking…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Box style={detailStyles.page}>
|
||||
<Center mih="60vh">
|
||||
<Stack align="center" gap="md">
|
||||
<Loader color="green" />
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
Loading booking…
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !booking) {
|
||||
return (
|
||||
<div className={bookingSurface.page}>
|
||||
<div className={bookingSurface.pageInner}>
|
||||
<div
|
||||
className={cn(
|
||||
bookingSurface.sectionCard,
|
||||
"mx-auto max-w-md p-12 text-center",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex size-16 items-center justify-center rounded-2xl",
|
||||
bookingGlass.iconWellGreen,
|
||||
)}
|
||||
>
|
||||
<Package className="size-8" />
|
||||
</div>
|
||||
<h1 className="mt-6 text-xl font-bold text-foreground">
|
||||
<Box style={detailStyles.page}>
|
||||
<Container size="sm" py="xl">
|
||||
<Paper radius="md" withBorder p="xl" ta="center" style={detailStyles.card}>
|
||||
<Center>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 16,
|
||||
background: "var(--mantine-color-green-0)",
|
||||
color: "var(--mantine-color-green-7)",
|
||||
}}
|
||||
>
|
||||
<Package size={32} />
|
||||
</Box>
|
||||
</Center>
|
||||
<Text fw={700} size="lg" mt="lg">
|
||||
Booking not found
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
This request may have been removed or the link is invalid.
|
||||
</p>
|
||||
</Text>
|
||||
<Button
|
||||
className="mt-6 gap-2"
|
||||
variant="outline"
|
||||
variant="default"
|
||||
mt="lg"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/dashboard/booking-requests")}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to booking requests
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const row = toBookingListRow(booking);
|
||||
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 (
|
||||
<div className={bookingSurface.page}>
|
||||
<div className={bookingSurface.pageInner}>
|
||||
<Box style={detailStyles.page}>
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Booking requests", href: "/dashboard/booking-requests" },
|
||||
@@ -113,381 +112,66 @@ export default function BookingRequestDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={bookingSurface.detailHero}>
|
||||
<div className={bookingSurface.heroGlow} />
|
||||
<div className={bookingSurface.heroSheen} />
|
||||
<div className="relative p-6 sm:p-8">
|
||||
<div className="mb-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-2 gap-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => navigate("/dashboard/booking-requests")}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to list
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="flex gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-16 shrink-0 items-center justify-center rounded-2xl",
|
||||
bookingGlass.iconWellGreen,
|
||||
<Stack gap="lg" mt="sm">
|
||||
<BookingRequestHero
|
||||
booking={booking}
|
||||
customerLabel={row.customerLabel}
|
||||
onBack={() => navigate("/dashboard/booking-requests")}
|
||||
onRefresh={() => refetch()}
|
||||
isFetching={isFetching}
|
||||
/>
|
||||
|
||||
<BookingWorkflowStepper
|
||||
status={booking.status}
|
||||
title={statusMeta.title}
|
||||
description={statusMeta.description}
|
||||
titleColor={statusMeta.color}
|
||||
/>
|
||||
|
||||
<Grid gutter="lg">
|
||||
{/* LEFT — primary content */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Stack gap="lg">
|
||||
<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>
|
||||
)}
|
||||
>
|
||||
<Package className="size-7" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Booking reference
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-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" />
|
||||
{showApprovalCard && (
|
||||
<ApprovalStepsCard booking={booking} mutations={mutations} />
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5 font-medium text-foreground">
|
||||
<Building2 className="size-4 opacity-70" />
|
||||
{row.customerLabel}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Calendar className="size-4 opacity-70" />
|
||||
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 & 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>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,20 @@ import {
|
||||
User,
|
||||
X,
|
||||
} 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 { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
@@ -26,12 +40,7 @@ import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
|
||||
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||
import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
|
||||
import {
|
||||
bookingGlass,
|
||||
bookingInput,
|
||||
bookingSurface,
|
||||
bookingTable,
|
||||
} from "@/components/bookings/booking-ui.styles";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import { useBookingList, useBookingListSummary } from "@/hooks/bookings/useBookings";
|
||||
@@ -237,168 +246,195 @@ export default function BookingRequestsPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={bookingSurface.page}>
|
||||
<div className={bookingSurface.pageInner}>
|
||||
<div style={{ background: "var(--mantine-color-gray-0)", minHeight: "100vh" }}>
|
||||
<Container size="xxl" py="xl">
|
||||
<Breadcrumbs items={[{ label: "Operations" }, { label: "Booking requests" }]} />
|
||||
|
||||
<div className={bookingSurface.hero}>
|
||||
<div className={bookingSurface.heroGlow} />
|
||||
<div className={bookingSurface.heroSheen} />
|
||||
<div className="relative flex flex-col gap-6 p-6 sm:flex-row sm:items-center sm:justify-between sm:p-8">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-14 shrink-0 items-center justify-center rounded-2xl",
|
||||
bookingGlass.iconWellGreen,
|
||||
)}
|
||||
>
|
||||
<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 });
|
||||
{/*
|
||||
<Card
|
||||
p="lg"
|
||||
radius="lg"
|
||||
withBorder
|
||||
mb="xl"
|
||||
style={{
|
||||
background: "white",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
|
||||
}}
|
||||
counts={tabCounts}
|
||||
/>
|
||||
|
||||
<div className={bookingSurface.panel}>
|
||||
<div className={bookingSurface.panelToolbar}>
|
||||
<div className="relative min-w-[12rem] flex-1 sm:max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Group gap="md" align="flex-start">
|
||||
<ThemeIcon
|
||||
size="lg"
|
||||
radius="lg"
|
||||
color="green"
|
||||
variant="light"
|
||||
>
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Inbox size={28} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={8}>
|
||||
<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 ? (
|
||||
<BookingTableEmpty
|
||||
isError={isError}
|
||||
hasSearch={hasSearch}
|
||||
onRetry={handleRefresh}
|
||||
/>
|
||||
) : (
|
||||
<div className={bookingSurface.tableWrap}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={handleRowClick}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
<div className="mt-6"></div>
|
||||
<Stack gap="lg">
|
||||
<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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<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={{
|
||||
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}
|
||||
counts={tabCounts}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Paper>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user