style: ui revamp

This commit is contained in:
ghost2023
2026-06-09 09:27:14 +03:00
parent 41061462ff
commit 4566033046
16 changed files with 2188 additions and 2308 deletions

View File

@@ -5,6 +5,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EDR Freight Portal</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@500;600;700;800&display=swap"
rel="stylesheet"
/>
</head>
<body>

View File

@@ -19,6 +19,7 @@ import { useDisclosure } from "@mantine/hooks";
import {
Bell,
ChevronDown,
ChevronRight,
Languages,
LogOut,
Moon,
@@ -93,14 +94,24 @@ function getActivePage(
return null;
}
// Shared NavLink styling — green tint only when active, quiet neutral otherwise.
const navLinkStyles = {
root: {
borderRadius: "var(--mantine-radius-md)",
fontWeight: 500,
},
label: { fontSize: "var(--mantine-font-size-sm)" },
} as const;
// NavLink classNames for the dark sidebar — Tailwind utilities (with v4 `!`
// important suffix) override Mantine's default active styling.
const navClassNames = (active: boolean) => {
const base =
"rounded-[10px] font-medium transition-all duration-150 active:scale-[0.98]";
if (active) {
return {
root: `${base} bg-gradient-to-br! from-emerald-600! to-emerald-500! text-white! shadow-[0_2px_8px_-4px_rgba(16,185,129,0.45)]`,
label: "text-white!",
section: "text-white!",
};
}
return {
root: `${base} text-white/60! hover:bg-white/[0.07]! hover:text-white!`,
label: "text-inherit!",
section: "text-inherit! opacity-90",
};
};
export function AppLayout({
title = "EDR Freight",
@@ -139,7 +150,7 @@ export function AppLayout({
<AppShell
layout="alt"
navbar={{
width: 256,
width: 264,
breakpoint: "sm",
collapsed: { mobile: !mobileOpen },
}}
@@ -148,8 +159,8 @@ export function AppLayout({
>
{/* ── Header ──────────────────────────────────────────────────────────── */}
<AppShell.Header
withBorder
style={{ background: "var(--mantine-color-body)" }}
withBorder={false}
className="border-b border-[var(--mantine-color-gray-2)]! bg-white/70! backdrop-blur-md backdrop-saturate-150"
>
<Group h="100%" px="lg" justify="space-between">
<Group gap="sm">
@@ -159,9 +170,24 @@ export function AppLayout({
hiddenFrom="sm"
size="sm"
/>
<Text fw={600} size="md">
{activePage ? activePage.label : title}
</Text>
<Group gap={6} wrap="nowrap">
<Text
size="sm"
className="font-medium text-[var(--mantine-color-gray-5)]"
visibleFrom="xs"
>
{title}
</Text>
<Box visibleFrom="xs" className="flex">
<ChevronRight size={14} color="var(--mantine-color-gray-4)" />
</Box>
<Text
size="md"
className="font-semibold tracking-tight text-[var(--mantine-color-gray-8)]"
>
{activePage ? activePage.label : title}
</Text>
</Group>
</Group>
{/* Right: utility actions + user menu */}
@@ -170,16 +196,20 @@ export function AppLayout({
variant="subtle"
color="gray"
size="lg"
radius="md"
className="transition-transform hover:-translate-y-px"
aria-label="Change language"
>
<Languages size={18} />
</ActionIcon>
<Indicator color="red" size={7} offset={5} zIndex={10}>
<Indicator color="red" size={7} offset={6} zIndex={10} withBorder>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
radius="md"
className="transition-transform hover:-translate-y-px"
aria-label="Notifications"
>
<Bell size={18} />
@@ -191,6 +221,8 @@ export function AppLayout({
variant="subtle"
color="gray"
size="lg"
radius="md"
className="transition-transform hover:-translate-y-px"
onClick={toggleTheme}
aria-label="Toggle theme"
>
@@ -198,38 +230,42 @@ export function AppLayout({
</ActionIcon>
)}
<Divider orientation="vertical" my={14} mx={6} />
<Menu
width={220}
width={232}
position="bottom-end"
withinPortal
shadow="md"
offset={8}
radius="md"
>
<Menu.Target>
<UnstyledButton
px={6}
py={4}
style={{ borderRadius: "var(--mantine-radius-md)" }}
className="rounded-[10px] transition-colors hover:bg-[var(--mantine-color-gray-1)]"
>
<Group gap={8} wrap="nowrap">
<Avatar color="edr-green" radius="xl" size={30}>
<Text fw={600} fz={11}>
<Avatar
radius="xl"
size={32}
gradient={{ from: "edr-green.5", to: "edr-green.8", deg: 135 }}
variant="gradient"
>
<Text fw={600} fz={11} c="white">
{initials}
</Text>
</Avatar>
<Text
size="sm"
fw={500}
visibleFrom="sm"
maw={120}
truncate
>
{userName}
</Text>
<ChevronDown
size={14}
color="var(--mantine-color-gray-5)"
/>
<Box visibleFrom="sm" className="min-w-0">
<Text size="sm" fw={600} maw={120} truncate lh={1.2}>
{userName}
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
Customer
</Text>
</Box>
<ChevronDown size={14} color="var(--mantine-color-gray-5)" />
</Group>
</UnstyledButton>
</Menu.Target>
@@ -267,49 +303,29 @@ export function AppLayout({
{/* ── Sidebar ─────────────────────────────────────────────────────────── */}
<AppShell.Navbar
withBorder
style={{
// Soft, near-neutral gray — warm enough to avoid the cold blue tint.
background: "#f7f7f6",
display: "flex",
flexDirection: "column",
}}
withBorder={false}
className="flex flex-col border-r border-white/[0.06]! bg-gradient-to-b from-[#1a2230] to-[#141a26]"
>
{/* Brand */}
<Box
h={60}
px="md"
style={{
display: "flex",
alignItems: "center",
borderBottom: "1px solid var(--mantine-color-gray-2)",
flexShrink: 0,
}}
>
<Box className="flex h-[60px] flex-shrink-0 items-center border-b border-white/[0.07] px-4">
<Group gap="sm" wrap="nowrap">
<Box
style={{
width: 32,
height: 32,
borderRadius: "var(--mantine-radius-md)",
background: "var(--mantine-color-edr-green-6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Box className="flex h-[34px] w-[34px] flex-shrink-0 items-center justify-center rounded-[10px] bg-gradient-to-br from-emerald-500 to-emerald-700 shadow-[inset_0_1px_0_rgba(255,255,255,0.2)]">
<Train size={18} color="white" strokeWidth={2.2} />
</Box>
<Text fw={650} size="md">
{title}
</Text>
<Box>
<Text className="font-bold leading-tight tracking-tight text-white" size="md">
{title}
</Text>
<Text className="text-[10px] font-semibold uppercase tracking-[0.12em] text-white/40">
Logistics Portal
</Text>
</Box>
</Group>
</Box>
{/* Nav links */}
<ScrollArea flex={1} type="never" p="sm">
<Stack gap={2}>
<Stack gap={3}>
{sidebarItems.map((item, i) => {
const active = isItemActive(item);
const hasChildren = !!item.children?.length;
@@ -324,13 +340,11 @@ export function AppLayout({
<Text
key={`section-${item.section}`}
size="xs"
fw={600}
c="dimmed"
tt="uppercase"
px="sm"
mt={i === 0 ? 0 : "md"}
mb={4}
style={{ letterSpacing: "0.06em" }}
mt={i === 0 ? 4 : "md"}
mb={6}
className="font-bold tracking-[0.1em] text-white/30"
>
{item.section}
</Text>
@@ -344,23 +358,18 @@ export function AppLayout({
label={item.label}
leftSection={item.icon}
active={active || childActive}
color="edr-green"
variant="filled"
defaultOpened={childActive}
styles={navLinkStyles}
classNames={navClassNames(active || childActive)}
>
{item.children!.map((child) => {
const cActive =
activePath === child.href.toLowerCase();
const cActive = activePath === child.href.toLowerCase();
return (
<NavLink
key={child.href}
label={child.label}
active={cActive}
color="edr-green"
variant="filled"
onClick={() => navigate(child.href)}
styles={navLinkStyles}
classNames={navClassNames(cActive)}
/>
);
})}
@@ -376,10 +385,8 @@ export function AppLayout({
label={item.label}
leftSection={item.icon}
active={active}
color="edr-green"
variant="filled"
onClick={() => navigate(item.href)}
styles={navLinkStyles}
classNames={navClassNames(active)}
/>
</Fragment>
);
@@ -388,26 +395,24 @@ export function AppLayout({
</ScrollArea>
{/* Bottom user */}
<Box
px="md"
py="sm"
style={{
borderTop: "1px solid var(--mantine-color-gray-2)",
flexShrink: 0,
}}
>
<Box className="m-3 flex-shrink-0 rounded-xl border border-white/[0.07] bg-white/[0.04] p-2.5">
<Group gap="sm" wrap="nowrap">
<Avatar color="edr-green" radius="xl" size={30}>
<Text fw={600} fz={11}>
<Avatar
radius="xl"
size={34}
gradient={{ from: "edr-green.5", to: "edr-green.8", deg: 135 }}
variant="gradient"
>
<Text fw={600} fz={12} c="white">
{initials}
</Text>
</Avatar>
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
<Box className="min-w-0">
<Text size="sm" fw={600} truncate className="text-white">
{userName}
</Text>
{userEmail && (
<Text size="xs" c="dimmed" truncate>
<Text size="xs" truncate className="text-white/40">
{userEmail}
</Text>
)}
@@ -417,7 +422,7 @@ export function AppLayout({
</AppShell.Navbar>
{/* ── Main ────────────────────────────────────────────────────────────── */}
<AppShell.Main style={{ background: "var(--mantine-color-body)" }}>
<AppShell.Main className="bg-[var(--mantine-color-gray-0)]">
{children}
</AppShell.Main>
</AppShell>

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { Box, Group, Stack, Text, ThemeIcon, Title } from "@mantine/core";
import { ShieldCheck, Train } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -27,67 +28,102 @@ export default function AuthLayout({
left,
}: AuthLayoutProps) {
return (
<div className="min-h-screen bg-background text-foreground">
<div className={cn("grid min-h-screen lg:grid-cols-2", parentClassName)}>
<div className="relative hidden overflow-hidden bg-primary p-8 px-12 text-primary-foreground lg:flex lg:flex-col lg:justify-between">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.14),transparent_35%)]" />
<div className="relative z-10">
<div className="flex items-center gap-3">
<div className="flex size-14 items-center justify-center rounded-3xl bg-white/10 backdrop-blur">
<Train className="size-7" />
</div>
<div>
<h1 className="text-xl font-bold">EDR Freight</h1>
<p className="mt-1 text-sm opacity-80">
Railway Logistics Platform
</p>
</div>
</div>
<div className="mt-16 max-w-lg">
<div className="inline-flex rounded-full bg-white/10 px-4 py-2 text-sm font-semibold backdrop-blur">
{left.badge}
</div>
<h2 className="mt-6 text-4xl font-bold leading-tight tracking-tight">
{left.title}
</h2>
<p className="mt-6 text-lg leading-8 opacity-85">
{left.description}
</p>
</div>
<div className="mt-14 grid gap-5">
<Box className="min-h-screen bg-white text-[var(--mantine-color-gray-9)]">
<Box className={cn("grid min-h-screen lg:grid-cols-2", parentClassName)}>
{/* ── Left: branded panel ─────────────────────────────────────── */}
<Box className="relative hidden overflow-hidden bg-gradient-to-br from-emerald-900 via-emerald-700 to-emerald-600 px-12 py-10 text-white lg:flex lg:flex-col lg:justify-between">
{/* rail-line motif */}
<Box
aria-hidden
className="pointer-events-none absolute inset-y-0 right-0 w-[360px] opacity-10 bg-[repeating-linear-gradient(90deg,#fff_0,#fff_2px,transparent_2px,transparent_18px)] [mask-image:linear-gradient(90deg,transparent_0%,#000_90%)] [-webkit-mask-image:linear-gradient(90deg,transparent_0%,#000_90%)]"
/>
{/* corner glow */}
<Box
aria-hidden
className="pointer-events-none absolute -right-24 -top-24 h-[320px] w-[320px] rounded-full blur-2xl bg-[radial-gradient(circle,rgba(255,255,255,0.16),transparent_70%)]"
/>
{/* Brand */}
<Group gap="sm" className="relative z-10">
<Box className="flex size-14 items-center justify-center rounded-3xl border border-white/20 bg-white/10 backdrop-blur">
<Train className="size-7" />
</Box>
<Box>
<Text fw={700} fz={20} className="leading-tight">
EDR Freight
</Text>
<Text fz="sm" className="text-white/70">
Railway Logistics Platform
</Text>
</Box>
</Group>
{/* Headline + features */}
<Box className="relative z-10 my-10 max-w-lg">
<Box className="inline-flex rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-semibold backdrop-blur">
{left.badge}
</Box>
<Title
order={1}
mt="lg"
c="white"
className="text-4xl! font-bold! leading-tight tracking-tight"
>
{left.title}
</Title>
<Text mt="lg" fz="lg" className="leading-8 text-white/85">
{left.description}
</Text>
<Stack gap="md" mt={40}>
{left.features.map((item) => (
<div key={item} className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-2xl bg-white/10">
<Group key={item} gap="sm" wrap="nowrap">
<Box className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-white/10">
<ShieldCheck className="size-5" />
</div>
<span className="font-medium">{item}</span>
</div>
</Box>
<Text fw={500} className="text-white/95">
{item}
</Text>
</Group>
))}
</div>
</div>
</div>
<div
</Stack>
</Box>
{/* spacer keeps brand pinned top / content centered */}
<Box className="relative z-10" />
</Box>
{/* ── Right: form area ────────────────────────────────────────── */}
<Box
className={cn(
"flex items-center justify-center p-6 md:p-10",
"flex items-center justify-center bg-[var(--mantine-color-gray-0)] p-6 md:p-10",
contentClassName,
)}
>
<div className="w-full max-w-lg">
<div className="mb-8 flex items-center gap-3 lg:hidden">
<div className="flex size-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Box className="w-full max-w-lg">
{/* Mobile brand */}
<Group gap="sm" mb="xl" className="lg:hidden">
<ThemeIcon
size={48}
radius="lg"
variant="gradient"
gradient={{ from: "edr-green.5", to: "edr-green.7", deg: 135 }}
>
<Train className="size-6" />
</div>
<div>
<h1 className="text-2xl font-bold">EDR Freight</h1>
<p className="text-sm text-muted-foreground">
</ThemeIcon>
<Box>
<Text fw={700} fz={22}>
EDR Freight
</Text>
<Text fz="sm" c="dimmed">
Railway Logistics Platform
</p>
</div>
</div>
<div>{children}</div>
</div>
</div>
</div>
</div>
</Text>
</Box>
</Group>
<Box>{children}</Box>
</Box>
</Box>
</Box>
</Box>
);
}

View File

@@ -4,6 +4,7 @@ import { format } from "date-fns";
import { useQuery } from "@tanstack/react-query";
import {
Anchor,
Avatar,
Badge,
Box,
Button,
@@ -30,6 +31,7 @@ import {
Plus,
Receipt,
Train,
TrendingUp,
UploadCloud,
} from "lucide-react";
@@ -71,6 +73,10 @@ const INVOICE_STATUS_META: Record<InvoiceStatus, { color: string }> = {
Cancelled: { color: "gray" },
};
// Card hover-lift, shared via Tailwind utilities.
const LIFT =
"transition-all duration-200 hover:-translate-y-[3px] hover:shadow-[0_16px_34px_-16px_rgba(16,24,40,0.22)] hover:border-emerald-300!";
export default function MyPortalPage() {
const { user, customer } = useAuth();
const myShipments = useMemo(() => getMyShipments(), []);
@@ -109,39 +115,30 @@ export default function MyPortalPage() {
const paidPct = Math.round((completedInvoices / invoiceTotal) * 100);
const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—";
const initials = displayName
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((n) => n[0]?.toUpperCase())
.join("");
const documentsComplete = !!(customer as any)?.documentsComplete;
const hasOutstanding = outstandingInvoices.length > 0;
const today = format(new Date(), "EEEE, MMMM d");
return (
<Stack gap="xl" p="xl" maw={1240} mx="auto">
<Stack gap="xl" p="xl" maw={1280} mx="auto">
{/* ── Document setup notice ───────────────────────────────────── */}
{!documentsComplete && !dismissed && (
<Group
align="center"
gap="md"
style={{
borderRadius: "var(--mantine-radius-xl)",
background: "#fffbeb",
border: "1px solid #fde68a",
padding: "16px 20px",
}}
className="rounded-2xl border border-amber-200 bg-gradient-to-r from-amber-50 to-amber-100 px-5 py-4"
>
<Box
style={{
width: 40,
height: 40,
borderRadius: 11,
flexShrink: 0,
background: "#f59e0b",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<UploadCloud size={19} color="white" strokeWidth={2} />
<Box className="flex h-[42px] w-[42px] flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-amber-400 to-amber-500 shadow-[0_6px_16px_-6px_rgba(245,158,11,0.7)]">
<UploadCloud size={20} color="white" strokeWidth={2} />
</Box>
<Box style={{ flex: 1 }}>
<Box className="flex-1">
<Text fw={700} size="lg" c="#78350f" lh={1.3}>
Finish setting up your account
</Text>
@@ -157,12 +154,7 @@ export default function MyPortalPage() {
to="/settings?tab=documents"
size="sm"
radius="xl"
style={{
background: "#f59e0b",
color: "white",
fontWeight: 600,
flexShrink: 0,
}}
className="flex-shrink-0 bg-gradient-to-br from-amber-500 to-amber-600! font-semibold text-white!"
rightSection={<ArrowRight size={16} />}
>
Upload docs
@@ -170,56 +162,58 @@ export default function MyPortalPage() {
</Group>
)}
{/* ── Welcome (branded band) ──────────────────────────────────── */}
<Box
p="xl"
style={{
position: "relative",
overflow: "hidden",
borderRadius: "var(--mantine-radius-lg)",
border: "1px solid var(--mantine-color-edr-green-1)",
background:
"linear-gradient(120deg, var(--mantine-color-edr-green-0) 0%, var(--mantine-color-body) 60%)",
}}
>
{/* ── Welcome (branded hero) ──────────────────────────────────── */}
<Box className="relative overflow-hidden rounded-[20px] bg-gradient-to-br from-emerald-900 via-emerald-700 to-emerald-600 p-9 text-white shadow-[0_14px_32px_-20px_rgba(16,24,40,0.4)]">
{/* faint rail-line motif */}
<Box
aria-hidden
style={{
position: "absolute",
inset: 0,
left: "auto",
width: 240,
opacity: 0.5,
backgroundImage:
"repeating-linear-gradient(90deg, var(--mantine-color-edr-green-2) 0, var(--mantine-color-edr-green-2) 2px, transparent 2px, transparent 16px)",
maskImage:
"linear-gradient(90deg, transparent 0%, black 100%)",
WebkitMaskImage:
"linear-gradient(90deg, transparent 0%, black 100%)",
pointerEvents: "none",
}}
className="pointer-events-none absolute inset-y-0 right-0 w-[320px] opacity-10 bg-[repeating-linear-gradient(90deg,#fff_0,#fff_2px,transparent_2px,transparent_18px)] [mask-image:linear-gradient(90deg,transparent_0%,#000_90%)] [-webkit-mask-image:linear-gradient(90deg,transparent_0%,#000_90%)]"
/>
<Box
aria-hidden
className="pointer-events-none absolute -bottom-32 left-[35%] h-[200px] w-[200px] rounded-full blur-xl bg-[radial-gradient(circle,rgba(255,255,255,0.12),transparent_70%)]"
/>
<Group
justify="space-between"
align="flex-end"
wrap="wrap"
gap="md"
style={{ position: "relative" }}
gap="lg"
className="relative"
>
<Box>
<Text size="sm" c="edr-green.7" fw={600}>
Welcome back
</Text>
<Title order={1} mt={4}>
{displayName}
</Title>
</Box>
<Group gap="md" wrap="nowrap" align="center">
<Avatar
radius="xl"
size={56}
className="border border-white/30 bg-white/[0.16] backdrop-blur-sm"
>
<Text fw={700} fz={18} c="white">
{initials}
</Text>
</Avatar>
<Box>
<Text
size="xs"
fw={600}
tt="uppercase"
className="tracking-[0.1em] text-white/75"
>
{today}
</Text>
<Title order={1} mt={2} c="white" className="tracking-[-0.02em]">
Welcome back, {displayName.split(" ")[0]}
</Title>
<Text size="sm" mt={4} className="text-white/[0.78]">
Here's what's moving across your account today.
</Text>
</Box>
</Group>
<Button
component={Link}
to="/bookings/new"
color="edr-green"
leftSection={<Plus size={16} />}
size="md"
radius="xl"
leftSection={<Plus size={17} />}
className="bg-white! font-semibold text-emerald-800! shadow-[0_10px_24px_-10px_rgba(0,0,0,0.4)]"
>
New Booking
</Button>
@@ -231,13 +225,13 @@ export default function MyPortalPage() {
<StatCard
label="Active Bookings"
value={myBookings.length.toString()}
icon={<Package2 size={18} />}
icon={<Package2 size={20} />}
color="blue"
/>
<StatCard
label="In Transit"
value={activeShipments.length.toString()}
icon={<Train size={18} />}
icon={<Train size={20} />}
color="teal"
/>
<StatCard
@@ -246,13 +240,13 @@ export default function MyPortalPage() {
sub={
hasOutstanding ? `${outstandingInvoices.length} unpaid` : "All clear"
}
icon={<Receipt size={18} />}
icon={<Receipt size={20} />}
color={hasOutstanding ? "red" : "edr-green"}
/>
<StatCard
label="Invoices Paid"
value={`${completedInvoices} / ${invoiceTotal}`}
icon={<CalendarDays size={18} />}
icon={<CalendarDays size={20} />}
color="edr-green"
ring={{ value: paidPct, color: "edr-green" }}
/>
@@ -262,20 +256,31 @@ export default function MyPortalPage() {
<Grid gutter="md">
{/* Recent Bookings (left, wider) */}
<Grid.Col span={{ base: 12, lg: 7 }}>
<Card h="100%">
<Card h="100%" className={LIFT}>
<Group justify="space-between" mb="md">
<Box>
<Title order={4}>Recent Bookings</Title>
<Text size="sm" c="dimmed">
Your latest freight requests
</Text>
</Box>
<Group gap="sm">
<ThemeIcon
size={36}
radius="md"
variant="gradient"
gradient={{ from: "edr-green.5", to: "edr-green.7", deg: 135 }}
>
<Package2 size={18} />
</ThemeIcon>
<Box>
<Title order={4}>Recent Bookings</Title>
<Text size="sm" c="dimmed">
Your latest freight requests
</Text>
</Box>
</Group>
<Button
component={Link}
to="/bookings"
variant="subtle"
color="gray"
variant="light"
color="edr-green"
size="xs"
radius="xl"
rightSection={<ArrowUpRight size={14} />}
>
View all
@@ -291,7 +296,7 @@ export default function MyPortalPage() {
) : recentBookings.length === 0 ? (
<EmptyState message="You haven't booked any freight yet." />
) : (
<Box style={{ overflowX: "auto" }}>
<Box className="overflow-x-auto">
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
@@ -307,24 +312,31 @@ export default function MyPortalPage() {
return (
<Table.Tr
key={booking.id}
style={{ cursor: "pointer" }}
className="cursor-pointer"
onClick={() => navigate(`/bookings/${booking.id}`)}
>
<Table.Td>
<Text fw={600} size="sm">
<Text fw={600} size="sm" c="edr-green.8">
{booking.reference}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{booking.originYard?.label ??
booking.originYard?.code ??
"—"}
{" → "}
{booking.destinationYard?.label ??
booking.destinationYard?.code ??
"—"}
</Text>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={500}>
{booking.originYard?.label ??
booking.originYard?.code ??
"—"}
</Text>
<ArrowRight
size={13}
color="var(--mantine-color-gray-4)"
/>
<Text size="sm" fw={500}>
{booking.destinationYard?.label ??
booking.destinationYard?.code ??
"—"}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
@@ -353,15 +365,20 @@ export default function MyPortalPage() {
<Grid.Col span={{ base: 12, lg: 5 }}>
<Stack gap="md" h="100%">
{/* Active Shipments */}
<Card style={{ flex: 1 }}>
<Card className={`flex-1 ${LIFT}`}>
<Group justify="space-between" mb="md">
<Box>
<Title order={4}>Shipments</Title>
<Text size="sm" c="dimmed">
In-flight cargo
</Text>
</Box>
<Anchor component={Link} to="/tracking" size="sm" fw={500}>
<Group gap="sm">
<ThemeIcon size={36} radius="md" color="teal" variant="light">
<Train size={18} />
</ThemeIcon>
<Box>
<Title order={4}>Shipments</Title>
<Text size="sm" c="dimmed">
In-flight cargo
</Text>
</Box>
</Group>
<Anchor component={Link} to="/tracking" size="sm" fw={500} c="edr-green.7">
<Group gap={4}>
All <ArrowRight size={13} />
</Group>
@@ -380,15 +397,20 @@ export default function MyPortalPage() {
</Card>
{/* Invoice Summary */}
<Card>
<Card className={LIFT}>
<Group justify="space-between" mb="md">
<Box>
<Title order={4}>Invoices</Title>
<Text size="sm" c="dimmed">
{outstandingInvoices.length} outstanding
</Text>
</Box>
<Anchor component={Link} to="/billing" size="sm" fw={500}>
<Group gap="sm">
<ThemeIcon size={36} radius="md" color="edr-green" variant="light">
<Receipt size={18} />
</ThemeIcon>
<Box>
<Title order={4}>Invoices</Title>
<Text size="sm" c="dimmed">
{outstandingInvoices.length} outstanding
</Text>
</Box>
</Group>
<Anchor component={Link} to="/billing" size="sm" fw={500} c="edr-green.7">
<Group gap={4}>
All <ArrowRight size={13} />
</Group>
@@ -407,10 +429,7 @@ export default function MyPortalPage() {
justify="space-between"
wrap="nowrap"
p="sm"
style={{
borderRadius: "var(--mantine-radius-md)",
border: "1px solid var(--mantine-color-gray-2)",
}}
className="rounded-md border border-[var(--mantine-color-gray-2)] transition-all duration-150 hover:translate-x-0.5 hover:border-emerald-300 hover:bg-emerald-50"
>
<Group gap="sm" wrap="nowrap">
<ThemeIcon
@@ -466,36 +485,68 @@ function StatCard({
color: string;
ring?: { value: number; color: string };
}) {
const accentBg: Record<string, string> = {
blue: "bg-blue-500",
teal: "bg-teal-500",
red: "bg-red-500",
"edr-green": "bg-emerald-500",
};
const accentText: Record<string, string> = {
blue: "text-blue-600",
teal: "text-teal-600",
red: "text-red-600",
"edr-green": "text-emerald-600",
};
const accent = accentBg[color] ?? "bg-emerald-500";
return (
<Card>
<Card className={`relative overflow-hidden ${LIFT}`}>
{/* top accent stripe */}
<Box className={`pointer-events-none absolute inset-x-0 top-0 h-[3px] ${accent}`} />
{/* soft corner glow */}
<Box className={`pointer-events-none absolute -right-8 -top-10 h-[110px] w-[110px] rounded-full opacity-[0.08] blur-md ${accent}`} />
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lh={1.4}>
<Box className="min-w-0 flex-1">
<Text
size="xs"
c="dimmed"
fw={600}
tt="uppercase"
lh={1.4}
className="tracking-[0.04em]"
>
{label}
</Text>
<Text fz={28} fw={650} lh={1.2} mt={6}>
<Text fz={30} fw={700} lh={1.15} mt={8} className="tracking-[-0.02em]">
{value}
</Text>
{sub && (
<Text size="xs" c="dimmed" mt={4}>
{sub}
</Text>
<Group gap={4} mt={6} wrap="nowrap">
<TrendingUp size={12} className={accentText[color] ?? "text-emerald-600"} />
<Text size="xs" c="dimmed">
{sub}
</Text>
</Group>
)}
</Box>
{ring ? (
<RingProgress
size={56}
size={60}
thickness={5}
roundCaps
sections={[{ value: ring.value, color: ring.color }]}
label={
<Text ta="center" fz={11} fw={600}>
<Text ta="center" fz={12} fw={700}>
{ring.value}%
</Text>
}
/>
) : (
<ThemeIcon size={40} radius="md" color={color} variant="light">
<ThemeIcon
size={46}
radius="md"
variant="gradient"
gradient={{ from: `${color}.4`, to: `${color}.6`, deg: 135 }}
>
{icon}
</ThemeIcon>
)}
@@ -512,13 +563,7 @@ function ShipmentCard({
const meta = SHIPMENT_STATUS_META[shipment.status as ShipmentStatus];
const color = meta?.color ?? "gray";
return (
<Box
style={{
borderRadius: "var(--mantine-radius-md)",
border: "1px solid var(--mantine-color-gray-2)",
padding: "var(--mantine-spacing-sm)",
}}
>
<Box className="rounded-md border border-[var(--mantine-color-gray-2)] p-3 transition-all duration-150 hover:translate-x-0.5 hover:border-emerald-300 hover:bg-emerald-50">
<Group justify="space-between" mb={6} wrap="nowrap">
<Text fw={600} size="sm" truncate>
{shipment.reference}
@@ -530,15 +575,21 @@ function ShipmentCard({
<Text size="xs" c="dimmed" mb={8}>
{shipment.originStation} {shipment.destinationStation}
</Text>
<Progress value={shipment.progress} size={4} color={color} mb={8} />
<Progress
value={shipment.progress}
size={5}
radius="xl"
color={color}
mb={8}
/>
<Group justify="space-between" gap="xs" wrap="nowrap">
<Group gap={4} c="dimmed" wrap="nowrap" style={{ minWidth: 0 }}>
<MapPin size={11} style={{ flexShrink: 0 }} />
<Group gap={4} c="dimmed" wrap="nowrap" className="min-w-0">
<MapPin size={11} className="flex-shrink-0" />
<Text size="xs" c="dimmed" truncate>
{shipment.currentLocation}
</Text>
</Group>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
<Text size="xs" c="dimmed" className="flex-shrink-0">
ETA {shipment.eta}
</Text>
</Group>
@@ -550,11 +601,7 @@ function EmptyState({ message }: { message: string }) {
return (
<Box
py="xl"
style={{
border: "1px dashed var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-md)",
textAlign: "center",
}}
className="rounded-md border border-dashed border-[var(--mantine-color-gray-3)] text-center"
>
<Text size="sm" c="dimmed">
{message}

View File

@@ -41,11 +41,7 @@ import {
type BookingFormValues,
type RouteDirection,
} from "./new-booking-form/schema";
import {
SelectField,
SelectItem,
AlertBox,
} from "./new-booking-form/shared";
import { SelectField, AlertBox } from "./new-booking-form/shared";
function yardNameFromBooking(yard: { label?: string; code?: string; name?: string } | undefined | null): string {
return yard?.label ?? yard?.name ?? yard?.code ?? "";
@@ -399,10 +395,11 @@ export default function EditBookingPage() {
error={fieldState.error}
label="Contract Type *"
placeholder="Select contract type..."
>
<SelectItem value="new">New Contract</SelectItem>
<SelectItem value="renewal">Contract Renewal</SelectItem>
</SelectField>
data={[
{ value: "new", label: "New Contract" },
{ value: "renewal", label: "Contract Renewal" },
]}
/>
)}
/>
@@ -431,10 +428,11 @@ export default function EditBookingPage() {
error={fieldState.error}
label="Service Type *"
placeholder="Select service type..."
>
<SelectItem value="rail">Rail Transport Only</SelectItem>
<SelectItem value="rail_forwarding">Logistics (Rail + Forwarding)</SelectItem>
</SelectField>
data={[
{ value: "rail", label: "Rail Transport Only" },
{ value: "rail_forwarding", label: "Logistics (Rail + Forwarding)" },
]}
/>
)}
/>
@@ -447,10 +445,11 @@ export default function EditBookingPage() {
error={fieldState.error}
label="Equipment Return"
placeholder="Select..."
>
<SelectItem value="with_return">With Return</SelectItem>
<SelectItem value="without_return">Without Return</SelectItem>
</SelectField>
data={[
{ value: "with_return", label: "With Return" },
{ value: "without_return", label: "Without Return" },
]}
/>
)}
/>
</div>
@@ -602,19 +601,8 @@ export default function EditBookingPage() {
label="Origin Yard *"
placeholder="Select origin..."
disabled={yardOptions.length === 0}
>
{yardOptions.length === 0 ? (
<SelectItem value="__empty" disabled>No yards available</SelectItem>
) : (
yardOptions
.filter((y) => y.value !== destinationYard)
.map((y) => (
<SelectItem key={y.value} value={y.value}>
{y.label}
</SelectItem>
))
)}
</SelectField>
data={yardOptions.filter((y) => y.value !== destinationYard)}
/>
)}
/>
@@ -628,19 +616,8 @@ export default function EditBookingPage() {
label="Destination Yard *"
placeholder="Select destination..."
disabled={yardOptions.length === 0}
>
{yardOptions.length === 0 ? (
<SelectItem value="__empty" disabled>No yards available</SelectItem>
) : (
yardOptions
.filter((y) => y.value !== originYard)
.map((y) => (
<SelectItem key={y.value} value={y.value}>
{y.label}
</SelectItem>
))
)}
</SelectField>
data={yardOptions.filter((y) => y.value !== originYard)}
/>
)}
/>
</div>
@@ -664,13 +641,8 @@ export default function EditBookingPage() {
error={fieldState.error}
label="Shipping Line"
placeholder="Select shipping line..."
>
{shippingLineOptions.map((sl) => (
<SelectItem key={sl.value} value={sl.value}>
{sl.label}
</SelectItem>
))}
</SelectField>
data={shippingLineOptions}
/>
)}
/>
)}
@@ -736,10 +708,11 @@ export default function EditBookingPage() {
error={fieldState.error}
label="Cargo Type *"
placeholder="Select cargo type..."
>
<SelectItem value="container">Containerized</SelectItem>
<SelectItem value="bulk">General Cargo</SelectItem>
</SelectField>
data={[
{ value: "container", label: "Containerized" },
{ value: "bulk", label: "General Cargo" },
]}
/>
)}
/>
@@ -782,13 +755,11 @@ export default function EditBookingPage() {
error={fieldState.error}
label="Freight Type *"
placeholder="Select freight type..."
>
{freightTypeGroups.map((group) => (
<SelectItem key={group.code} value={group.code.toLowerCase()}>
{group.name}
</SelectItem>
))}
</SelectField>
data={freightTypeGroups.map((g) => ({
value: g.code.toLowerCase(),
label: g.name,
}))}
/>
)}
/>
@@ -802,13 +773,8 @@ export default function EditBookingPage() {
error={fieldState.error}
label="Commodity *"
placeholder="Select commodity..."
>
{commodityOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectField>
data={commodityOptions}
/>
)}
/>
)}
@@ -903,10 +869,11 @@ export default function EditBookingPage() {
error={fieldState.error}
label="Size *"
placeholder="Size..."
>
<SelectItem value="20ft">20ft (TEU)</SelectItem>
<SelectItem value="40ft">40ft (FEU)</SelectItem>
</SelectField>
data={[
{ value: "20ft", label: "20ft (TEU)" },
{ value: "40ft", label: "40ft (FEU)" },
]}
/>
)}
/>
@@ -919,13 +886,8 @@ export default function EditBookingPage() {
error={fieldState.error}
label="Type *"
placeholder="Type..."
>
{containerTypeOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectField>
data={containerTypeOptions}
/>
)}
/>

View File

@@ -1,6 +1,21 @@
import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
Menu,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import {
ArrowRight,
Clock,
@@ -20,17 +35,6 @@ import {
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@edr/ui-common";
export default function MyBookings() {
@@ -80,15 +84,19 @@ export default function MyBookings() {
cell: ({ row }) => {
const booking = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Group gap="sm" wrap="nowrap">
<Box className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-700 text-white shadow-sm shadow-emerald-500/30">
<Package className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-foreground">{booking.reference}</p>
<p className="text-sm text-muted-foreground">{booking.scheduledDate ?? booking.createdAt}</p>
</div>
</div>
</Box>
<Box>
<Text fw={600} size="sm">
{booking.reference}
</Text>
<Text size="xs" c="dimmed">
{booking.scheduledDate ?? booking.createdAt}
</Text>
</Box>
</Group>
);
},
},
@@ -96,11 +104,15 @@ export default function MyBookings() {
id: "route",
header: "Route",
cell: ({ row }) => (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{row.original.originYard?.label ?? row.original.originYard?.code ?? "—"}</span>
<ArrowRight className="text-muted-foreground" />
<span>{row.original.destinationYard?.label ?? row.original.destinationYard?.code ?? "—"}</span>
</div>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={500}>
{row.original.originYard?.label ?? row.original.originYard?.code ?? "—"}
</Text>
<ArrowRight className="h-4 w-4 text-[var(--mantine-color-gray-4)]" />
<Text size="sm" fw={500}>
{row.original.destinationYard?.label ?? row.original.destinationYard?.code ?? "—"}
</Text>
</Group>
),
},
{
@@ -111,12 +123,15 @@ export default function MyBookings() {
const containerCount = b.containers?.reduce((sum, c) => sum + c.qty, 0) ?? 0;
const containerType = b.containers?.[0]?.type ?? null;
return (
<div className="text-sm text-muted-foreground">
<p>{b.freightType === "BULK" ? "Bulk" : "Break Bulk"}</p>
<p className="text-xs text-muted-foreground">
{containerType && containerCount > 0 ? `${containerCount} × ${containerType} · ` : ""}{b.cargoTotalWeightVgm}t
</p>
</div>
<Box>
<Text size="sm" fw={500}>
{b.freightType === "BULK" ? "Bulk" : "Break Bulk"}
</Text>
<Text size="xs" c="dimmed">
{containerType && containerCount > 0 ? `${containerCount} × ${containerType} · ` : ""}
{b.cargoTotalWeightVgm}t
</Text>
</Box>
);
},
},
@@ -124,9 +139,9 @@ export default function MyBookings() {
id: "transportMode",
header: "Transport",
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
<Text size="sm" c="dimmed">
{row.original.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail"}
</span>
</Text>
),
},
{
@@ -140,26 +155,23 @@ export default function MyBookings() {
cell: ({ row }) => {
const booking = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
<Group justify="flex-end" onClick={(e) => e.stopPropagation()}>
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
<Menu.Target>
<ActionIcon variant="default" radius="md" aria-label="Row actions">
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<Eye size={15} />}
onClick={() => navigate(`/bookings/${booking.id}`)}
>
<Eye />
View
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
);
},
},
@@ -168,148 +180,191 @@ export default function MyBookings() {
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-foreground">
My Bookings
</h1>
<p className="mt-1 text-sm text-muted-foreground">
View and manage your freight booking requests.
</p>
</div>
<Box className="min-h-screen bg-gray-50/50 p-6">
<Stack gap="lg" maw={1280} mx="auto">
{/* ── Header band ─────────────────────────────────────────── */}
<Card
radius="lg"
withBorder
className="relative overflow-hidden border-emerald-100! bg-gradient-to-br from-emerald-50 via-white to-white"
>
<Box className="pointer-events-none absolute -right-10 -top-16 h-48 w-48 rounded-full bg-emerald-400/10 blur-2xl" />
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md" className="relative">
<Box>
<Title order={1} className="tracking-tight">
My Bookings
</Title>
<Text size="sm" c="dimmed" mt={4}>
View and manage your freight booking requests.
</Text>
</Box>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type="search"
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search bookings..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8!"
onChange={(e) => setSearchTerm(e.currentTarget.value)}
leftSection={<Search size={16} />}
radius="md"
className="w-full sm:w-80"
styles={{ input: { background: "white" } }}
/>
</div>
<Link to="/bookings/new">
<Button>
<Plus />
<Button
component={Link}
to="/bookings/new"
color="edr-green"
radius="md"
leftSection={<Plus size={16} />}
className="shadow-sm shadow-emerald-500/30"
>
New Booking
</Button>
</Link>
</div>
</Group>
</Group>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Total Bookings</p>
<h3 className="mt-2 text-3xl font-bold text-foreground">
{bookings.length}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Package />
</div>
</CardContent>
</Card>
{/* ── Stat cards ──────────────────────────────────────────── */}
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="md">
<StatCard
label="Total Bookings"
value={bookings.length}
icon={<Package className="h-6 w-6" />}
gradient="from-emerald-500 to-emerald-700 shadow-emerald-500/30"
/>
<StatCard
label="Active Bookings"
value={activeCount}
icon={<Truck className="h-6 w-6" />}
gradient="from-sky-500 to-blue-600 shadow-sky-500/30"
/>
<StatCard
label="Pending Approval"
value={pendingCount}
icon={<Clock className="h-6 w-6" />}
gradient="from-amber-400 to-orange-500 shadow-amber-500/30"
/>
</SimpleGrid>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Active Bookings</p>
<h3 className="mt-2 text-3xl font-bold text-foreground">
{activeCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Truck />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Pending Approval</p>
<h3 className="mt-2 text-3xl font-bold text-foreground">
{pendingCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Clock />
</div>
</CardContent>
</Card>
</div>
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Recent Requests</CardTitle>
<CardDescription>
{/* ── Table ───────────────────────────────────────────────── */}
<Card radius="lg" withBorder p={0} className="overflow-hidden">
<Group justify="space-between" align="flex-start" p="lg" className="border-b border-[var(--mantine-color-gray-2)] bg-gray-50/60">
<Box>
<Title order={4}>Recent Requests</Title>
<Text size="sm" c="dimmed">
A list of your recent freight bookings and their statuses.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
</Text>
</Box>
<Button variant="default" size="sm" radius="md" leftSection={<Filter size={15} />}>
Filter
</Button>
</CardHeader>
</Group>
<CardContent className="px-0">
{total === 0 && dataTableStatus === "success" ? (
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
<Package className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-sm font-semibold text-foreground">No bookings found</h3>
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
{searchTerm ? "No bookings match your current search filter." : "You haven't requested any bookings yet."}
</p>
</div>
) : (
<DataTable
columns={columns}
data={paginatedData}
status={dataTableStatus}
onRowClick={(row) => navigate(`/bookings/${(row as Freight.IBooking).id}`)}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
)}
</CardContent>
{total === 0 && dataTableStatus === "success" ? (
<Stack align="center" gap={4} px="lg" py={64} className="text-center">
<ThemeIcon size={56} radius="lg" color="edr-green" variant="light" mb="xs">
<Package size={28} />
</ThemeIcon>
<Text size="sm" fw={600}>
No bookings found
</Text>
<Text size="xs" c="dimmed" maw={320}>
{searchTerm
? "No bookings match your current search filter."
: "You haven't requested any bookings yet."}
</Text>
{!searchTerm && (
<Button
component={Link}
to="/bookings/new"
size="sm"
color="edr-green"
radius="md"
mt="md"
leftSection={<Plus size={15} />}
>
Create your first booking
</Button>
)}
</Stack>
) : (
<DataTable
columns={columns}
data={paginatedData}
status={dataTableStatus}
onRowClick={(row) => navigate(`/bookings/${(row as Freight.IBooking).id}`)}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none"
footer={DataTableFooter}
/>
)}
</Card>
</div>
</div>
</Stack>
</Box>
);
}
function StatCard({
label,
value,
icon,
gradient,
}: {
label: string;
value: number;
icon: React.ReactNode;
gradient: string;
}) {
return (
<Card
radius="lg"
withBorder
className="group transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"
>
<Group justify="space-between" align="center" wrap="nowrap">
<Box>
<Text size="sm" fw={500} c="dimmed">
{label}
</Text>
<Text fz={30} fw={700} mt={6} className="tracking-tight">
{value}
</Text>
</Box>
<Box
className={`flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br text-white shadow-md transition-transform duration-200 group-hover:scale-105 ${gradient}`}
>
{icon}
</Box>
</Group>
</Card>
);
}
function StatusBadge({ status }: { status: string }) {
const styles: Record<string, string> = {
DRAFT: "bg-amber-100 text-amber-700",
CONFIRMED: "bg-primary/10 text-primary",
IN_TRANSIT: "bg-muted text-foreground",
DELIVERED: "bg-primary/10 text-primary",
CANCELLED: "bg-destructive/10 text-destructive",
const colorMap: Record<string, string> = {
DRAFT: "amber",
CONFIRMED: "edr-green",
IN_TRANSIT: "blue",
DELIVERED: "edr-green",
CANCELLED: "red",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status] ?? "bg-muted text-muted-foreground"}`}
<Badge
variant="light"
color={colorMap[status] ?? "gray"}
radius="sm"
size="sm"
>
{status.replace(/_/g, ' ')}
</span>
{status.replace(/_/g, " ")}
</Badge>
);
}

View File

@@ -3,14 +3,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import {
AlertCircle,
Check,
ChevronLeft,
ChevronRight,
LoaderCircle,
} from "lucide-react";
import { Button } from "@edr/ui-common";
import { AlertCircle, Check, ChevronLeft, ChevronRight } from "lucide-react";
import { Alert, Box, Button, Text } from "@mantine/core";
import { api } from "@/services/api";
import type { CreateBookingPayload } from "@/services/bookings.service";
import {
@@ -210,27 +204,34 @@ export default function NewBookingPage() {
className="flex flex-col"
onSubmit={handleSubmit}
>
<div className="sticky top-0 z-20">
<div className="mx-auto max-w-4xl space-y-3 px-6 pt-4">
{/* Step indicator — sticky */}
<Box className="sticky top-0 z-20 border-b border-gray-100 bg-white/90 backdrop-blur-sm">
<Box className="mx-auto max-w-4xl px-6 py-4">
<StepIndicator step={step} />
</div>
</div>
</Box>
</Box>
<div className="flex-1">
<div className="mx-auto max-w-4xl px-6 py-8">
{/* Step content */}
<Box flex={1}>
<Box className="mx-auto max-w-4xl px-6 py-8">
{createMutation.isError && (
<div className="mb-6 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
<div>
<p className="font-semibold">Failed to save draft</p>
<p className="mt-1 text-red-600">
{createMutation.error instanceof Error
? createMutation.error.message
: "An unexpected error occurred. Please try again."}
</p>
</div>
</div>
<Alert
color="red"
icon={<AlertCircle size={16} />}
radius="md"
mb="lg"
>
<Text size="sm" fw={600}>
Failed to save draft
</Text>
<Text size="sm" mt={4} c="red.7">
{createMutation.error instanceof Error
? createMutation.error.message
: "An unexpected error occurred. Please try again."}
</Text>
</Alert>
)}
{step === 1 && <Step1ContractType form={form} />}
{step === 2 && <Step2ServiceType form={form} />}
{step === 3 && (
@@ -251,43 +252,49 @@ export default function NewBookingPage() {
{step === 5 && (
<Step8Review form={form} setStep={setStep} direction={direction} />
)}
</div>
</div>
</Box>
</Box>
<div className="sticky bottom-0 z-20 border-t border-border bg-background px-6 py-4">
<div className="mx-auto flex max-w-4xl items-center justify-between">
{/* Navigation footer — sticky */}
<Box className="sticky bottom-0 z-20 border-t border-gray-200 bg-white/90 px-6 py-4 backdrop-blur-sm">
<Box className="mx-auto flex max-w-4xl items-center justify-between">
<Button
type="button"
variant="outline"
onClick={() =>
setStep((currentStep) => Math.max(1, currentStep - 1))
}
variant="default"
radius="md"
leftSection={<ChevronLeft size={16} />}
onClick={() => setStep((s) => Math.max(1, s - 1))}
disabled={step === 1}
>
<ChevronLeft className="mr-1 h-4 w-4" />
Back
</Button>
{step < STEPS.length ? (
<Button type="button" onClick={handleContinue}>
<Button
type="button"
color="edr-green"
radius="md"
rightSection={<ChevronRight size={16} />}
onClick={handleContinue}
>
Continue
<ChevronRight />
</Button>
) : (
<Button
type="submit"
form="new-booking-form"
disabled={createMutation.isPending}
color="edr-green"
radius="md"
loading={createMutation.isPending}
leftSection={
createMutation.isPending ? undefined : <Check size={16} />
}
>
{createMutation.isPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Check />
)}
{createMutation.isPending ? "Saving Draft..." : "Save as Draft"}
</Button>
)}
</div>
</div>
</Box>
</Box>
</form>
);
}

View File

@@ -9,19 +9,19 @@ export function StepIndicator({ step }: { step: number }) {
<Fragment key={item.id}>
<div className="flex shrink-0 flex-col items-center gap-1">
<div
className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition-colors ${
className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition-all duration-200 ${
step > item.id
? "bg-primary text-primary-foreground"
? "bg-emerald-600 text-white shadow-sm shadow-emerald-600/40"
: step === item.id
? "border-2 border-primary text-primary"
: "bg-muted text-muted-foreground"
? "border-2 border-emerald-500 text-emerald-600 shadow-sm shadow-emerald-500/30"
: "bg-gray-100 text-gray-400"
}`}
>
{step > item.id ? <Check className="h-3.5 w-3.5" /> : item.id}
</div>
<span
className={`hidden text-[10px] font-medium lg:block ${
step >= item.id ? "text-foreground" : "text-muted-foreground"
className={`hidden text-[10px] font-medium lg:block transition-colors ${
step >= item.id ? "text-gray-800" : "text-gray-400"
}`}
>
{item.short}
@@ -29,8 +29,8 @@ export function StepIndicator({ step }: { step: number }) {
</div>
{index < STEPS.length - 1 && (
<div
className={`mx-1 h-0.5 flex-1 rounded-full transition-colors ${
step > item.id ? "bg-primary" : "bg-border"
className={`mx-1 h-0.5 flex-1 rounded-full transition-all duration-300 ${
step > item.id ? "bg-emerald-500" : "bg-gray-200"
}`}
/>
)}

View File

@@ -1,31 +1,16 @@
import type { ReactNode } from "react";
import type {
ControllerRenderProps,
FieldError as RhfFieldError,
} from "react-hook-form";
import {
AlertTriangle,
Check,
CheckCircle2,
Info,
XCircle,
} from "lucide-react";
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@edr/ui-common";
import type { BookingFormInputValues, BookingFormValues } from "./schema";
import { cn } from "@/lib/utils";
import type { ControllerRenderProps, FieldError as RhfFieldError } from "react-hook-form";
import { AlertTriangle, Check, CheckCircle2, Info, XCircle } from "lucide-react";
import { Alert, Select, Text, Title } from "@mantine/core";
import type { BookingFormInputValues } from "./schema";
export function OptionFieldError({ error }: { error?: { message?: string } }) {
return <FieldError errors={[error]} />;
if (!error?.message) return null;
return (
<Text size="xs" c="red" mt={4}>
{error.message}
</Text>
);
}
export function OptionCard({
@@ -44,16 +29,17 @@ export function OptionCard({
type="button"
onClick={onClick}
disabled={disabled}
className={`relative w-full rounded-xl border-2 p-4 text-left transition ${disabled
? "cursor-not-allowed border-border bg-muted opacity-60"
className={`relative w-full rounded-xl border-2 p-4 text-left transition-all duration-150 ${
disabled
? "cursor-not-allowed border-gray-200 bg-gray-100 opacity-60"
: selected
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-primary/40"
}`}
? "border-emerald-500 bg-emerald-50 shadow-sm shadow-emerald-500/20"
: "border-gray-200 bg-white hover:border-emerald-300 hover:shadow-sm"
}`}
>
{selected && !disabled && (
<span className="absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full bg-primary">
<Check className="h-3 w-3 text-primary-foreground" />
<span className="absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500">
<Check className="h-3 w-3 text-white" />
</span>
)}
{children}
@@ -68,34 +54,25 @@ export function AlertBox({
tone: "warning" | "error" | "success" | "info";
children: ReactNode;
}) {
const styles = {
warning: "bg-amber-50 border-amber-200 text-amber-800",
error: "bg-red-50 border-red-200 text-red-800",
success: "bg-emerald-50 border-emerald-200 text-emerald-800",
info: "bg-sky-50 border-sky-200 text-sky-800",
const map: Record<string, { color: string; icon: ReactNode }> = {
warning: { color: "yellow", icon: <AlertTriangle size={16} /> },
error: { color: "red", icon: <XCircle size={16} /> },
success: { color: "teal", icon: <CheckCircle2 size={16} /> },
info: { color: "blue", icon: <Info size={16} /> },
};
const icons = {
warning: <AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" />,
error: <XCircle className="h-4 w-4 shrink-0 text-red-500" />,
success: <CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-600" />,
info: <Info className="h-4 w-4 shrink-0 text-sky-500" />,
};
const { color, icon } = map[tone];
return (
<div
className={`flex items-start gap-3 rounded-xl border p-3 text-sm ${styles[tone]}`}
>
{icons[tone]}
<div>{children}</div>
</div>
<Alert color={color} icon={icon} radius="md" fz="sm">
{children}
</Alert>
);
}
export function StepLabel({ children }: { children: ReactNode }) {
return (
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<Text size="xs" fw={600} tt="uppercase" c="dimmed" className="tracking-wide">
{children}
</p>
</Text>
);
}
@@ -108,8 +85,12 @@ export function StepHeader({
}) {
return (
<div>
<h2 className="text-xl font-bold tracking-tight">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
<Title order={3} className="tracking-tight">
{title}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{description}
</Text>
</div>
);
}
@@ -120,46 +101,27 @@ export function SelectField({
label,
placeholder,
disabled,
children,
data,
}: {
field: ControllerRenderProps<BookingFormInputValues>;
error?: RhfFieldError;
label: string;
placeholder: string;
disabled?: boolean;
children: ReactNode;
data: string[] | { value: string; label: string }[];
}) {
return (
<Field data-invalid={Boolean(error)}>
<FieldLabel>{label}</FieldLabel>
<Select
value={String(field.value)}
onValueChange={field.onChange}
disabled={disabled}
>
<SelectTrigger
className={cn("w-full ", error ? "border-destructive!" : "")}
aria-invalid={Boolean(error)}
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>{children}</SelectContent>
</Select>
<FieldError errors={[error]} />
</Field>
);
}
export { SelectItem };
export function SelectOptions({ options }: { options: readonly string[] }) {
return (
<>
{options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</>
<Select
label={label}
placeholder={placeholder}
disabled={disabled}
data={data}
value={String(field.value) || null}
onChange={(v) => field.onChange(v ?? "")}
onBlur={field.onBlur}
error={error?.message}
radius="md"
allowDeselect={false}
/>
);
}

View File

@@ -1,6 +1,5 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { FileText, RefreshCw } from "lucide-react";
import { Field } from "@edr/ui-common";
import {
BookingFormInputValues,
MOCK_VALID_CONTRACTS,
@@ -11,15 +10,10 @@ import {
OptionCard,
OptionFieldError,
SelectField,
SelectItem,
StepHeader,
} from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
export function Step1ContractType({ form }: { form: BookingForm }) {
const contractType = form.watch("contractType");
@@ -36,7 +30,7 @@ export function Step1ContractType({ form }: { form: BookingForm }) {
name="contractType"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<div>
<div className="grid gap-3 md:grid-cols-2">
<OptionCard
selected={field.value === "new"}
@@ -46,11 +40,11 @@ export function Step1ContractType({ form }: { form: BookingForm }) {
form.setValue("previousContractRef", "");
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<FileText className="h-4 w-4 text-primary" />
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
<FileText className="h-4 w-4 text-emerald-600" />
</div>
<p className="font-semibold">New Contract</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<p className="mt-0.5 text-xs text-gray-500">
Create a new contract.
</p>
</OptionCard>
@@ -66,14 +60,14 @@ export function Step1ContractType({ form }: { form: BookingForm }) {
<RefreshCw className="h-4 w-4 text-sky-600" />
</div>
<p className="font-semibold">Contract Renewal</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<p className="mt-0.5 text-xs text-gray-500">
Select a previous reference to auto-populate historical
parameters.
</p>
</OptionCard>
</div>
<OptionFieldError error={fieldState.error} />
</Field>
</div>
)}
/>
@@ -88,13 +82,8 @@ export function Step1ContractType({ form }: { form: BookingForm }) {
error={fieldState.error}
label="Previous Contract Reference Number"
placeholder="Select a contract..."
>
{MOCK_VALID_CONTRACTS.map((ref) => (
<SelectItem key={ref} value={ref}>
{ref}
</SelectItem>
))}
</SelectField>
data={MOCK_VALID_CONTRACTS}
/>
)}
/>
{previousContractRef && (

View File

@@ -1,15 +1,11 @@
import { useEffect, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { FileText, Package, Train, Truck } from "lucide-react";
import { Badge, Field, FieldError, Input, Switch } from "@edr/ui-common";
import { Badge, Switch, TextInput } from "@mantine/core";
import { BookingFormInputValues, type BookingFormValues } from "./schema";
import { OptionCard, OptionFieldError, StepHeader } from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
export function Step2ServiceType({ form }: { form: BookingForm }) {
const serviceType = form.watch("serviceType");
@@ -35,34 +31,18 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
{ enabled: false, deliveryAddress: "" },
{ shouldDirty: true, shouldValidate: true },
);
form.setValue("equipmentReturn", "with_return", {
shouldDirty: true,
});
form.setValue("customsClearingEnabled", false, {
shouldDirty: true,
});
form.setValue("equipmentReturn", "with_return", { shouldDirty: true });
form.setValue("customsClearingEnabled", false, { shouldDirty: true });
} else if (serviceType === "rail_forwarding") {
form.setValue(
"firstMile",
{
enabled: false,
pickUpAddress: "",
},
{
shouldDirty: false,
shouldValidate: false,
},
{ enabled: false, pickUpAddress: "" },
{ shouldDirty: false, shouldValidate: false },
);
form.setValue(
"lastMile",
{
enabled: false,
deliveryAddress: "",
},
{
shouldDirty: false,
shouldValidate: false,
},
{ enabled: false, deliveryAddress: "" },
{ shouldDirty: false, shouldValidate: false },
);
}
}, [serviceType, form]);
@@ -80,7 +60,7 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
name="serviceType"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<div>
<div className="grid gap-3 md:grid-cols-2">
<OptionCard
selected={serviceType === "rail"}
@@ -90,11 +70,11 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
<Train className="h-4 w-4 text-indigo-600" />
</div>
<p className="font-semibold">Rail Transport Only</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<p className="mt-0.5 text-xs text-gray-500">
Rail transport along the EDR corridor, with optional
first/last mile trucking.
</p>
<Badge className="mt-2 bg-indigo-100 text-indigo-700 hover:bg-indigo-100">
<Badge color="indigo" variant="light" mt="xs" size="sm">
Option A
</Badge>
</OptionCard>
@@ -103,26 +83,27 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
selected={serviceType === "rail_forwarding"}
onClick={() => field.onChange("rail_forwarding")}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<Package className="h-4 w-4 text-primary" />
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
<Package className="h-4 w-4 text-emerald-600" />
</div>
<p className="font-semibold">Logistics</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<p className="mt-0.5 text-xs text-gray-500">
Rail transport plus documentation, customs liaison, and a
dedicated coordinator.
</p>
<Badge className="mt-2 bg-emerald-100 text-emerald-700 hover:bg-emerald-100">
<Badge color="edr-green" variant="light" mt="xs" size="sm">
Option B
</Badge>
</OptionCard>
</div>
<OptionFieldError error={fieldState.error} />
</Field>
</div>
)}
/>
{showServiceSections && (
<div className="divide-y divide-border rounded-xl border border-border">
<div className="divide-y divide-gray-200 rounded-xl border border-gray-200">
{/* First Mile */}
<div className="p-4">
<Controller
name="firstMile.enabled"
@@ -130,12 +111,10 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div>
<p className="text-sm font-medium">
First Mile - Pick-up
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<p className="text-sm font-medium">First Mile Pick-up</p>
<p className="mt-0.5 text-xs text-gray-500">
Truck pick-up from your premises (Door to Port) to the
origin rail yard.
</p>
@@ -143,7 +122,8 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
</div>
<Switch
checked={field.value}
onCheckedChange={(value) => {
onChange={(e) => {
const value = e.currentTarget.checked;
field.onChange(value);
if (!value) {
form.setValue("firstMile.pickUpAddress", "", {
@@ -152,6 +132,7 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
});
}
}}
color="edr-green"
/>
</div>
)}
@@ -161,19 +142,19 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
name="firstMile.pickUpAddress"
control={form.control}
render={({ field, fieldState }) => (
<Field className="mt-3" data-invalid={fieldState.invalid}>
<Input
{...field}
aria-invalid={fieldState.invalid}
placeholder="Pick-up address *"
/>
<FieldError errors={[fieldState.error]} />
</Field>
<TextInput
{...field}
mt="sm"
placeholder="Pick-up address *"
error={fieldState.error?.message}
radius="md"
/>
)}
/>
)}
</div>
{/* Last Mile */}
<div className="p-4">
<Controller
name="lastMile.enabled"
@@ -181,12 +162,10 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div>
<p className="text-sm font-medium">
Last Mile - Delivery
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<p className="text-sm font-medium">Last Mile Delivery</p>
<p className="mt-0.5 text-xs text-gray-500">
Truck delivery from the destination rail yard to the
final address (Port to Door).
</p>
@@ -194,7 +173,8 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
</div>
<Switch
checked={field.value}
onCheckedChange={(value) => {
onChange={(e) => {
const value = e.currentTarget.checked;
field.onChange(value);
if (!value) {
form.setValue("lastMile.deliveryAddress", "", {
@@ -206,6 +186,7 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
});
}
}}
color="edr-green"
/>
</div>
)}
@@ -215,19 +196,19 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
name="lastMile.deliveryAddress"
control={form.control}
render={({ field, fieldState }) => (
<Field className="mt-3" data-invalid={fieldState.invalid}>
<Input
{...field}
aria-invalid={fieldState.invalid}
placeholder="Delivery address *"
/>
<FieldError errors={[fieldState.error]} />
</Field>
<TextInput
{...field}
mt="sm"
placeholder="Delivery address *"
error={fieldState.error?.message}
radius="md"
/>
)}
/>
)}
</div>
{/* Equipment Return */}
{lastMileEnabled && (
<div className="p-4">
<Controller
@@ -235,23 +216,22 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<div>
<p className="text-sm font-medium">Equipment Return</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{field.value === "with_return"
? "Container returned to EDR after unloading."
: "Container retained by the customer after delivery."}
</p>
</div>
<div>
<p className="text-sm font-medium">Equipment Return</p>
<p className="mt-0.5 text-xs text-gray-500">
{field.value === "with_return"
? "Container returned to EDR after unloading."
: "Container retained by the customer after delivery."}
</p>
</div>
<Switch
checked={field.value === "with_return"}
onCheckedChange={(value) => {
onChange={(e) => {
field.onChange(
value ? "with_return" : "without_return",
e.currentTarget.checked ? "with_return" : "without_return",
);
}}
color="edr-green"
/>
</div>
)}
@@ -259,6 +239,7 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
</div>
)}
{/* Customs Clearing */}
<div className="p-4">
<Controller
name="customsClearingEnabled"
@@ -266,12 +247,10 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div>
<p className="text-sm font-medium">
Customs Clearing Service
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<p className="text-sm font-medium">Customs Clearing Service</p>
<p className="mt-0.5 text-xs text-gray-500">
EDR handles customs documentation and clearance on your
behalf.
</p>
@@ -279,7 +258,8 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
</div>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div>
)}

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { Flame, MapPin, Snowflake } from "lucide-react";
import { Field, SelectItem, Separator, Skeleton, Switch } from "@edr/ui-common";
import { Divider, Skeleton, Stack, Switch } from "@mantine/core";
import type { Freight } from "@edr/types";
import {
BookingFormInputValues,
@@ -10,11 +10,7 @@ import {
} from "./schema";
import { SelectField, StepHeader, StepLabel } from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
export function Step4Route({
form,
@@ -30,26 +26,29 @@ export function Step4Route({
const yardOptions = useMemo(() => {
if (!referenceData?.yard) return [];
return referenceData.yard.map((y) => ({
value: y.name,
label: y.name,
country: y.country,
}));
return referenceData.yard.map((y) => ({ value: y.name, label: y.name }));
}, [referenceData]);
const shippingLineOptions = useMemo(() => {
if (!referenceData?.shipping_line) return [];
return referenceData.shipping_line.map((sl) => ({
value: sl.name,
label: sl.name,
}));
return referenceData.shipping_line.map((sl) => ({ value: sl.name, label: sl.name }));
}, [referenceData]);
const originData = useMemo(
() => yardOptions.filter((o) => o.value !== destinationYard),
[yardOptions, destinationYard],
);
const destData = useMemo(
() => yardOptions.filter((o) => o.value !== originYard),
[yardOptions, originYard],
);
const direction = getRouteDirection(originYard, destinationYard);
const directionStyle: Record<string, string> = {
export: "bg-sky-50 text-sky-800 border-sky-200",
import: "bg-amber-50 text-amber-800 border-amber-200",
domestic: "bg-muted text-muted-foreground border-border",
domestic: "bg-gray-100 text-gray-600 border-gray-200",
};
const directionLabel: Record<string, string> = {
export: "Export workflow (inside country to outside country)",
@@ -85,15 +84,11 @@ export function Step4Route({
<SelectField
field={field}
error={fieldState.error}
label="Origin Yard*"
label="Origin Yard *"
placeholder="Select origin..."
disabled={stationSelectDisabled}
>
<YardSelectOptions
options={yardOptions}
excludeValue={destinationYard}
/>
</SelectField>
data={originData}
/>
)}
/>
<Controller
@@ -106,12 +101,8 @@ export function Step4Route({
label="Destination Yard *"
placeholder="Select destination..."
disabled={stationSelectDisabled}
>
<YardSelectOptions
options={yardOptions}
excludeValue={originYard}
/>
</SelectField>
data={destData}
/>
)}
/>
</div>
@@ -126,7 +117,7 @@ export function Step4Route({
</div>
)}
{direction && direction != "domestic" && (
{direction && direction !== "domestic" && (
<Controller
name="shippingLine"
control={form.control}
@@ -136,19 +127,15 @@ export function Step4Route({
error={fieldState.error}
label="Shipping Line"
placeholder="Select shipping line..."
>
{shippingLineOptions.map((sl) => (
<SelectItem key={sl.value} value={sl.value}>
{sl.label}
</SelectItem>
))}
</SelectField>
data={shippingLineOptions}
/>
)}
/>
)}
<Separator />
<div className="space-y-0 divide-y divide-border">
<Divider />
<div className="divide-y divide-gray-200">
<Controller
name="isHazardous"
control={form.control}
@@ -158,12 +145,16 @@ export function Step4Route({
<Flame className="h-4 w-4 shrink-0 text-red-500" />
<div>
<p className="text-sm font-medium">Hazardous Material</p>
<p className="text-xs text-muted-foreground">
<p className="text-xs text-gray-500">
Applies a Hazard Surcharge to the final bill.
</p>
</div>
</div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
<Switch
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div>
)}
/>
@@ -176,13 +167,17 @@ export function Step4Route({
<Snowflake className="h-4 w-4 shrink-0 text-sky-500" />
<div>
<p className="text-sm font-medium">Refrigerated Cargo</p>
<p className="text-xs text-muted-foreground">
<p className="text-xs text-gray-500">
Temperature-controlled transport applies a Refrigerator
Surcharge.
</p>
</div>
</div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
<Switch
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div>
)}
/>
@@ -193,48 +188,18 @@ export function Step4Route({
function LoadingSkeleton() {
return (
<div className="space-y-4 rounded-xl border border-border p-4">
<div className="space-y-4 rounded-xl border border-gray-200 p-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-10 w-full" />
</div>
<Stack gap={8}>
<Skeleton height={12} w={80} radius="sm" />
<Skeleton height={40} radius="md" />
</Stack>
<Stack gap={8}>
<Skeleton height={12} w={96} radius="sm" />
<Skeleton height={40} radius="md" />
</Stack>
</div>
<Skeleton className="h-8 w-full" />
<Skeleton height={32} radius="md" />
</div>
);
}
function YardSelectOptions({
options,
excludeValue,
}: {
options: Array<{ value: string; label: string; country: string }>;
excludeValue: string;
}) {
if (options.length === 0) {
return (
<SelectItem value="__yards_empty" disabled>
No yards available
</SelectItem>
);
}
const availableOptions = options.filter(
(option) => option.value !== excludeValue,
);
return (
<>
{availableOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</>
);
}

View File

@@ -1,14 +1,7 @@
import { useMemo } from "react";
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react";
import {
Button,
Field,
FieldError,
FieldLabel,
Input,
Skeleton,
} from "@edr/ui-common";
import { ActionIcon, Button, Skeleton, Stack, Text, TextInput } from "@mantine/core";
import type { Freight } from "@edr/types";
import {
BookingFormInputValues,
@@ -19,17 +12,13 @@ import {
import {
AlertBox,
OptionCard,
OptionFieldError,
SelectField,
SelectItem,
StepHeader,
StepLabel,
} from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
export function Step5CargoDetails({
form,
@@ -44,7 +33,6 @@ export function Step5CargoDetails({
}) {
const cargoType = form.watch("cargoType");
const freightType = form.watch("freightType");
const bulkCommoditytype = form.watch("bulkCommoditytype");
const containers = form.watch("containers");
const { fields, append, remove } = useFieldArray({
@@ -61,9 +49,7 @@ export function Step5CargoDetails({
const freightTypeGroups = useMemo(() => {
if (!referenceData?.cargo_type) return [];
return referenceData.cargo_type.filter(
(g) => g.code !== "CONTAINER",
);
return referenceData.cargo_type.filter((g) => g.code !== "CONTAINER");
}, [referenceData]);
const commodityOptions = useMemo(() => {
@@ -97,14 +83,14 @@ export function Step5CargoDetails({
title="Cargo Details"
description="Define your cargo type, weight, and container configuration."
/>
<div className="space-y-4 rounded-xl border border-border p-4">
<Skeleton className="h-4 w-24" />
<div className="space-y-4 rounded-xl border border-gray-200 p-4">
<Skeleton height={14} w={96} radius="sm" />
<div className="grid gap-3 sm:grid-cols-2">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton height={96} radius="xl" />
<Skeleton height={96} radius="xl" />
</div>
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-1/3" />
<Skeleton height={40} radius="md" />
<Skeleton height={40} w="33%" radius="md" />
</div>
</div>
);
@@ -117,28 +103,27 @@ export function Step5CargoDetails({
description="Define your cargo type, weight, and container configuration."
/>
{/* Cargo Type */}
<div className="space-y-3">
<StepLabel>Cargo Type *</StepLabel>
<Controller
name="cargoType"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<div>
<div className="grid gap-3 sm:grid-cols-2">
<OptionCard
selected={cargoType === "container"}
onClick={() => {
field.onChange("container");
form.setValue("freightType", "", {
shouldDirty: true,
});
form.setValue("freightType", "", { shouldDirty: true });
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<Package className="h-4 w-4 text-primary" />
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
<Package className="h-4 w-4 text-emerald-600" />
</div>
<p className="font-semibold">Containerized</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<p className="mt-0.5 text-xs text-gray-500">
Pre-packed containerized cargo (20ft / 40ft).
</p>
</OptionCard>
@@ -153,45 +138,41 @@ export function Step5CargoDetails({
<Weight className="h-4 w-4 text-amber-600" />
</div>
<p className="font-semibold">General Cargo</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<p className="mt-0.5 text-xs text-gray-500">
Bulk commodities or break-bulk cargo.
</p>
</OptionCard>
</div>
<FieldError errors={[fieldState.error]} />
</Field>
<OptionFieldError error={fieldState.error} />
</div>
)}
/>
</div>
{/* Weight */}
<div className="space-y-3">
<StepLabel>Weight</StepLabel>
<Controller
name="cargoWeight"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="cargoWeight">
Total Cargo Weight(Tons)*
</FieldLabel>
<div className="relative">
<Weight className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
{...field}
id="cargoWeight"
type="number"
aria-invalid={fieldState.invalid}
placeholder="0.00"
className="pl-9"
min="0"
step="0.01"
/>
</div>
<FieldError errors={[fieldState.error]} />
</Field>
<TextInput
{...field}
id="cargoWeight"
type="number"
label="Total Cargo Weight (Tons) *"
placeholder="0.00"
leftSection={<Weight className="h-4 w-4" />}
error={fieldState.error?.message}
radius="md"
min={0}
step={0.01}
/>
)}
/>
</div>
{/* Bulk freight type */}
{cargoType === "bulk" && (
<div className="space-y-3">
<StepLabel>Freight Type *</StepLabel>
@@ -199,7 +180,7 @@ export function Step5CargoDetails({
name="freightType"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<div>
<div className="grid gap-3 sm:grid-cols-2">
{freightTypeGroups.map((group) => {
const val = group.code.toLowerCase();
@@ -219,36 +200,30 @@ export function Step5CargoDetails({
);
})}
</div>
<FieldError errors={[fieldState.error]} />
</Field>
<OptionFieldError error={fieldState.error} />
</div>
)}
/>
{freightType && commodityOptions.length > 0 && (
<div className="space-y-2">
<Controller
name="bulkCommoditytype"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Cargo type *"
placeholder="Select type *"
>
{commodityOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectField>
)}
/>
</div>
<Controller
name="bulkCommoditytype"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Cargo type *"
placeholder="Select type *"
data={commodityOptions}
/>
)}
/>
)}
</div>
)}
{/* Container list */}
{cargoType === "container" && (
<>
<div className="space-y-4">
@@ -256,18 +231,14 @@ export function Step5CargoDetails({
<StepLabel>Containers</StepLabel>
<Button
type="button"
variant="outline"
variant="default"
size="sm"
radius="md"
leftSection={<Plus size={14} />}
onClick={() =>
append({
type: "20ft",
containerType: "",
qty: "1",
vgm: "",
})
append({ type: "20ft", containerType: "", qty: "1", vgm: "" })
}
>
<Plus className="mr-1 h-3.5 w-3.5" />
Add Container
</Button>
</div>
@@ -280,25 +251,31 @@ export function Step5CargoDetails({
return (
<div
key={field.id}
className="rounded-xl flex flex-col border border-border p-3 gap-2"
className="flex flex-col gap-3 rounded-xl border border-gray-200 bg-gray-50/50 p-4"
>
<div className="flex items-center justify-between">
<Text size="xs" fw={600} c="dimmed" tt="uppercase" className="tracking-wide">
Container {index + 1}
</Text>
{fields.length > 1 && (
<button
type="button"
<ActionIcon
color="red"
variant="subtle"
size="sm"
onClick={() => remove(index)}
className="text-xs text-destructive hover:underline"
aria-label="Remove container"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<Trash2 size={15} />
</ActionIcon>
)}
</div>
{/* Container size */}
<Controller
name={`containers.${index}.type`}
control={form.control}
render={({ field: typeField, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<div>
<div className="grid gap-2 sm:grid-cols-2">
{[
{
@@ -321,52 +298,47 @@ export function Step5CargoDetails({
onClick={() => typeField.onChange(ct.val)}
>
<div className="mb-1 flex items-center gap-2">
<Package className="h-4 w-4 text-primary" />
<Package className="h-4 w-4 text-emerald-600" />
<p className="font-semibold">{ct.label}</p>
</div>
<p className="text-xs text-muted-foreground">
{ct.limit}
</p>
<p className="text-xs text-gray-500">{ct.limit}</p>
</OptionCard>
))}
</div>
<FieldError errors={[fieldState.error]} />
</Field>
<OptionFieldError error={fieldState.error} />
</div>
)}
/>
{/* Qty + VGM + Type */}
<div className="grid gap-3 sm:grid-cols-3">
<Controller
name={`containers.${index}.qty`}
control={form.control}
render={({ field: qtyField, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel>Quantity *</FieldLabel>
<div>
<Text size="sm" fw={500} mb={4}>
Quantity *
</Text>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
qtyField.onChange(
Math.max(
1,
Number(qtyField.value ?? 1) - 1,
).toString(),
Math.max(1, Number(qtyField.value ?? 1) - 1).toString(),
)
}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-input transition hover:bg-accent"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
>
-
</button>
<Input
<input
value={qtyField.value ?? 1}
onChange={(e) =>
qtyField.onChange(e.target.value)
}
onChange={(e) => qtyField.onChange(e.target.value)}
onBlur={qtyField.onBlur}
type="number"
aria-invalid={fieldState.invalid}
className="text-center"
min="1"
min={1}
className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-center text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
<button
type="button"
@@ -375,13 +347,17 @@ export function Step5CargoDetails({
(Number(qtyField.value ?? 1) + 1).toString(),
)
}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-input transition hover:bg-accent"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
>
+
</button>
</div>
<FieldError errors={[fieldState.error]} />
</Field>
{fieldState.error?.message && (
<Text size="xs" c="red" mt={4}>
{fieldState.error.message}
</Text>
)}
</div>
)}
/>
@@ -389,20 +365,18 @@ export function Step5CargoDetails({
name={`containers.${index}.vgm`}
control={form.control}
render={({ field: vgmField, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel>Tons*</FieldLabel>
<Input
value={vgmField.value ?? 0}
onChange={(e) => vgmField.onChange(e.target.value)}
onBlur={vgmField.onBlur}
type="number"
aria-invalid={fieldState.invalid}
placeholder="e.g. 18.5"
min="0"
step="0.1"
/>
<FieldError errors={[fieldState.error]} />
</Field>
<TextInput
value={vgmField.value ?? 0}
onChange={(e) => vgmField.onChange(e.target.value)}
onBlur={vgmField.onBlur}
type="number"
label="Tons *"
placeholder="e.g. 18.5"
error={fieldState.error?.message}
radius="md"
min={0}
step={0.1}
/>
)}
/>
@@ -415,13 +389,8 @@ export function Step5CargoDetails({
error={fieldState.error}
label="Container Type *"
placeholder="Select type..."
>
{containerTypeOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectField>
data={containerTypeOptions}
/>
)}
/>
</div>
@@ -441,18 +410,13 @@ export function Step5CargoDetails({
if (result.hasOddUnit) {
return (
<AlertBox tone="warning">
<div className="flex items-start gap-2">
<div>
<p className="font-semibold">Unpaired 20ft Container</p>
<p className="mt-1 text-xs">
One 20ft container occupies only half a wagon. The wagon
will depart once a co-loader is found to fill the
remaining slot, which{" "}
<strong>may delay departure</strong> beyond the standard
lead time.
</p>
</div>
</div>
<p className="font-semibold">Unpaired 20ft Container</p>
<p className="mt-1 text-xs">
One 20ft container occupies only half a wagon. The wagon
will depart once a co-loader is found to fill the remaining
slot, which <strong>may delay departure</strong> beyond the
standard lead time.
</p>
</AlertBox>
);
}

View File

@@ -1,15 +1,5 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { Check } from "lucide-react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
Field,
FieldError,
FieldLabel,
Textarea,
} from "@edr/ui-common";
import { Box, Card, Checkbox, SimpleGrid, Text, Textarea, Title } from "@mantine/core";
import {
BookingFormInputValues,
type BookingFormValues,
@@ -17,11 +7,7 @@ import {
} from "./schema";
import { StepHeader } from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
export function Step8Review({
form,
@@ -47,13 +33,17 @@ export function Step8Review({
return (
<div className="flex items-start justify-between gap-4 py-2">
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-0.5 truncate text-sm font-medium">{value || "-"}</p>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" fw={500} mt={2} className="truncate">
{value || "—"}
</Text>
</div>
<button
type="button"
onClick={() => setStep(target)}
className="shrink-0 text-xs font-medium text-primary hover:underline"
className="shrink-0 text-xs font-medium text-emerald-600 hover:underline"
>
Edit
</button>
@@ -64,26 +54,53 @@ export function Step8Review({
const containerSummary =
values.cargoType === "container" && values.containers.length > 0
? values.containers
.filter((c) => +c.qty > 0)
.map((c) => `${c.qty} × ${c.type}`)
.join(", ")
.filter((c) => +c.qty > 0)
.map((c) => `${c.qty} × ${c.type}`)
.join(", ")
: "";
const totalVgm =
values.cargoType === "container"
? values.containers.reduce(
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
0,
)
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
0,
)
: 0;
const cargoValue =
values.cargoType === "container"
? containerSummary
: values.freightType === "bulk"
? `Bulk - ${values.bulkCommodity === "Others" ? values.bulkCommodityOther : values.bulkCommodity}`
? `Bulk ${values.bulkCommoditytype}`
: values.freightType === "break_bulk"
? `Break-Bulk - ${values.breakBulkType === "Others" ? values.breakBulkTypeOther : values.breakBulkType}`
? `Break-Bulk`
: "";
function ReviewCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<Card radius="lg" withBorder p={0} className="overflow-hidden">
<Box
px="md"
py="sm"
className="border-b border-[var(--mantine-color-gray-2)] bg-gray-50/60"
>
<Text size="xs" fw={600} tt="uppercase" c="dimmed" className="tracking-wider">
{title}
</Text>
</Box>
<Box px="md" py="xs" className="divide-y divide-gray-100">
{children}
</Box>
</Card>
);
}
return (
<div className="space-y-6">
<StepHeader
@@ -91,153 +108,118 @@ export function Step8Review({
description="Confirm your contract request before sending it for EDR staff review."
/>
<div className="grid gap-4 md:grid-cols-2">
<Card className="gap-0 overflow-hidden py-0">
<CardHeader className="border-b px-5 py-3!">
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Contract & Service
</CardTitle>
</CardHeader>
<CardContent className="divide-y divide-border px-5 pb-2 pt-0">
<Row
label="Type"
value={values.contractType === "new" ? "New Contract" : "Renewal"}
target={1}
/>
<Row
label="Service"
value={
values.serviceType === "rail"
? "Rail Only"
: values.serviceType === "rail_forwarding"
? "Rail + Forwarding"
: ""
}
target={2}
/>
</CardContent>
</Card>
<Card className="gap-0 overflow-hidden py-0">
<CardHeader className="border-b px-5 py-3!">
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
First & Last Mile
</CardTitle>
</CardHeader>
<CardContent className="divide-y divide-border px-5 pb-2 pt-0">
<Row
label="First Mile"
value={
values.firstMile.enabled
? values.firstMile.pickUpAddress
: "Not requested"
}
target={2}
/>
<Row
label="Last Mile"
value={
values.lastMile.enabled
? values.lastMile.deliveryAddress
: "Not requested"
}
target={2}
/>
<Row
label="Equipment Return"
value={
values.equipmentReturn === "with_return"
? "With Return"
: "Without Return"
}
target={2}
/>
<Row
label="Customs Clearing"
value={
values.customsClearingEnabled ? "Enabled" : "Not requested"
}
target={2}
/>
</CardContent>
</Card>
<Card className="gap-0 overflow-hidden py-0">
<CardHeader className="border-b px-5 py-3!">
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Route & Cargo
</CardTitle>
</CardHeader>
<CardContent className="divide-y divide-border px-5 pb-2 pt-0">
<Row
label="Route"
value={`${values.originYard} -> ${values.destinationYard}`}
target={3}
/>
<Row
label="Workflow"
value={
direction
? direction.charAt(0).toUpperCase() + direction.slice(1)
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<ReviewCard title="Contract & Service">
<Row
label="Type"
value={values.contractType === "new" ? "New Contract" : "Renewal"}
target={1}
/>
<Row
label="Service"
value={
values.serviceType === "rail"
? "Rail Only"
: values.serviceType === "rail_forwarding"
? "Rail + Forwarding"
: ""
}
target={3}
/>
<Row
label="Weight (VGM)"
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
target={4}
/>
<Row label="Cargo" value={cargoValue} target={4} />
<Row
label="Modifiers"
value={
[
values.isHazardous && "Hazardous",
values.isRefrigerated && "Refrigerated",
]
.filter(Boolean)
.join(", ") || "None"
}
target={3}
/>
</CardContent>
</Card>
}
target={2}
/>
</ReviewCard>
<Card className="gap-0 overflow-hidden py-0">
<CardHeader className="border-b px-5 py-3!">
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Container & Wagons
</CardTitle>
</CardHeader>
<CardContent className="divide-y divide-border px-5 pb-2 pt-0">
<Row
label="Containers"
value={containerSummary || "-"}
target={4}
/>
<Row
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : ""}
target={4}
/>
</CardContent>
</Card>
</div>
<ReviewCard title="First & Last Mile">
<Row
label="First Mile"
value={
values.firstMile.enabled
? values.firstMile.pickUpAddress
: "Not requested"
}
target={2}
/>
<Row
label="Last Mile"
value={
values.lastMile.enabled
? values.lastMile.deliveryAddress
: "Not requested"
}
target={2}
/>
<Row
label="Equipment Return"
value={
values.equipmentReturn === "with_return"
? "With Return"
: "Without Return"
}
target={2}
/>
<Row
label="Customs Clearing"
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
target={2}
/>
</ReviewCard>
<ReviewCard title="Route & Cargo">
<Row
label="Route"
value={`${values.originYard}${values.destinationYard}`}
target={3}
/>
<Row
label="Workflow"
value={
direction
? direction.charAt(0).toUpperCase() + direction.slice(1)
: ""
}
target={3}
/>
<Row
label="Weight (VGM)"
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
target={4}
/>
<Row label="Cargo" value={cargoValue} target={4} />
<Row
label="Modifiers"
value={
[
values.isHazardous && "Hazardous",
values.isRefrigerated && "Refrigerated",
]
.filter(Boolean)
.join(", ") || "None"
}
target={3}
/>
</ReviewCard>
<ReviewCard title="Container & Wagons">
<Row label="Containers" value={containerSummary || "—"} target={4} />
<Row
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : ""}
target={4}
/>
</ReviewCard>
</SimpleGrid>
<Controller
name="notes"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="notes">Additional Notes</FieldLabel>
<Textarea
{...field}
id="notes"
placeholder="Any special instructions or notes for EDR operations..."
rows={3}
/>
</Field>
<Textarea
{...field}
id="notes"
label="Additional Notes"
placeholder="Any special instructions or notes for EDR operations..."
rows={3}
radius="md"
/>
)}
/>
@@ -245,31 +227,24 @@ export function Step8Review({
name="termsAccepted"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<label className="flex cursor-pointer items-start gap-3">
<button
type="button"
role="checkbox"
aria-checked={field.value}
aria-invalid={fieldState.invalid}
onClick={() => field.onChange(!field.value)}
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border-2 transition ${field.value ? "border-primary bg-primary" : "border-input"
}`}
>
{field.value && (
<Check className="h-3 w-3 text-primary-foreground" />
)}
</button>
<span className="text-sm text-muted-foreground">
<Checkbox
label={
<Text size="sm" c="dimmed">
I confirm the information is accurate and agree to EDR's{" "}
<span className="text-primary">
<Text component="span" c="edr-green" fw={500}>
freight contract terms and conditions
</span>
</Text>
.
</span>
</label>
<FieldError errors={[fieldState.error, errors.termsAccepted]} />
</Field>
</Text>
}
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
error={
fieldState.error?.message ?? errors.termsAccepted?.message
}
color="edr-green"
radius="sm"
/>
)}
/>
</div>

View File

@@ -40,7 +40,7 @@ export const mantineTheme = createTheme({
black: "#1c2129",
fontFamily:
'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
'"Plus Jakarta Sans",ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
defaultRadius: "md",
@@ -84,8 +84,8 @@ export const mantineTheme = createTheme({
fontFamily: '"Plus Jakarta Sans", var(--mantine-font-family)',
fontWeight: "600",
sizes: {
h1: { fontSize: "40px", lineHeight: "1.1", fontWeight: "700" },
h2: { fontSize: "30px", lineHeight: "1.2", fontWeight: "650" },
h1: { fontSize: "40px", lineHeight: "1.1", fontWeight: "800" },
h2: { fontSize: "30px", lineHeight: "1.2", fontWeight: "700" },
h3: { fontSize: "23px", lineHeight: "1.3", fontWeight: "600" },
h4: { fontSize: "18px", lineHeight: "1.4", fontWeight: "600" },
h5: { fontSize: "15px", lineHeight: "1.45", fontWeight: "600" },