mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 07:22:53 +00:00
style: ui revamp
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user