mantine added to Train, wagon, containers and cargoes

This commit is contained in:
hagiye
2026-06-08 09:29:34 +03:00
105 changed files with 6510 additions and 3248 deletions

View File

@@ -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,76 @@ 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";
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 var(--mantine-color-gray-2)",
borderLeft: isNext
? "3px solid var(--freight-brand)"
: "1px solid var(--mantine-color-gray-2)",
background: isNext ? "var(--mantine-color-gray-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: "var(--mantine-color-gray-1)",
color: isNext ? "var(--mantine-color-gray-7)" : "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>
);
}

View File

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

View File

@@ -1,12 +1,11 @@
import { Download, Zap } from "lucide-react";
import { Download, Zap, FileText, Clock } 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,84 @@ 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>
);
}
if (
["FULLY_EXECUTED", "PNR_GENERATED", "PAYMENT_VERIFICATION_IN_PROGRESS"].includes(
status,
)
) {
return (
<Stack gap="lg">
<SectionCard icon={Clock} title="Awaiting customer payment">
<Stack gap="sm">
<Text size="sm" c="dimmed">
Payment is completed by the customer. The booking status updates
automatically once payment is confirmed, then moves to Operations.
</Text>
{status === "FULLY_EXECUTED" && (
<BookingActionsMenu row={row} variant="toolbar" />
)}
</Stack>
</SectionCard>
</Stack>
);
}
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 +114,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>
);
}

View File

@@ -14,7 +14,7 @@ export function BookingApprovalProgressCell({ row }: BookingApprovalProgressCell
<p
className={cn(
"text-sm font-semibold",
summary.complete ? "text-emerald-700 dark:text-emerald-400" : "text-foreground",
summary.complete ? "text-[color:var(--freight-brand)]" : "text-foreground",
)}
>
{summary.label}

View File

@@ -1,17 +1,16 @@
import { Loader2 } from "lucide-react";
import type { ReactNode } from "react";
import {
Modal,
Group,
Stack,
Text,
Box,
Button,
Textarea,
FileInput,
} from "@mantine/core";
import type { BookingActionDef } from "@/features/bookings/booking-actions.config";
import { 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>
);
}

View File

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

View File

@@ -1,21 +1,23 @@
import { Badge } from "@mantine/core";
export function BookingPriorityBadge({ score }: { score: number }) {
if (score >= 1000) {
return (
<span className="rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-red-700">
<Badge color="red" variant="filled" size="sm" radius="lg" tt="uppercase">
Urgent
</span>
</Badge>
);
}
if (score >= 500) {
return (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700">
<Badge color="yellow" variant="filled" size="sm" radius="lg" tt="uppercase">
High
</span>
</Badge>
);
}
return (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-slate-600">
<Badge color="gray" variant="light" size="sm" radius="lg" tt="uppercase">
Normal
</span>
</Badge>
);
}

View File

@@ -1,6 +1,5 @@
import type { LucideIcon } from "lucide-react";
import { bookingGlass } from "./booking-ui.styles";
import { cn } from "@/lib/utils";
import { Card, Group, Stack, Text, Paper } from "@mantine/core";
export interface StatItem {
label: string;
@@ -10,54 +9,100 @@ export interface StatItem {
accent?: "default" | "amber" | "emerald" | "rose";
}
const iconAccentStyles = {
default: "text-foreground/70",
amber: "text-amber-600 dark:text-amber-400",
emerald: "text-emerald-600 dark:text-emerald-400",
rose: "text-rose-600 dark:text-rose-400",
const accentColors = {
default: { bg: "var(--mantine-color-gray-1)", color: "var(--mantine-color-gray-6)" },
amber: { bg: "var(--mantine-color-yellow-1)", color: "var(--mantine-color-yellow-6)" },
emerald: { bg: "var(--freight-brand-muted)", color: "var(--freight-brand)" },
rose: { bg: "var(--mantine-color-red-1)", color: "var(--mantine-color-red-6)" },
};
export function BookingStatGrid({ items }: { items: StatItem[] }) {
return (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{items.map((item) => {
const Icon = item.icon;
const accent = item.accent ?? "default";
return (
<div
key={item.label}
className={cn(
"group relative overflow-hidden rounded-xl p-5 transition-all duration-200 hover:shadow-md",
bookingGlass.card,
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{item.label}
</p>
<p className="mt-2 text-3xl font-semibold tabular-nums tracking-tight text-foreground">
{item.value}
</p>
{item.hint && (
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
{item.hint}
</p>
)}
</div>
<div
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-xl transition-transform duration-200 group-hover:scale-[1.02]",
bookingGlass.iconWellGreen,
iconAccentStyles[accent],
)}
>
<Icon className="size-[18px]" strokeWidth={1.75} />
</div>
</div>
</div>
);
})}
</div>
<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",
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(--freight-brand-border)";
}}
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}
</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>
);
}

View File

@@ -1,19 +1,50 @@
import { Badge } from "@edr/ui-common";
import { cn } from "@/lib/utils";
import { Badge } from "@mantine/core";
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
const statusColorMap: Record<string, string> = {
DRAFT: "gray",
SUBMITTED: "yellow",
CHANGES_REQUESTED: "orange",
PENDING_APPROVAL: "yellow",
APPROVED_PENDING_SIGNATURE: "cyan",
APPROVED: "green",
CONTRACT_READY: "indigo",
SIGNED_CUSTOMER: "cyan",
FULLY_EXECUTED: "indigo",
PNR_GENERATED: "violet",
PAYMENT_VERIFICATION_IN_PROGRESS: "yellow",
PAID: "green",
IN_TRANSIT: "cyan",
COMPLETED: "indigo",
REJECTED: "red",
CANCELLED: "red",
PENDING_CONSOLIDATION: "yellow",
CONSOLIDATED: "indigo",
};
export function BookingStatusBadge({ status }: { status: string }) {
const style = BOOKING_STATUS_STYLES[status] ?? {
label: status,
color: "bg-muted text-muted-foreground border-border",
color: "gray",
};
const color = statusColorMap[status] ?? "gray";
return (
<Badge
variant="outline"
className={cn(
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider",
style.color,
)}
color={color}
variant="light"
size="sm"
radius="md"
tt="uppercase"
fw={600}
title={style.label}
style={{
fontSize: "0.7rem",
letterSpacing: "0.05em",
display: "inline-flex",
maxWidth: "100%",
whiteSpace: "nowrap",
}}
>
{style.label}
</Badge>

View File

@@ -8,27 +8,24 @@ import {
Wallet,
XCircle,
} from "lucide-react";
import { Group, Badge, UnstyledButton, Text } from "@mantine/core";
import {
BOOKING_LIST_TABS,
type BookingStatusTabKey,
} from "@/features/bookings/booking-status.config";
import { bookingGlass } from "./booking-ui.styles";
import { cn } from "@/lib/utils";
const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
all: <LayoutGrid className="size-3.5" strokeWidth={1.75} />,
intake: <Inbox className="size-3.5" strokeWidth={1.75} />,
in_approval: <ClipboardCheck className="size-3.5" strokeWidth={1.75} />,
approved_contract: <FileSignature className="size-3.5" strokeWidth={1.75} />,
payment: <Wallet className="size-3.5" strokeWidth={1.75} />,
operations: <Train className="size-3.5" strokeWidth={1.75} />,
completed: <CheckCircle className="size-3.5" strokeWidth={1.75} />,
closed: <XCircle className="size-3.5" strokeWidth={1.75} />,
all: <LayoutGrid size={18} strokeWidth={1.75} />,
intake: <Inbox size={18} strokeWidth={1.75} />,
in_approval: <ClipboardCheck size={18} strokeWidth={1.75} />,
approved_contract: <FileSignature size={18} strokeWidth={1.75} />,
payment: <Wallet size={18} strokeWidth={1.75} />,
operations: <Train size={18} strokeWidth={1.75} />,
completed: <CheckCircle size={18} strokeWidth={1.75} />,
closed: <XCircle size={18} strokeWidth={1.75} />,
};
const activeTabText = "text-black";
interface BookingStatusTabsProps {
active: BookingStatusTabKey;
onChange: (tab: BookingStatusTabKey) => void;
@@ -41,66 +38,74 @@ export function BookingStatusTabs({
counts,
}: BookingStatusTabsProps) {
return (
<div className={bookingGlass.tabRail}>
<div
className="flex flex-nowrap gap-1.5 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
role="tablist"
aria-label="Booking status filters"
>
{BOOKING_LIST_TABS.map((tab) => {
const isActive = active === tab.key;
const count = counts?.[tab.key];
return (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => onChange(tab.key)}
className={cn(
"flex min-w-[8rem] shrink-0 flex-col items-start gap-0.5 rounded-lg px-3 py-2.5 text-left transition-all duration-200",
isActive
? bookingGlass.activeTab
: "text-muted-foreground hover:bg-emerald-500/5 hover:text-foreground",
)}
>
<span className="flex w-full items-center justify-between gap-2">
<span
className={cn(
"flex items-center gap-2 text-sm font-medium",
isActive ? activeTabText : "text-muted-foreground",
)}
<Group
gap="sm"
wrap="nowrap"
p="md"
style={{
background: "var(--mantine-color-gray-0)",
borderRadius: "12px",
border: "1px solid var(--mantine-color-gray-2)",
overflowX: "auto",
overflowY: "hidden",
WebkitOverflowScrolling: "touch",
scrollBehavior: "smooth",
scrollbarWidth: "thin",
}}
>
{BOOKING_LIST_TABS.map((tab) => {
const isActive = active === tab.key;
const count = counts?.[tab.key];
return (
<UnstyledButton
key={tab.key}
onClick={() => onChange(tab.key)}
style={{
flexShrink: 0,
background: isActive ? "white" : "transparent",
border: isActive ? "1px solid var(--freight-brand-border)" : "1px solid var(--mantine-color-gray-2)",
borderRadius: "10px",
padding: "10px 16px",
transition: "all 0.2s ease",
cursor: "pointer",
boxShadow: isActive ? "0 2px 8px rgb(21 128 61 / 0.12)" : "none",
}}
>
<Group gap="sm" justify="space-between" wrap="nowrap">
<Group gap={8}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "32px",
height: "32px",
borderRadius: "8px",
background: isActive ? "var(--freight-brand-muted)" : "var(--mantine-color-gray-1)",
color: isActive ? "var(--freight-brand-dark)" : "var(--mantine-color-gray-6)",
}}
>
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md",
isActive
? cn(bookingGlass.iconWellGreen, "text-black")
: "border border-transparent bg-muted/30",
)}
>
{TAB_ICONS[tab.key]}
</span>
<span className="whitespace-nowrap">{tab.label}</span>
</span>
{count !== undefined && count > 0 && (
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-semibold tabular-nums",
isActive
? cn("bg-emerald-500/15", activeTabText)
: "bg-muted/50 text-muted-foreground",
)}
>
{count}
</span>
)}
</span>
</button>
);
})}
</div>
</div>
{TAB_ICONS[tab.key]}
</div>
<Text size="sm" fw={600}>
{tab.label}
</Text>
</Group>
{count !== undefined && count > 0 && (
<Badge
size="sm"
variant={isActive ? "filled" : "light"}
color={isActive ? "green" : "gray"}
radius="lg"
>
{count}
</Badge>
)}
</Group>
</UnstyledButton>
);
})}
</Group>
);
}

View File

@@ -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, detailStyles } 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,94 @@ 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(--freight-brand-dark)"
: "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={
isTerminal ? detailStyles.statusBannerTerminal : detailStyles.statusBanner
}
>
<Text size="sm" fw={600} c={isTerminal ? "red.7" : "dark"}>
{title}
</Text>
<Text size="sm" c="dimmed" mt={4}>
{description}
</Text>
</Paper>
</SectionCard>
);
}

View File

@@ -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="gray"
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>
);
}

View File

@@ -1,4 +1,4 @@
/** Shared surfaces for booking list & detail — frosted glass, neutral accents. */
/** Shared surfaces for booking list & detail — frosted glass, brand accents. */
export const bookingGlass = {
card: "border border-border/50 bg-card/75 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/60",
@@ -10,9 +10,9 @@ export const bookingGlass = {
iconWellHero:
"border border-border/50 bg-background/60 text-foreground shadow-sm ring-1 ring-border/30 backdrop-blur-md supports-[backdrop-filter]:bg-background/45",
activeTab:
"border border-emerald-500/20 bg-emerald-500/10 shadow-sm backdrop-blur-md ring-1 ring-emerald-500/10 supports-[backdrop-filter]:bg-emerald-500/[0.08]",
"border border-[color:var(--freight-brand-border)] bg-[color:var(--freight-brand-muted)] shadow-sm backdrop-blur-md ring-1 ring-[color:var(--freight-brand-ring)]",
iconWellGreen:
"border border-emerald-500/20 bg-emerald-500/15 text-emerald-700 shadow-sm backdrop-blur-sm supports-[backdrop-filter]:bg-emerald-500/10 dark:text-emerald-400",
"border border-[color:var(--freight-brand-border)] bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] shadow-sm backdrop-blur-sm",
tabRail:
"rounded-xl border border-border/60 bg-muted/10 p-2 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/5",
tableHeader:
@@ -38,7 +38,7 @@ export const bookingSurface = {
sectionIcon: `flex size-9 shrink-0 items-center justify-center rounded-lg ${bookingGlass.iconWellGreen}`,
sectionIconLg: `flex size-11 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`,
valueCard:
"rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-emerald-500/[0.08]",
"rounded-xl border border-[color:var(--freight-brand-border)] bg-[color:var(--freight-brand-muted)] p-4 shadow-sm backdrop-blur-md",
stickySidebar: "lg:sticky lg:top-6 lg:self-start",
metricTile:
"rounded-lg border border-border/50 bg-background/70 px-4 py-3 shadow-xs backdrop-blur-sm",
@@ -48,7 +48,7 @@ export const bookingSurface = {
export const bookingInput = {
search:
"h-10 w-full rounded-lg border border-border/60 bg-background/80 pl-10 text-sm shadow-xs backdrop-blur-sm transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-ring/60 focus-visible:ring-[3px] focus-visible:ring-ring/20 sm:max-w-xs",
"h-10 w-full rounded-lg border border-border/60 bg-background/80 pl-10 text-sm shadow-xs backdrop-blur-sm transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-[color:var(--freight-brand)] focus-visible:ring-[3px] focus-visible:ring-[color:var(--freight-brand-ring)] sm:max-w-xs",
} as const;
export const bookingTable = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,101 @@
import { Train, MapPin, ArrowRight } from "lucide-react";
import { Group, Stack, Text, Badge, Box, SimpleGrid } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { SectionCard } from "./SectionCard";
import { MetricTile } from "./MetricTile";
export interface BookingRouteServiceCardProps {
booking: BookingDetail;
originLabel: string;
destinationLabel: string;
}
function Endpoint({
label,
station,
align = "left",
}: {
label: string;
station: string;
align?: "left" | "right";
}) {
return (
<Stack gap={2} style={{ flex: 1, minWidth: 0 }} align={align === "right" ? "flex-end" : "flex-start"}>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
{label}
</Text>
<Group gap={6} wrap="nowrap">
<MapPin size={15} color="var(--freight-brand)" />
<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-gray-1)",
border: "1px solid var(--mantine-color-gray-3)",
}}
>
<Train size={18} color="var(--mantine-color-gray-7)" />
</Box>
<Badge variant="light" color="gray" size="sm" radius="sm" tt="uppercase">
{serviceLabel}
</Badge>
</Stack>
<Group gap={6} wrap="nowrap" style={{ flex: 1, justifyContent: "flex-end" }}>
<ArrowRight size={16} color="var(--mantine-color-gray-4)" style={{ flexShrink: 0 }} />
<Endpoint label="Destination" station={destinationLabel} align="right" />
</Group>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="sm" mt="lg">
{metrics.map((m) => (
<MetricTile key={m.label} label={m.label} value={m.value} />
))}
</SimpleGrid>
</SectionCard>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,154 @@
import type { CSSProperties } from "react";
import { FREIGHT_BRAND } from "@/theme/freight-brand";
/** Single brand accent. Minimal design uses solid green sparingly, no gradients. */
export const BRAND_GREEN = FREIGHT_BRAND;
/** 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) — neutral tint, still flat. */
highlightCard: {
background: "var(--mantine-color-gray-0)",
borderColor: "var(--mantine-color-gray-2)",
} satisfies CSSProperties,
/** Workflow status description banner — neutral default. */
statusBanner: {
background: "var(--mantine-color-gray-0)",
borderColor: "var(--mantine-color-gray-2)",
} satisfies CSSProperties,
/** Workflow status banner for terminal (rejected/cancelled) states. */
statusBannerTerminal: {
background: "var(--mantine-color-red-0)",
borderColor: "var(--mantine-color-red-2)",
} satisfies CSSProperties,
routeLine: {
flex: 1,
height: "1px",
background: "var(--mantine-color-gray-3)",
} satisfies CSSProperties,
fileRow: {
borderRadius: "8px",
border: "1px solid var(--mantine-color-gray-2)",
transition: "background 0.12s ease, border-color 0.12s ease",
} satisfies CSSProperties,
} as const;
/** Map an approval-step status to a Mantine colour. */
export function approvalStatusColor(status: string): string {
switch (status) {
case "APPROVED":
return "green";
case "PENDING":
return "yellow";
case "REJECTED":
return "red";
default:
return "gray";
}
}
export function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
export function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
// ---- View model -----------------------------------------------------------
export interface BookingNamedRefView {
id: string;
label?: string;
code?: string;
companyName?: string;
name?: string;
}
export interface BookingContainerView {
id: string;
quantity: number;
vgmPerUnitTons: number;
containerType?: {
label?: string;
sizeFt?: number;
isReefer?: boolean;
};
}
export interface BookingApprovalStepView {
id: string;
stepOrder: number;
requiredRole: string;
status: string;
actionedAt?: string | null;
}
export interface BookingReviewNoteView {
id: string;
note: string;
type: string;
createdAt: string;
}
export interface BookingFileView {
id: string;
name: string;
mimeType?: string;
}
export interface BookingDetailView {
id: string;
reference: string;
status: string;
scheduledDate: string;
totalAmount: number;
paymentCurrency: string;
paymentStatus: string;
tradeDirection: string;
freightType: string;
priorityScore: number;
cargoTotalWeightVgm: number;
pnrCode?: string | null;
createdAt: string;
updatedAt: string;
company?: BookingNamedRefView;
originYard?: BookingNamedRefView;
destinationYard?: BookingNamedRefView;
serviceType?: BookingNamedRefView;
cargoType?: BookingNamedRefView;
shippingLine?: BookingNamedRefView;
bookingContainers?: BookingContainerView[];
approvalSteps?: BookingApprovalStepView[];
reviewNotes?: BookingReviewNoteView[];
files?: BookingFileView[];
}

View File

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

View File

@@ -86,14 +86,8 @@ export function useBookingActionDialog(
);
break;
}
case "generateContract":
mutations.generateContract.mutate(undefined, { onSuccess });
break;
case "viewContract":
break;
case "payBooking":
mutations.payBooking.mutate(undefined, { onSuccess });
break;
case "startTransit":
mutations.startTransit.mutate(undefined, { onSuccess });
break;

View File

@@ -9,13 +9,10 @@ import {
Sun,
User,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Group, Stack, Text, Avatar, Menu, ActionIcon, Badge, Box } 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";
import { freightBrand } from "@/theme/freight-brand";
export interface FreightDashboardHeaderProps {
pageMeta: PageMeta;
@@ -77,120 +74,146 @@ 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",
// borderBottom: `3px solid ${freightBrand.primary}`,
}}
>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="lg" fw={700} truncate style={{ color: freightBrand.primaryDark }}>
{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" styles={{ root: { background: freightBrand.primary } }} />
<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>
);
};

View File

@@ -1,9 +1,11 @@
import { type ReactNode, useEffect, useState } from "react";
import { Box, Paper, MantineProvider } from "@mantine/core";
import FreightDashboardHeader from "./FreightDashboardHeader";
import FreightSidebar from "./FreightSidebar";
import { getPageMeta } from "./route-meta";
import type { SidebarSection } from "./types";
import { freightMantineTheme } from "@/theme/freight-brand";
type Theme = "light" | "dark";
const THEME_STORAGE_KEY = "edr-theme";
@@ -30,9 +32,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 +72,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 theme={freightMantineTheme}>
<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>
</>
);
};

View File

@@ -5,13 +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";
import { freightBrand } from "@/theme/freight-brand";
export interface FreightSidebarProps {
sections: SidebarSection[];
@@ -103,25 +101,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 +115,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 ? freightBrand.mutedBg : "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} style={{ color: groupActive ? freightBrand.primary : undefined }} c={groupActive ? undefined : "dimmed"} tt="uppercase">
{child.label}
</Text>
<ChevronDown
size={14}
style={{
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
transition: "transform 0.2s",
color: groupActive ? freightBrand.primary : "var(--mantine-color-gray-5)",
}}
/>
</Group>
</UnstyledButton>
{isOpen && (
<Stack gap={2} style={{ paddingLeft: "12px", borderLeft: `2px solid ${freightBrand.mutedBorder}` }}>
{renderNavBranch(child.children!, depth + 1, key)}
</div>
) : null}
</div>
</Stack>
)}
</Stack>
);
}
@@ -176,24 +155,47 @@ 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 ? freightBrand.gradient : "var(--mantine-color-gray-1)",
color: active ? "white" : "var(--mantine-color-gray-6)",
boxShadow: active ? freightBrand.shadowSm : "none",
transition: "all 0.2s ease",
}}
>
{icon}
</Box>
);
};
const renderTopLevelItem = (item: SidebarItem) => {
if (!item.href) return null;
@@ -211,99 +213,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 ${freightBrand.mutedBorder}` }}>
{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: freightBrand.gradient,
boxShadow: freightBrand.shadow,
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>
);
};

View File

@@ -35,6 +35,20 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "Dashboard summary and key metrics",
},
},
{
prefix: "/dashboard/routes",
meta: {
title: "Routes",
subtitle: "Manage route definitions built from freight yards",
},
},
{
prefix: "/dashboard/locomotives",
meta: {
title: "Locomotives",
subtitle: "Manage locomotive master data and service status",
},
},
{
prefix: "/dashboard/user-management/employees",
meta: {
@@ -81,7 +95,7 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
prefix: RULE_ENGINE_CATEGORY_BASE_PATH.configuration,
meta: {
title: "Configuration",
subtitle: "Master data: cargo, containers, services, surcharges, yards, and shipping lines",
subtitle: "Master data: cargo, containers, wagon types, services, surcharges, yards, and shipping lines",
},
},
...configurationRouteMeta,

View File

@@ -1,12 +1,11 @@
import { Stack, Group, Text, Pagination, Card, SimpleGrid } from "@mantine/core";
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
import type { RuleEngineRecord } from "@/types/rule-engine";
import { DataTableFooter } from "@edr/ui-common";
import type { Table } from "@edr/ui-common";
import RuleEngineRecordActions from "./RuleEngineRecordActions";
import { cardInitials, resolveCardPresentation } from "./ruleEngineCardMeta";
import { formatCell } from "./ruleEngineFormat";
import { ruleEngineCard } from "./ruleEngineStyles";
export interface RuleEngineCardGridProps {
config: RuleEngineResourceConfig;
@@ -14,7 +13,6 @@ export interface RuleEngineCardGridProps {
status: "loading" | "error" | "success";
emptyMessage: string;
itemLabel: string;
table: Table<RuleEngineRecord>;
pagination: {
pageIndex: number;
pageSize: number;
@@ -29,13 +27,49 @@ export interface RuleEngineCardGridProps {
onApproveRate?: (record: RuleEngineRecord) => void;
}
const extractLabel = (value: unknown): string => {
if (!value || typeof value !== "object") {
return String(value || "");
}
const obj = value as Record<string, unknown>;
return (
(typeof obj.label === "string" ? obj.label : null) ||
(typeof obj.cargoTypeName === "string" ? obj.cargoTypeName : null) ||
(typeof obj.serviceName === "string" ? obj.serviceName : null) ||
(typeof obj.code === "string" ? obj.code : null) ||
(typeof obj.name === "string" ? obj.name : null) ||
(typeof obj.actionLabel === "string" ? obj.actionLabel : null) ||
String(value)
);
};
const getSmartValue = (record: RuleEngineRecord, key: string): unknown => {
const value = record[key as keyof RuleEngineRecord];
// If the value is already an object, use it directly
if (value && typeof value === "object") {
return value;
}
// If the key ends with "Id" and there's a corresponding non-Id key, use that
if (typeof key === "string" && key.endsWith("Id")) {
const relatedKey = key.slice(0, -2); // Remove "Id" suffix
const relatedValue = record[relatedKey as keyof RuleEngineRecord];
if (relatedValue && typeof relatedValue === "object") {
return relatedValue;
}
}
return value;
};
const RuleEngineCardGrid = ({
config,
rows,
status,
emptyMessage,
itemLabel,
table,
pagination,
onEdit,
onDelete,
@@ -48,109 +82,156 @@ const RuleEngineCardGrid = ({
if (status === "error") {
return (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
<p className="text-sm font-medium text-foreground">Failed to load data</p>
<p className="mt-1 text-sm text-muted-foreground">
<Stack align="center" justify="center" p="xl" style={{ minHeight: "400px" }}>
<Text size="lg" fw={600} c="red">Failed to load data</Text>
<Text size="sm" c="dimmed">
Please refresh the page or try again later.
</p>
</div>
</Text>
</Stack>
);
}
if (status === "loading") {
return (
<div className="grid grid-cols-1 gap-3 p-4 sm:grid-cols-2 lg:grid-cols-3 xl:gap-4">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className={ruleEngineCard.skeleton}>
<div className="flex gap-3">
<div className="h-10 w-10 rounded-md bg-muted" />
<div className="flex-1 space-y-2">
<div className="h-4 w-2/3 rounded-sm bg-muted" />
<div className="h-3 w-1/3 rounded-sm bg-muted" />
<Stack gap="md" p="md">
<SimpleGrid cols={{ base: 1, sm: 2, md: 2, lg: 3 }} spacing="md">
{Array.from({ length: 6 }).map((_, index) => (
<Card key={index} p="md" radius="lg" withBorder style={{ height: "280px", background: "var(--mantine-color-gray-0)" }}>
<div style={{ animation: "pulse 2s infinite", opacity: 0.5 }}>
<div style={{ height: "20px", background: "var(--mantine-color-gray-3)", borderRadius: "4px", marginBottom: "12px" }} />
<div style={{ height: "16px", background: "var(--mantine-color-gray-3)", borderRadius: "4px", marginBottom: "20px", width: "80%" }} />
<div style={{ height: "16px", background: "var(--mantine-color-gray-3)", borderRadius: "4px", marginBottom: "8px" }} />
<div style={{ height: "16px", background: "var(--mantine-color-gray-3)", borderRadius: "4px" }} />
</div>
</div>
<div className="mt-4 space-y-2">
<div className="h-3 w-full rounded-sm bg-muted" />
<div className="h-3 w-4/5 rounded-sm bg-muted" />
</div>
</div>
))}
</div>
</Card>
))}
</SimpleGrid>
</Stack>
);
}
if (status === "success" && rows.length === 0) {
return (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
<p className="text-sm font-medium text-foreground">{emptyMessage}</p>
<p className="mt-1 text-sm text-muted-foreground">
<Stack align="center" justify="center" p="xl" style={{ minHeight: "400px" }}>
<Text size="lg" fw={600}>{emptyMessage}</Text>
<Text size="sm" c="dimmed">
Try adjusting your search or add a new record.
</p>
</div>
</Text>
</Stack>
);
}
const avatarBg = "#f1f5f9";
const avatarText = "#475569";
return (
<>
<div className="grid grid-cols-1 gap-3 p-4 sm:grid-cols-2 lg:grid-cols-3 xl:gap-4">
<Stack gap="md" p="md">
<SimpleGrid cols={{ base: 1, sm: 2, md: 2, lg: 3 }} spacing="md">
{rows.map((record) => {
const title = String(record[presentation.titleKey] ?? "Untitled");
const subtitle = presentation.subtitleKey
? String(record[presentation.subtitleKey] ?? "")
: "";
const code = presentation.codeKey
? String(record[presentation.codeKey] ?? "")
: "";
const titleValue = getSmartValue(record, presentation.titleKey);
const title = extractLabel(titleValue);
const subtitleValue = presentation.subtitleKey
? getSmartValue(record, presentation.subtitleKey)
: null;
const subtitle = subtitleValue ? extractLabel(subtitleValue) : "";
const codeValue = presentation.codeKey
? getSmartValue(record, presentation.codeKey)
: null;
const code = codeValue ? extractLabel(codeValue) : "";
const statusValue = presentation.statusKey
? record[presentation.statusKey]
: undefined;
return (
<article key={record.id} className={ruleEngineCard.article}>
<div className={ruleEngineCard.header}>
<div className="flex items-start gap-3">
<div className={ruleEngineCard.avatar} aria-hidden>
<Card
key={record.id}
p="lg"
radius="lg"
withBorder
style={{
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
display: "flex",
flexDirection: "column",
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" mb="md">
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "44px",
height: "44px",
borderRadius: "8px",
background: avatarBg,
fontSize: "16px",
fontWeight: 700,
color: avatarText,
flexShrink: 0,
}}
>
{cardInitials(title)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className={ruleEngineCard.title}>{title}</h3>
{presentation.statusKey
? formatCell(statusValue, "activeBadge")
: null}
</div>
{(code || subtitle) && (
<div className="mt-1.5 flex flex-wrap items-center gap-2">
{code ? formatCell(code, "code") : null}
{subtitle ? (
<span className={ruleEngineCard.meta}>
{presentation.subtitleKey === "stepOrder"
? `Step ${subtitle}`
: subtitle}
</span>
) : null}
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<Text size="sm" fw={700} truncate title={title}>
{title}
</Text>
{code && (
<Text size="xs" c="dimmed" style={{ marginTop: "4px" }}>
{code}
</Text>
)}
</div>
</div>
</div>
</Group>
{presentation.statusKey && (
<div>{formatCell(statusValue, "activeBadge")}</div>
)}
</Group>
{presentation.detailColumns.length > 0 ? (
<dl className="grid flex-1 gap-x-4 gap-y-3 px-4 py-3.5 sm:grid-cols-2">
{presentation.detailColumns.map((col) => (
<div key={col.id} className="min-w-0">
<dt className={ruleEngineCard.detailLabel}>{col.header}</dt>
<dd className={ruleEngineCard.detailValue}>
{formatCell(record[col.accessorKey], col.format)}
</dd>
</div>
))}
</dl>
) : (
<div className="flex-1 px-4 py-2" />
{(subtitle || presentation.detailColumns.length > 0) && (
<Stack gap="xs" style={{ flex: 1, marginBottom: "md" }}>
{subtitle && (
<Group gap="xs">
<Text size="xs" c="dimmed" fw={500}>
{presentation.subtitleKey === "stepOrder" ? "Step" : "Type"}:
</Text>
<Text size="xs" fw={500}>
{subtitle}
</Text>
</Group>
)}
{presentation.detailColumns.map((col) => {
const displayValue = getSmartValue(record, col.accessorKey);
return (
<Group key={col.id} justify="space-between" gap="xs" align="flex-start">
<Text size="xs" c="dimmed" fw={500}>
{col.header}:
</Text>
<div style={{ textAlign: "right", flex: 1 }}>
{formatCell(displayValue, col.format)}
</div>
</Group>
);
})}
</Stack>
)}
<div className={ruleEngineCard.footer}>
<Group justify="flex-end" gap="xs" style={{ borderTop: "1px solid var(--mantine-color-gray-1)", paddingTop: "md" }}>
<RuleEngineRecordActions
record={record}
config={config}
@@ -162,26 +243,26 @@ const RuleEngineCardGrid = ({
onSubmitRate={onSubmitRate}
onApproveRate={onApproveRate}
/>
</div>
</article>
</Group>
</Card>
);
})}
</div>
</SimpleGrid>
<div className="border-t border-border bg-card">
<DataTableFooter
table={table}
pagination={pagination}
options={{
labels: {
showing: "Showing",
ofLabel: "of",
items: itemLabel,
},
}}
/>
</div>
</>
{pagination.pageCount > 1 && (
<Group justify="space-between" align="center" p="md" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
<Text size="sm" c="dimmed">
Showing {Math.min(rows.length, pagination.pageSize)} of {pagination.totalCount} {itemLabel}
</Text>
<Pagination
value={pagination.pageIndex + 1}
total={pagination.pageCount}
size="sm"
radius="md"
/>
</Group>
)}
</Stack>
);
};

View File

@@ -1,27 +1,19 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Loader2 } from "lucide-react";
import {
Modal,
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Field,
FieldContent,
FieldLabel,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
Switch,
TextInput,
Textarea,
} from "@edr/ui-common";
Select,
Switch,
Stack,
Group,
Text,
Box,
SimpleGrid,
Divider,
} from "@mantine/core";
import {
RULE_ENGINE_SELECT_NONE,
@@ -29,8 +21,6 @@ import {
} from "@/pages/ruleEngine/config/resources";
import type { RuleEngineRecord } from "@/types/rule-engine";
import { ruleEngineField, ruleEngineSurface } from "./ruleEngineStyles";
export interface RuleEngineFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -43,6 +33,43 @@ export interface RuleEngineFormDialogProps {
onSubmit: (values: Record<string, unknown>) => void;
}
type FormRow =
| { kind: "pair"; fields: [FormFieldDef, FormFieldDef] }
| { kind: "single"; field: FormFieldDef };
const isShortField = (field: FormFieldDef) =>
field.type === "text" ||
field.type === "number" ||
field.type === "select" ||
field.type === "date";
const buildFormRows = (fields: FormFieldDef[]): FormRow[] => {
const rows: FormRow[] = [];
let index = 0;
while (index < fields.length) {
const field = fields[index];
if (field.type === "textarea" || field.type === "boolean") {
rows.push({ kind: "single", field });
index += 1;
continue;
}
const next = fields[index + 1];
if (next && isShortField(next)) {
rows.push({ kind: "pair", fields: [field, next] });
index += 2;
continue;
}
rows.push({ kind: "single", field });
index += 1;
}
return rows;
};
const buildInitialValues = (
fields: FormFieldDef[],
record?: RuleEngineRecord | null,
@@ -53,6 +80,8 @@ const buildInitialValues = (
if (raw !== undefined && raw !== null) {
if (field.type === "date" && typeof raw === "string") {
values[field.name] = raw.slice(0, 10);
} else if (Array.isArray(raw)) {
values[field.name] = raw.join(", ");
} else {
values[field.name] = raw;
}
@@ -85,11 +114,34 @@ const resolveSelectValue = (
return String(raw);
};
const inputStyles = {
label: { fontWeight: 600, marginBottom: 6, color: "var(--mantine-color-gray-8)" },
input: {
borderColor: "#e2e8f0",
background: "white",
transition: "border-color 0.15s ease, box-shadow 0.15s ease",
"&:focus": {
borderColor: "var(--freight-brand)",
boxShadow: "0 0 0 3px var(--freight-brand-ring)",
},
},
} as const;
const FieldLabel = ({ label, required }: { label: string; required?: boolean }) => (
<Group gap={4} wrap="nowrap">
<span>{label}</span>
{required ? (
<Text component="span" c="red" size="sm">
*
</Text>
) : null}
</Group>
);
const RuleEngineFormDialog = ({
open,
onOpenChange,
title,
description,
fields,
initialRecord,
isSubmitting,
@@ -106,6 +158,8 @@ const RuleEngineFormDialog = ({
}
}, [open, fields, initialRecord]);
const formRows = useMemo(() => buildFormRows(fields), [fields]);
const setField = (name: string, value: unknown) => {
setValues((current) => ({ ...current, [name]: value }));
};
@@ -141,134 +195,171 @@ const RuleEngineFormDialog = ({
onSubmit(payload);
};
const renderField = (field: FormFieldDef) => {
if (field.type === "boolean") {
return (
<Group
key={field.name}
justify="space-between"
align="center"
wrap="nowrap"
gap="md"
px="md"
style={{
minHeight: 42,
background: "#f8fafc",
border: "1px solid #e2e8f0",
borderRadius: "var(--mantine-radius-md)",
}}
>
<Text size="sm" fw={600}>
{field.label}
</Text>
<Switch
checked={Boolean(values[field.name])}
onChange={(e) => setField(field.name, e.currentTarget.checked)}
size="md"
color="green"
/>
</Group>
);
}
const label = <FieldLabel label={field.label} required={field.required} />;
if (field.type === "select") {
return (
<Select
key={field.name}
label={label}
placeholder={
selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option")
}
value={resolveSelectValue(field, values)}
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
disabled={selectOptionsLoading}
data={(field.options ?? [])
.filter((opt) => opt.value !== "")
.map((opt) => ({
label: opt.label,
value: opt.value,
}))}
searchable
clearable
size="md"
radius="md"
styles={inputStyles}
/>
);
}
if (field.type === "textarea") {
return (
<Textarea
key={field.name}
label={label}
value={String(values[field.name] ?? "")}
onChange={(e) => setField(field.name, e.currentTarget.value)}
placeholder={field.placeholder}
required={field.required}
minRows={4}
autosize
maxRows={8}
size="md"
radius="md"
styles={inputStyles}
/>
);
}
return (
<TextInput
key={field.name}
label={label}
type={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"}
value={String(values[field.name] ?? "")}
onChange={(e) => setField(field.name, e.currentTarget.value)}
placeholder={field.placeholder}
required={field.required}
size="md"
radius="md"
styles={inputStyles}
/>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={ruleEngineSurface.dialog}>
<DialogHeader className="space-y-1">
<DialogTitle className="text-lg font-semibold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-1">
<div className="max-h-[min(60vh,28rem)] space-y-4 overflow-y-auto pr-1">
{fields.map((field) => (
<Field key={field.name} orientation="vertical" className="gap-1.5">
{field.type === "boolean" ? (
<div className={ruleEngineField.switchRow}>
<div className="min-w-0">
<FieldLabel htmlFor={field.name} className={ruleEngineField.label}>
{field.label}
</FieldLabel>
<p className={ruleEngineField.switchHint}>
{Boolean(values[field.name]) ? "Enabled" : "Disabled"}
</p>
</div>
<Switch
id={field.name}
checked={Boolean(values[field.name])}
onCheckedChange={(checked) => setField(field.name, checked)}
/>
</div>
<Modal
opened={open}
onClose={() => onOpenChange(false)}
title={
<Text size="lg" fw={700} lh={1.2}>
{title}
</Text>
}
centered
size={720}
radius="lg"
padding="xl"
overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
styles={{
content: {
maxWidth: "min(720px, 95vw)",
},
body: {
paddingTop: 20,
},
}}
>
<form onSubmit={handleSubmit}>
<Stack gap="lg">
<Box style={{ maxHeight: "calc(65vh - 120px)", overflowY: "auto", paddingRight: 4 }}>
<Stack gap="md">
{formRows.map((row) =>
row.kind === "pair" ? (
<SimpleGrid key={`${row.fields[0].name}-${row.fields[1].name}`} cols={2} spacing="md">
<Box style={{ minWidth: 0 }}>{renderField(row.fields[0])}</Box>
<Box style={{ minWidth: 0 }}>{renderField(row.fields[1])}</Box>
</SimpleGrid>
) : (
<>
<FieldLabel htmlFor={field.name} className={ruleEngineField.label}>
{field.label}
{field.required ? (
<span className={ruleEngineField.requiredMark}> *</span>
) : null}
</FieldLabel>
<FieldContent>
{field.type === "select" ? (
<Select
value={resolveSelectValue(field, values)}
onValueChange={(v) =>
setField(
field.name,
v === RULE_ENGINE_SELECT_NONE ? "" : v,
)
}
disabled={selectOptionsLoading}
>
<SelectTrigger
id={field.name}
className={ruleEngineField.selectTrigger}
>
<SelectValue
placeholder={
selectOptionsLoading
? "Loading options..."
: (field.placeholder ?? "Select an option")
}
/>
</SelectTrigger>
<SelectContent className={ruleEngineField.selectContent}>
{(field.options ?? [])
.filter((opt) => opt.value !== "")
.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : field.type === "textarea" ? (
<Textarea
id={field.name}
value={String(values[field.name] ?? "")}
onChange={(e) => setField(field.name, e.target.value)}
placeholder={field.placeholder}
className={ruleEngineField.textarea}
/>
) : (
<Input
id={field.name}
type={
field.type === "number"
? "number"
: field.type === "date"
? "date"
: "text"
}
value={String(values[field.name] ?? "")}
onChange={(e) => setField(field.name, e.target.value)}
placeholder={field.placeholder}
className={ruleEngineField.input}
required={field.required}
/>
)}
</FieldContent>
</>
)}
</Field>
))}
</div>
<Box key={row.field.name}>{renderField(row.field)}</Box>
),
)}
</Stack>
</Box>
<Separator className="my-4" />
<Divider />
<DialogFooter className="gap-2 sm:gap-2">
<Group justify="flex-end" gap="sm">
<Button
type="button"
variant="outline"
className="rounded-md"
variant="default"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
radius="md"
size="md"
>
Cancel
</Button>
<Button type="submit" className="rounded-md" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Saving...
</>
) : (
"Save"
)}
<Button
type="submit"
disabled={isSubmitting}
leftSection={
isSubmitting ? (
<Loader2 size={18} style={{ animation: "spin 1s linear infinite" }} />
) : undefined
}
radius="md"
color="green"
variant="filled"
fw={600}
size="md"
>
{isSubmitting ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</Group>
</Stack>
</form>
</Modal>
);
};

View File

@@ -6,16 +6,10 @@ import {
Send,
Trash2,
} from "lucide-react";
import { ActionIcon, Button, Group, Menu, Tooltip } from "@mantine/core";
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
import type { RuleEngineRecord } from "@/types/rule-engine";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@edr/ui-common";
export interface RuleEngineRecordActionsProps {
record: RuleEngineRecord;
@@ -29,6 +23,16 @@ export interface RuleEngineRecordActionsProps {
readOnly?: boolean;
}
const actionGroupStyle = {
display: "inline-flex",
alignItems: "center",
gap: 4,
borderRadius: 10,
border: "1px solid var(--mantine-color-gray-2)",
background: "var(--mantine-color-gray-0)",
padding: 3,
} as const;
const RuleEngineRecordActions = ({
record,
config,
@@ -43,95 +47,209 @@ const RuleEngineRecordActions = ({
const status = String(record.status ?? "");
const hasRateActions =
config.slug === "rates" && (status === "DRAFT" || status === "PENDING_APPROVAL");
const iconBtnClass =
layout === "compact"
? "h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
: "h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground";
const showViewChain = config.slug === "approval-rules" && onViewChain;
if (readOnly) {
return onViewChain ? (
<Button
type="button"
variant="ghost"
size="icon"
className={iconBtnClass}
onClick={onViewChain}
aria-label="View chain"
>
<Eye className="h-4 w-4" />
</Button>
) : null;
}
return (
<div className="flex items-center justify-end gap-0.5">
<Button
type="button"
variant="ghost"
size="icon"
className={iconBtnClass}
onClick={() => onEdit(record)}
aria-label="Edit"
>
<Pencil className="h-4 w-4" />
</Button>
{config.slug === "approval-rules" && onViewChain ? (
<Button
type="button"
variant="ghost"
size="icon"
className={iconBtnClass}
return showViewChain ? (
<Tooltip label="View approval chain">
<ActionIcon
variant="subtle"
color="gray"
size="md"
radius="md"
onClick={onViewChain}
aria-label="View approval chain"
>
<Eye className="h-4 w-4" />
</Button>
<Eye size={16} />
</ActionIcon>
</Tooltip>
) : null;
}
if (layout === "compact") {
return (
<Group gap={6} wrap="nowrap" justify="flex-end">
{showViewChain ? (
<Button
variant="light"
color="gray"
size="compact-sm"
radius="md"
onClick={onViewChain}
leftSection={<Eye size={14} />}
>
Chain
</Button>
) : null}
{hasRateActions ? (
<RateActionsMenu
status={status}
onSubmitRate={onSubmitRate}
onApproveRate={onApproveRate}
record={record}
compact
/>
) : null}
<div style={actionGroupStyle}>
<Button
variant="subtle"
color="gray"
size="compact-sm"
radius="md"
onClick={() => onEdit(record)}
leftSection={<Pencil size={14} />}
styles={{ root: { fontWeight: 600 } }}
>
Edit
</Button>
<Button
variant="subtle"
color="red"
size="compact-sm"
radius="md"
onClick={() => onDelete(record)}
leftSection={<Trash2 size={14} />}
styles={{ root: { fontWeight: 600 } }}
>
Delete
</Button>
</div>
</Group>
);
}
return (
<Group gap={6} wrap="nowrap" justify="flex-end">
{showViewChain ? (
<Tooltip label="View approval chain">
<ActionIcon
variant="light"
color="gray"
size="md"
radius="md"
onClick={onViewChain}
aria-label="View approval chain"
style={{
border: "1px solid var(--mantine-color-gray-2)",
background: "white",
}}
>
<Eye size={16} />
</ActionIcon>
</Tooltip>
) : null}
{hasRateActions ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={iconBtnClass}
aria-label="More actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{status === "DRAFT" && onSubmitRate ? (
<DropdownMenuItem onSelect={() => onSubmitRate(record.id)}>
<Send />
Submit for approval
</DropdownMenuItem>
) : null}
{status === "PENDING_APPROVAL" && onApproveRate ? (
<DropdownMenuItem onSelect={() => onApproveRate(record)}>
<CheckCircle2 />
Approve
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
<RateActionsMenu
status={status}
onSubmitRate={onSubmitRate}
onApproveRate={onApproveRate}
record={record}
/>
) : null}
<Button
type="button"
variant="ghost"
size="icon"
className={`${iconBtnClass} hover:bg-red-50 hover:text-red-600`}
onClick={() => onDelete(record)}
aria-label="Delete"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div style={actionGroupStyle}>
<Tooltip label="Edit">
<ActionIcon
variant="subtle"
color="gray"
size="md"
radius="md"
onClick={() => onEdit(record)}
aria-label="Edit record"
style={{
background: "white",
}}
>
<Pencil size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete">
<ActionIcon
variant="subtle"
color="red"
size="md"
radius="md"
onClick={() => onDelete(record)}
aria-label="Delete record"
style={{
background: "var(--mantine-color-red-0)",
}}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</div>
</Group>
);
};
function RateActionsMenu({
status,
onSubmitRate,
onApproveRate,
record,
compact = false,
}: {
status: string;
onSubmitRate?: (id: string) => void;
onApproveRate?: (record: RuleEngineRecord) => void;
record: RuleEngineRecord;
compact?: boolean;
}) {
return (
<Menu position="bottom-end" shadow="md" withinPortal>
<Menu.Target>
{compact ? (
<Button
variant="light"
color="gray"
size="compact-sm"
radius="md"
leftSection={<MoreHorizontal size={14} />}
>
More
</Button>
) : (
<Tooltip label="More actions">
<ActionIcon
variant="light"
color="gray"
size="md"
radius="md"
aria-label="More actions"
style={{
border: "1px solid var(--mantine-color-gray-2)",
background: "white",
}}
>
<MoreHorizontal size={16} />
</ActionIcon>
</Tooltip>
)}
</Menu.Target>
<Menu.Dropdown>
{status === "DRAFT" && onSubmitRate ? (
<Menu.Item
leftSection={<Send size={14} />}
onClick={() => onSubmitRate(record.id)}
>
Submit for approval
</Menu.Item>
) : null}
{status === "PENDING_APPROVAL" && onApproveRate ? (
<Menu.Item
leftSection={<CheckCircle2 size={14} />}
onClick={() => onApproveRate(record)}
>
Approve
</Menu.Item>
) : null}
</Menu.Dropdown>
</Menu>
);
}
export default RuleEngineRecordActions;

View File

@@ -1,10 +1,7 @@
import { Filter, LayoutGrid, Plus, Search, Table2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button, Input } from "@edr/ui-common";
import { LayoutGrid, Plus, Search, Table2 } from "lucide-react";
import { Button, TextInput, Group, SegmentedControl } from "@mantine/core";
import type { RuleEngineViewMode } from "./useRuleEngineViewMode";
import { ruleEngineToolbar } from "./ruleEngineStyles";
export interface RuleEngineToolbarProps {
search: string;
@@ -25,70 +22,72 @@ const RuleEngineToolbar = ({
viewMode,
onViewModeChange,
}: RuleEngineToolbarProps) => (
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="relative min-w-0 flex-1 lg:max-w-md">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={searchPlaceholder}
className={ruleEngineToolbar.search}
<Group gap="md" justify="space-between" align="center" wrap="nowrap">
<TextInput
placeholder={searchPlaceholder}
value={search}
onChange={(e) => onSearchChange(e.currentTarget.value)}
leftSection={<Search size={18} />}
size="md"
radius="lg"
style={{ flex: 1, minWidth: 0 }}
styles={{
input: {
borderColor: "var(--mantine-color-gray-3)",
},
}}
/>
<Group gap="md" align="center" justify="flex-end" wrap="nowrap">
<SegmentedControl
value={viewMode}
onChange={(value) => onViewModeChange(value as RuleEngineViewMode)}
size="sm"
radius="lg"
color="green"
data={[
{
value: "table",
label: (
<Group gap={6} justify="center" wrap="nowrap">
<Table2 size={16} />
<span>Table</span>
</Group>
),
},
{
value: "cards",
label: (
<Group gap={6} justify="center" wrap="nowrap">
<LayoutGrid size={16} />
<span>Cards</span>
</Group>
),
},
]}
styles={{
root: {
background: "var(--mantine-color-gray-1)",
},
}}
/>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<div
className={ruleEngineToolbar.viewToggleGroup}
role="group"
aria-label="View mode"
>
<button
type="button"
onClick={() => onViewModeChange("table")}
className={cn(
ruleEngineToolbar.viewToggleBtn,
viewMode === "table"
? ruleEngineToolbar.viewToggleActive
: ruleEngineToolbar.viewToggleIdle,
)}
aria-pressed={viewMode === "table"}
>
<Table2 className="h-4 w-4" />
Table
</button>
<button
type="button"
onClick={() => onViewModeChange("cards")}
className={cn(
ruleEngineToolbar.viewToggleBtn,
viewMode === "cards"
? ruleEngineToolbar.viewToggleActive
: ruleEngineToolbar.viewToggleIdle,
)}
aria-pressed={viewMode === "cards"}
>
<LayoutGrid className="h-4 w-4" />
Cards
</button>
</div>
<Button
type="button"
variant="outline"
className={cn(ruleEngineToolbar.actionBtn, "gap-2 px-3")}
>
<Filter className="h-4 w-4" />
Filter
</Button>
{onAdd ? (
<Button type="button" className={ruleEngineToolbar.primaryBtn} onClick={onAdd}>
<Plus className="h-4 w-4" />
<Button
onClick={onAdd}
leftSection={<Plus size={18} />}
size="sm"
radius="lg"
color="green"
variant="filled"
fw={600}
style={{ whiteSpace: "nowrap" }}
>
{addLabel}
</Button>
) : null}
</div>
</div>
</Group>
</Group>
);
export default RuleEngineToolbar;

View File

@@ -6,6 +6,7 @@ import type {
const TITLE_KEY_PRIORITY = [
"cargoTypeName",
"serviceName",
"name",
"label",
"actionLabel",
"rateType",

View File

@@ -1,30 +1,49 @@
import type { ReactNode } from "react";
import { Badge, Text } from "@mantine/core";
import { cn } from "@/lib/utils";
import type { ColumnFormat } from "@/pages/ruleEngine/config/resources";
import { Badge } from "@edr/ui-common";
const statusBadgeClass = (active: boolean) =>
cn(
"rounded-sm px-2 py-0.5 text-xs font-medium",
active
? "border-emerald-200 bg-emerald-50 text-emerald-800"
: "border-border bg-muted text-muted-foreground",
const extractLabel = (value: unknown): string | null => {
if (!value || typeof value !== "object") return null;
const obj = value as Record<string, unknown>;
return (
(typeof obj.label === "string" ? obj.label : null) ||
(typeof obj.cargoTypeName === "string" ? obj.cargoTypeName : null) ||
(typeof obj.code === "string" ? obj.code : null) ||
(typeof obj.name === "string" ? obj.name : null) ||
(typeof obj.actionLabel === "string" ? obj.actionLabel : null) ||
null
);
};
export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode => {
if (value === null || value === undefined || value === "") {
return <span className="text-muted-foreground"></span>;
return <Text size="sm" c="dimmed"></Text>;
}
// Handle stringified objects (e.g., "[object Object]")
if (typeof value === "string" && value.trim() === "[object Object]") {
return <Text size="sm" c="dimmed"></Text>;
}
if (format === "boolean") {
return value ? "Yes" : "No";
if (typeof value === "string") {
const boolVal = value.toLowerCase() === "true" || value === "1";
return <Text size="sm">{boolVal ? "✓ Yes" : "✗ No"}</Text>;
}
return <Text size="sm">{value ? "✓ Yes" : "✗ No"}</Text>;
}
if (format === "activeBadge") {
const active = Boolean(value);
return (
<Badge variant="outline" className={statusBadgeClass(active)}>
<Badge
color={active ? "green" : "gray"}
variant={active ? "filled" : "light"}
size="sm"
radius="md"
>
{active ? "Active" : "Inactive"}
</Badge>
);
@@ -32,14 +51,16 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
if (format === "rateStatus") {
const status = String(value);
const tone =
const color =
status === "LIVE"
? "border-emerald-200 bg-emerald-50 text-emerald-800"
? "green"
: status === "DRAFT"
? "border-amber-200 bg-amber-50 text-amber-800"
: "border-sky-200 bg-sky-50 text-sky-800";
? "yellow"
: status === "PENDING_APPROVAL"
? "orange"
: "blue";
return (
<Badge variant="outline" className={cn("rounded-sm font-medium", tone)}>
<Badge color={color} variant="filled" size="sm" radius="md">
{status}
</Badge>
);
@@ -48,38 +69,50 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
if (format === "code") {
return (
<Badge
variant="secondary"
className="rounded-sm border border-border bg-muted/80 font-mono text-[11px] font-medium text-foreground"
variant="light"
color="blue"
size="sm"
radius="md"
style={{
fontFamily: "monospace",
fontSize: "0.75rem",
fontWeight: 600,
letterSpacing: "0.05em",
}}
>
{String(value)}
{String(value).toUpperCase()}
</Badge>
);
}
if (format === "date") {
const d = new Date(String(value));
return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleDateString();
if (Number.isNaN(d.getTime())) return <Text size="sm">{String(value)}</Text>;
return <Text size="sm">{d.toLocaleDateString()}</Text>;
}
if (Array.isArray(value)) {
return (
<Text size="sm">{value.length > 0 ? value.join(", ") : "—"}</Text>
);
}
if (format === "entityLabel" && value && typeof value === "object") {
const entity = value as { label?: string; code?: string; cargoTypeName?: string };
const label =
entity.label?.trim() ||
entity.cargoTypeName?.trim() ||
entity.code?.trim();
return label ? (
<span>{label}</span>
) : (
<span className="text-muted-foreground"></span>
);
const label = extractLabel(value);
if (label) {
return <Text size="sm">{label}</Text>;
}
return <Text size="sm" c="dimmed"></Text>;
}
if (format === "rateLabel") {
if (!value || typeof value !== "object") {
return value ? (
<span className="font-mono text-xs text-muted-foreground">{String(value)}</span>
<Text size="sm" c="dimmed" style={{ fontFamily: "monospace" }}>
{String(value)}
</Text>
) : (
<span className="text-muted-foreground"></span>
<Text size="sm" c="dimmed"></Text>
);
}
const rate = value as {
@@ -95,11 +128,17 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
rate.rateUnit?.replace(/_/g, " "),
].filter(Boolean);
return parts.length > 0 ? (
<span>{parts.join(" · ")}</span>
<Text size="sm">{parts.join(" · ")}</Text>
) : (
<span className="text-muted-foreground"></span>
<Text size="sm" c="dimmed"></Text>
);
}
return String(value);
if (typeof value === "object") {
const label = extractLabel(value);
if (label) return <Text size="sm">{label}</Text>;
return <Text size="sm" c="dimmed"></Text>;
}
return <Text size="sm">{String(value)}</Text>;
};

View File

@@ -3,7 +3,7 @@
export const ruleEngineSurface = {
pageCard:
"overflow-hidden rounded-lg border border-border bg-card shadow-sm",
pageCardToolbar: "border-b border-border bg-muted/30 px-4 py-3 sm:px-5 sm:py-3.5",
pageCardToolbar: "border-b border-border bg-muted/30 px-3 py-2.5 sm:px-4 sm:py-3",
dialog: "max-h-[90vh] overflow-y-auto rounded-lg border-border sm:max-w-lg",
dialogSm: "rounded-lg border-border sm:max-w-md",
} as const;
@@ -24,30 +24,30 @@ export const ruleEngineField = {
export const ruleEngineToolbar = {
search:
"h-10 rounded-md border border-input bg-background pl-10 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30",
"h-9 rounded-md border border-input bg-background pl-9 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 sm:h-10 sm:pl-10",
viewToggleGroup:
"flex h-10 items-center rounded-md border border-border bg-muted/40 p-0.5",
"flex h-9 items-center rounded-md border border-border bg-muted/40 p-0.5 sm:h-10",
viewToggleBtn:
"inline-flex h-8 items-center gap-1.5 rounded-sm px-3 text-sm font-medium transition-colors",
"inline-flex h-7 items-center gap-1 rounded-sm px-2 text-xs font-medium transition-colors sm:h-8 sm:gap-1.5 sm:px-3 sm:text-sm",
viewToggleActive: "bg-background text-foreground shadow-sm",
viewToggleIdle: "text-muted-foreground hover:bg-background/60 hover:text-foreground",
actionBtn: "h-10 rounded-md shadow-xs",
primaryBtn: "h-10 gap-2 rounded-md px-4 text-sm font-medium shadow-xs",
actionBtn: "h-9 rounded-md shadow-xs sm:h-10",
primaryBtn: "h-9 gap-1.5 rounded-md px-3 text-xs font-medium shadow-xs sm:h-10 sm:gap-2 sm:px-4 sm:text-sm",
} as const;
export const ruleEngineCard = {
article:
"group flex flex-col overflow-hidden rounded-lg border border-border bg-card shadow-sm transition-shadow duration-200 hover:shadow-md",
header: "border-b border-border bg-muted/25 px-4 py-3.5",
header: "border-b border-border bg-muted/25 px-3 py-2.5 sm:px-4 sm:py-3.5",
avatar:
"flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-primary/12 text-sm font-semibold text-primary",
title: "truncate text-[15px] font-semibold text-foreground",
"flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-[#f1f5f9] text-xs font-semibold text-slate-600 sm:h-10 sm:w-10 sm:text-sm",
title: "truncate text-sm font-semibold text-foreground sm:text-[15px]",
meta: "text-xs text-muted-foreground",
detailLabel:
"text-[11px] font-medium uppercase tracking-wide text-muted-foreground",
detailValue: "mt-0.5 text-sm text-foreground",
footer: "mt-auto border-t border-border bg-muted/20 px-3 py-2.5",
skeleton: "animate-pulse rounded-lg border border-border bg-muted/30 p-4",
"text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-[11px]",
detailValue: "mt-0.5 text-xs text-foreground sm:text-sm",
footer: "mt-auto border-t border-border bg-muted/20 px-2 py-2 sm:px-3 sm:py-2.5",
skeleton: "animate-pulse rounded-lg border border-border bg-muted/30 p-3 sm:p-4",
} as const;
export const ruleEngineTable = {

View File

@@ -20,7 +20,7 @@ export default function Breadcrumbs({ items }: BreadcrumbsProps) {
<Link
to="/"
aria-label="Home"
className="flex items-center transition hover:text-[#10B981]"
className="flex items-center transition hover:text-[var(--freight-brand)]"
>
{/* <Home className="h-4 w-4" /> */}
Dashboard
@@ -36,7 +36,7 @@ export default function Breadcrumbs({ items }: BreadcrumbsProps) {
{item.href && !isLast ? (
<Link
to={item.href}
className="transition hover:text-[#10B981]"
className="transition hover:text-[var(--freight-brand)]"
>
{item.label}
</Link>