Refactor contract form steps and improve UI components

- Updated Step 3: Cargo Scope to use Select and MultiSelect components for better user experience.
- Simplified loading states and improved layout in Step 4: Route.
- Enhanced Step 8: Review with a new SummaryItem component for better readability and organization.
- Adjusted navigation labels in payment pages to reflect contracts instead of bookings.
- Introduced RecentContractsSection component to display recent contracts with improved UI and functionality.
- Added clamping logic for step navigation in useContractDraft to handle older drafts.
This commit is contained in:
Marshal
2026-06-27 17:09:01 +00:00
parent 01d53c218c
commit 3dff7ec189
37 changed files with 1824 additions and 2379 deletions

View File

@@ -113,6 +113,8 @@ export class ContractsRepository extends BaseRepository<Contract> {
.leftJoinAndSelect('contract.company', 'company')
.leftJoinAndSelect('contract.serviceType', 'serviceType')
.leftJoinAndSelect('contract.routes', 'routes')
.leftJoinAndSelect('routes.originYard', 'routeOrigin')
.leftJoinAndSelect('routes.destinationYard', 'routeDestination')
.leftJoinAndSelect('contract.cargoScope', 'cargoScope')
.where('contract.deleted_at IS NULL');

View File

@@ -1,7 +1,6 @@
import { AppLayout, type SidebarItem } from "@/components/AppLayout";
import { useDisclosure } from "@mantine/hooks";
import {
CalendarCheck,
Home,
Layers,
Loader2,
@@ -36,7 +35,6 @@ import BillingPage from "./pages/billing/BillingPage";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import EditBookingPage from "./pages/bookings/EditBookingPage";
import MyBookings from "./pages/bookings/MyBookings";
import ContractClearanceFlow from "./pages/contracts/ContractClearanceFlow";
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
import ContractsList from "./pages/contracts/ContractsList";
@@ -178,11 +176,6 @@ function LandingRoute() {
const sidebarItems: SidebarItem[] = [
{ label: "Home", href: "/portal", icon: <Home size={18} /> },
{
label: "My Bookings",
href: "/bookings",
icon: <CalendarCheck size={18} />,
},
{
label: "Contracts",
href: "/contracts",
@@ -259,8 +252,12 @@ const App = () => {
}
>
<Route path="/portal" element={<MyPortalPage />} />
<Route path="/bookings" element={<MyBookings />} />
{/* Contract creation replaces the legacy booking wizard. */}
{/* Bookings live under contracts now — the standalone list is gone.
Legacy /bookings* entry points redirect into the contract flow. */}
<Route
path="/bookings"
element={<Navigate to="/contracts" replace />}
/>
<Route
path="/bookings/new"
element={<Navigate to="/contracts/new" replace />}

View File

@@ -122,16 +122,19 @@ function getActivePage(
const navClassNames = (active: boolean) => {
if (active) {
// Active tab gets the strong brand color: a green gradient pill, white
// label + icon, and a soft lifted shadow so it clearly stands out from the
// light rail around it.
return {
root: `rounded-[10px] font-medium transition-all duration-150 bg-[#ECF6F1]! ring-1 ring-inset ring-[#0EA371]/15`,
label: `text-[#0A6F4D]! font-bold!`,
section: `text-[#0A6F4D]!`,
root: `rounded-[12px] font-medium transition-all duration-150 bg-gradient-to-r from-[#0EA371] to-[#0A8A60]! shadow-[0_6px_16px_rgba(14,163,113,0.30)]!`,
label: `text-white! font-bold!`,
section: `text-white!`,
};
}
return {
root: `rounded-[10px] font-medium transition-all duration-150 hover:bg-[#F1F4F7]!`,
label: `text-edr-text! font-semibold! hover:text-[#0C1A2B]!`,
section: `text-edr-text! hover:text-[#0C1A2B]!`,
root: `rounded-[12px] font-medium transition-all duration-150 hover:bg-[#EBF4EF]!`,
label: `text-edr-text! font-semibold! hover:text-[#0A6F4D]!`,
section: `text-[#64748B]! hover:text-[#0A6F4D]!`,
};
};
@@ -253,9 +256,12 @@ export function AppLayout({
style={{
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
border: "none",
boxShadow: "none",
background: "transparent",
// Light translucent surface with a faint green-tinted wash on the
// right, a hairline base, and a soft drop so it floats above content.
background:
"linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.78) 100%)",
borderBottom: `1px solid ${borderColor}`,
boxShadow: "0 1px 12px rgba(16,24,40,0.04)",
}}
>
<Group
@@ -506,7 +512,10 @@ export function AppLayout({
<AppShell.Navbar
withBorder={false}
style={{
backgroundColor: "#ffffff",
// Soft light wash — a barely-there green tint at the top fading to
// white, so the rail reads as its own clean surface.
background:
"linear-gradient(180deg, #F4FAF7 0%, #FBFDFC 22%, #FFFFFF 100%)",
borderRight: `1px solid ${borderColor}`,
display: "flex",
flexDirection: "column",
@@ -584,15 +593,15 @@ export function AppLayout({
item.section && item.section !== prevSection ? (
<Text
key={`section-${item.section}`}
size="xs"
tt="uppercase"
px="sm"
mt={i === 0 ? 4 : "md"}
mb={4}
mt={i === 0 ? 6 : "lg"}
mb={6}
style={{
fontWeight: 600,
color: textColor,
fontSize: 12,
fontWeight: 700,
color: "#94A3B8",
fontSize: 10.5,
letterSpacing: "0.08em",
}}
>
{item.section}
@@ -795,7 +804,7 @@ export function AppLayout({
style={{
backgroundColor: bgColor,
backgroundImage:
"radial-gradient(58% 42% at 100% 0%, rgba(14,163,113,0.18) 0%, rgba(14,163,113,0.0.8) 38%, rgba(14,163,113,0.04) 72%)",
"radial-gradient(58% 42% at 100% 0%, rgba(14,163,113,0.18) 0%, rgba(14,163,113,0.08) 38%, rgba(14,163,113,0.04) 72%)",
backgroundRepeat: "no-repeat",
backgroundAttachment: "fixed",
}}

View File

@@ -1,3 +1,3 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'http://localhost:3001';
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
export const API_BASE_URL = 'http://localhost:3001';

View File

@@ -9,6 +9,7 @@ import {
HelloSection,
InvoicesSection,
RecentActivitySection,
RecentContractsSection,
ShipmentsSection,
StatsSection,
} from "./components";
@@ -23,6 +24,9 @@ export default function MyPortalPage() {
companyProfiles,
bookingsQuery,
dashboardQuery,
contractsQuery,
recentContracts,
activeContractsCount,
allBookings,
activeBookings,
newActiveThisWeek,
@@ -66,9 +70,11 @@ export default function MyPortalPage() {
)}
<StatsSection
activeContractsCount={activeContractsCount}
activeBookingsLength={activeBookings.length}
newActiveThisWeek={newActiveThisWeek}
bookingsLoading={bookingsQuery.isPending}
contractsLoading={contractsQuery.isPending}
outstandingInvoicesLength={outstandingInvoices.length}
totalOutstanding={totalOutstanding}
deliveredCount={dashboard?.deliveredCount.toString()}
@@ -85,12 +91,12 @@ export default function MyPortalPage() {
dashboardLoading={dashboardQuery.isPending}
/>
{/* Contracts lead the dashboard; bookings live under their contract. */}
<Grid align="stretch">
<Grid.Col span={{ base: 12, lg: 8 }}>
<ShipmentsSection
bookings={allBookings}
isLoading={bookingsQuery.isPending}
onBookingClick={handleBookingClick}
<RecentContractsSection
contracts={recentContracts}
isLoading={contractsQuery.isPending}
/>
</Grid.Col>
@@ -100,7 +106,25 @@ export default function MyPortalPage() {
</Grid>
<Grid align="stretch">
<Grid.Col span={{ base: 12, md: 5 }}>
<Grid.Col span={{ base: 12, lg: 8 }}>
<ShipmentsSection
bookings={allBookings.slice(0, 6)}
isLoading={bookingsQuery.isPending}
onBookingClick={handleBookingClick}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<RecentActivitySection
bookings={allBookings}
isLoading={bookingsQuery.isPending}
onBookingClick={handleBookingClick}
/>
</Grid.Col>
</Grid>
<Grid align="stretch">
<Grid.Col span={{ base: 12 }}>
<FreightVolumeSection
totalTonnes={dashboard?.freightVolume.totalTonnes ?? 0}
totalValue={dashboard?.freightVolume.totalValue ?? 0}
@@ -111,14 +135,6 @@ export default function MyPortalPage() {
isLoading={dashboardQuery.isPending}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 7 }}>
<RecentActivitySection
bookings={allBookings}
isLoading={bookingsQuery.isPending}
onBookingClick={handleBookingClick}
/>
</Grid.Col>
</Grid>
</Stack>
);

View File

@@ -1,5 +1,5 @@
import { Box, Group, Text } from "@mantine/core";
import { ArrowRight, Truck } from "lucide-react";
import { ArrowRight, FileSignature } from "lucide-react";
import { memo } from "react";
import { Link } from "react-router-dom";
import { cv } from "../constants";
@@ -26,7 +26,7 @@ export const HelloSection = memo(function HelloSection({
</Group>
</Box>
<Link to="/bookings/new" state={{ fresh: true }}>
<Link to="/contracts/new" state={{ fresh: true }}>
<Group
gap={14}
align="center"
@@ -36,11 +36,11 @@ export const HelloSection = memo(function HelloSection({
py={14}
className="w-full md:w-60! rounded-2xl no-underline shadow-[0_6px_10px_-12px_rgba(14,163,83,0.8)]"
>
<Truck size={22} color="#fff" />
<FileSignature size={22} color="#fff" />
<Box className="min-w-0 flex-1">
<Text fz={14} fw={700} c="white" lh={1.3}>
Book a shipment
Create a contract
</Text>
</Box>
<Box className="flex h-[32px] w-[32px] shrink-0 items-center justify-center rounded-full bg-white">

View File

@@ -24,7 +24,7 @@ export const RecentActivitySection = memo(function RecentActivitySection({
<Text fz={17} fw={700} c="edr-text">
Recent Activity
</Text>
<Link to="/bookings">
<Link to="/contracts">
<Group gap={3} align="center" className="no-underline">
<Text fz={13} fw={600} c="edr-green.7">
View all

View File

@@ -0,0 +1,140 @@
import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
import { memo } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowRight, FileSignature, Package, Plus } from "lucide-react";
import type { Freight } from "@edr/types";
import { ContractStatusBadge } from "@/pages/contracts/contract-ui";
import { Card } from "./Card";
import { EmptyState } from "./EmptyState";
const INK = "#10202F";
const MUTED = "#6B7C8E";
const BORDER = "#E6ECF2";
// Statuses where a Path A customer may book directly against the contract.
const BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
interface RecentContractsSectionProps {
contracts: Freight.IContract[];
isLoading: boolean;
}
export const RecentContractsSection = memo(function RecentContractsSection({
contracts,
isLoading,
}: RecentContractsSectionProps) {
const navigate = useNavigate();
return (
<Card className="h-full" padding={28}>
<Group justify="space-between" align="center" mb={18} wrap="nowrap">
<Box>
<Text fz={19} fw={800} c="edr-text">
Recent Contracts
</Text>
<Text fz={13} c="edr-muted">
Your freight agreements ship against them after signing
</Text>
</Box>
<Button
variant="light"
color="edr-green"
radius="md"
size="xs"
leftSection={<Plus size={14} />}
onClick={() => navigate("/contracts/new", { state: { fresh: true } })}
>
New
</Button>
</Group>
{isLoading ? (
<Stack gap={6}>
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} height={60} radius="md" />
))}
</Stack>
) : contracts.length === 0 ? (
<EmptyState message="No contracts yet. Create one to get started." />
) : (
<Stack gap={10}>
{contracts.map((c) => {
const isGeneral = c.contractKind === "GENERAL";
const isContainer = c.freightType === "CONTAINER";
const canSign = c.status === "CONTRACT_READY";
const canBook = !c.customsClearingEnabled && BOOKABLE.includes(c.status);
return (
<Group
key={c.id}
justify="space-between"
wrap="nowrap"
gap={12}
p="sm"
style={{
borderRadius: 12,
border: `1px solid ${BORDER}`,
cursor: "pointer",
}}
onClick={() => navigate(`/contracts/${c.id}`)}
>
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} style={{ color: INK }} truncate>
{c.reference}
</Text>
<Text fz={12} style={{ color: MUTED }} truncate>
{isGeneral ? "General" : "One-Time"} ·{" "}
{isContainer ? "Container" : "Bulk"}
</Text>
</Box>
<Group gap={10} wrap="nowrap" style={{ flexShrink: 0 }}>
<ContractStatusBadge status={c.status} />
{canSign ? (
<Button
size="xs"
radius="md"
color="edr-green"
leftSection={<FileSignature size={13} />}
onClick={(e) => {
e.stopPropagation();
navigate(`/contracts/${c.id}`);
}}
>
Sign
</Button>
) : canBook ? (
<Button
size="xs"
radius="md"
color="edr-green"
leftSection={<Package size={13} />}
onClick={(e) => {
e.stopPropagation();
navigate(`/contracts/${c.id}/bookings/new`);
}}
>
Book
</Button>
) : (
<Button
size="xs"
radius="md"
variant="default"
rightSection={<ArrowRight size={13} />}
onClick={(e) => {
e.stopPropagation();
navigate(`/contracts/${c.id}`);
}}
>
Open
</Button>
)}
</Group>
</Group>
);
})}
</Stack>
)}
</Card>
);
});

View File

@@ -1,15 +1,17 @@
import { formatCurrency } from "@/pages/billing/invoices.mock";
import { SimpleGrid } from "@mantine/core";
import { CheckCircle2, Clock3, Truck, Wallet } from "lucide-react";
import { CheckCircle2, Clock3, Layers, Truck, Wallet } from "lucide-react";
import { memo } from "react";
import { formatPct } from "../constants";
import { Card } from "./Card";
import { StatKpi } from "./StatKpi";
interface StatsSectionProps {
activeContractsCount: number;
activeBookingsLength: number;
newActiveThisWeek: number;
bookingsLoading: boolean;
contractsLoading: boolean;
outstandingInvoicesLength: number;
totalOutstanding: number;
deliveredCount: string | undefined;
@@ -20,9 +22,11 @@ interface StatsSectionProps {
}
export const StatsSection = memo(function StatsSection({
activeContractsCount,
activeBookingsLength,
newActiveThisWeek,
bookingsLoading,
contractsLoading,
outstandingInvoicesLength,
totalOutstanding,
deliveredCount,
@@ -37,9 +41,17 @@ export const StatsSection = memo(function StatsSection({
className="border-edr-divider! shadow-[0_1px_2px_rgba(16,24,40,0.04)]"
>
<SimpleGrid
cols={{ base: 2, lg: 4 }}
cols={{ base: 2, md: 3, lg: 5 }}
spacing={{ base: 20, lg: 0 }}
>
<StatKpi
icon={Layers}
accent="green"
label="Active Contracts"
value={activeContractsCount.toString()}
delta=""
loading={contractsLoading}
/>
<StatKpi
icon={Truck}
accent="green"
@@ -47,6 +59,7 @@ export const StatsSection = memo(function StatsSection({
value={activeBookingsLength.toString()}
delta={newActiveThisWeek > 0 ? `+${newActiveThisWeek} this week` : ""}
loading={bookingsLoading}
divider
/>
<StatKpi
icon={Clock3}

View File

@@ -6,6 +6,7 @@ export { FreightVolumeSection } from "./FreightVolumeSection";
export { HelloSection } from "./HelloSection";
export { InvoicesSection } from "./InvoicesSection";
export { RecentActivitySection } from "./RecentActivitySection";
export { RecentContractsSection } from "./RecentContractsSection";
export { ShipmentsSection } from "./ShipmentsSection";
export { StatKpi } from "./StatKpi";
export { StatsSection } from "./StatsSection";

View File

@@ -25,6 +25,29 @@ export function useMyPortalData(selectedProfileId?: string) {
api.companies.getDashboard.queryOptions({ input: selectedProfileId }),
);
const contractsQuery = useQuery(
api.contracts.list.queryOptions({
input: {
page: 1,
pageSize: 50,
sortBy: "createdAt",
sortOrder: "DESC",
},
}),
);
const allContracts = contractsQuery.data?.items ?? [];
const recentContracts = allContracts.slice(0, 5);
const ACTIVE_CONTRACT_STATUSES = [
"CONTRACT_ACTIVE",
"FULLY_EXECUTED",
"ACTIVE_SHIPMENT_IN_PROGRESS",
];
const activeContractsCount = allContracts.filter((c) =>
ACTIVE_CONTRACT_STATUSES.includes(c.status),
).length;
const allBookings = bookingsQuery.data?.items ?? [];
const activeBookings = allBookings.filter((b) =>
ACTIVE_STATUSES.includes(b.status),
@@ -67,6 +90,10 @@ export function useMyPortalData(selectedProfileId?: string) {
companyProfiles,
bookingsQuery,
dashboardQuery,
contractsQuery,
allContracts,
recentContracts,
activeContractsCount,
allBookings,
activeBookings,
newActiveThisWeek,

View File

@@ -1,5 +1,4 @@
import { Box, Group, Table, Text } from "@mantine/core";
import { Boxes } from "lucide-react";
import type { Freight } from "@edr/types";
@@ -22,10 +21,7 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
return (
<SectionCard>
<Group justify="space-between" align="center" mb="md">
<Group gap={8} align="center">
<Boxes size={18} color="#0A6F4D" />
<CardTitle>Containers</CardTitle>
</Group>
<CardTitle>Containers</CardTitle>
<Text fz="12.5px" fw={600} c="#9AA8B5">
{totalUnits} unit{totalUnits !== 1 ? "s" : ""}
</Text>

View File

@@ -52,52 +52,30 @@ export function ContractCard({
radius={16}
px={22}
py={20}
bg="#F1FAF6"
style={{ border: "1px solid #CFEBDD" }}
bg="white"
style={{
border: "1px solid #E6ECF2",
boxShadow: "0 1px 2px rgba(16,24,40,0.04)",
}}
>
<Group justify="space-between" align="center" wrap="wrap" gap={20}>
<Group gap={16} align="center" wrap="nowrap">
<Box
style={{
flexShrink: 0,
width: 46,
height: 46,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 13,
backgroundColor: "#fff",
border: "1px solid #CDEBDD",
color: "#0A6F4D",
}}
<Box miw={0}>
<Text
fz="11px"
fw={700}
c="#6B7C8E"
tt="uppercase"
style={{ letterSpacing: "0.06em" }}
>
<FileSignature size={22} />
</Box>
<Box miw={0}>
<Box
component="span"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: "#0A6F4D",
padding: "4px 10px",
fontSize: 10.5,
fontWeight: 800,
letterSpacing: 0.3,
color: "#fff",
textTransform: "uppercase",
}}
>
What's next
</Box>
<Text mt={6} fz="15.5px" fw={700} c="#10202F">
{c.title}
</Text>
<Text mt={2} fz="13px" c="#5B6B7A">
{c.description}
</Text>
</Box>
</Group>
What's next
</Text>
<Text mt={6} fz="15.5px" fw={700} c="#10202F">
{c.title}
</Text>
<Text mt={2} fz="13px" c="#6B7C8E">
{c.description}
</Text>
</Box>
{c.buttonLabel && (
<Button
onClick={() => navigate(`/bookings/${booking.id}/contract`)}

View File

@@ -1,12 +1,4 @@
import { Box, Group, SimpleGrid, Text } from "@mantine/core";
import {
CalendarClock,
CreditCard,
MapPin,
Package,
Tag,
Train,
} from "lucide-react";
import { Box, SimpleGrid, Text } from "@mantine/core";
import type { ReactNode } from "react";
import type { Freight } from "@edr/types";
@@ -20,41 +12,22 @@ type BookingLike = Freight.IBooking & {
trainScheduleId?: string | null;
};
function Fact({
icon,
label,
value,
}: {
icon: ReactNode;
label: string;
value: ReactNode;
}) {
function Fact({ label, value }: { label: string; value: ReactNode }) {
return (
<Group gap={10} wrap="nowrap" align="flex-start">
<Box
style={{
width: 34,
height: 34,
borderRadius: 9,
flexShrink: 0,
background: "#F1F6FA",
color: "#0A6F4D",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
<Box miw={0}>
<Text
fz="11px"
fw={700}
c="#6B7C8E"
tt="uppercase"
style={{ letterSpacing: "0.06em" }}
>
{icon}
</Box>
<Box miw={0}>
<Text fz="11.5px" fw={600} c="#9AA8B5">
{label}
</Text>
<Text mt={2} fz="14px" fw={700} c="#10202F" truncate>
{value}
</Text>
</Box>
</Group>
{label}
</Text>
<Text mt={4} fz="15px" fw={700} c="#10202F" truncate>
{value}
</Text>
</Box>
);
}
@@ -73,27 +46,20 @@ export function KeyFactsStrip({ booking }: { booking: BookingLike }) {
: "—";
return (
<SectionCard p="md">
<SimpleGrid cols={{ base: 1, xs: 2, md: 3, xl: 6 }} spacing="lg">
<SectionCard p="lg">
<SimpleGrid cols={{ base: 2, md: 3, xl: 6 }} spacing={0} verticalSpacing="lg">
<Fact label="Type" value={isContract ? "General Contract" : "One-Time"} />
<Fact label="Cargo" value={freight} />
<Fact
icon={<Tag size={17} />}
label="Type"
value={isContract ? "General Contract" : "One-Time"}
/>
<Fact icon={<Package size={17} />} label="Cargo" value={freight} />
<Fact
icon={<MapPin size={17} />}
label="Route"
value={`${yardLabel(booking.originYard)}${yardLabel(booking.destinationYard)}`}
/>
<Fact icon={<CreditCard size={17} />} label="Payment" value={payment} />
<Fact label="Payment" value={payment} />
<Fact
icon={<Train size={17} />}
label="Train"
value={booking.trainScheduleId ? "Assigned" : "Not assigned"}
/>
<Fact
icon={<CalendarClock size={17} />}
label={isContract ? "Ordering until" : "Scheduled"}
value={
isContract

View File

@@ -1,12 +1,5 @@
import { Box, Group, Text } from "@mantine/core";
import {
AlertTriangle,
Check,
FileText,
History,
MapPin,
MoveRight,
} from "lucide-react";
import { Check, MoveRight } from "lucide-react";
import type { Freight } from "@edr/types";
@@ -14,8 +7,6 @@ import { PROGRESS_STAGES, STATUS_MAP } from "../constants";
import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils";
import { SectionCard } from "./layout";
const ACCENT = "#F2A516";
/** Origin → destination strip rendered above the progress tracker. */
function RouteStrip({ booking }: { booking: Freight.IBooking }) {
const origin = yardLabel(booking.originYard);
@@ -25,27 +16,14 @@ function RouteStrip({ booking }: { booking: Freight.IBooking }) {
mb={22}
px={18}
py={14}
className="rounded-2xl"
style={{
background:
"linear-gradient(135deg, #FEF8EC 0%, #FBFCFD 60%, #F4FAF7 100%)",
border: "1px solid #F2E4C4",
borderRadius: 12,
border: "1px solid #E6ECF2",
}}
>
<Group justify="space-between" align="center" wrap="nowrap" gap="md">
<RouteEndpoint label="Origin" value={origin} />
<Box
className="flex items-center justify-center rounded-full shrink-0"
style={{
width: 34,
height: 34,
backgroundColor: "#fff",
border: `1px solid ${ACCENT}33`,
color: ACCENT,
}}
>
<MoveRight size={18} />
</Box>
<MoveRight size={18} color="#6B7C8E" className="shrink-0" />
<RouteEndpoint label="Destination" value={destination} alignRight />
</Group>
</Box>
@@ -63,24 +41,16 @@ function RouteEndpoint({
}) {
return (
<Box miw={0} style={{ textAlign: alignRight ? "right" : "left", flex: 1 }}>
<Group
gap={5}
align="center"
wrap="nowrap"
justify={alignRight ? "flex-end" : "flex-start"}
<Text
fz="11px"
fw={700}
c="#6B7C8E"
tt="uppercase"
style={{ letterSpacing: "0.06em" }}
>
<MapPin size={12} color={ACCENT} />
<Text
fz="10.5px"
fw={700}
c="#B07D14"
tt="uppercase"
className="tracking-[0.6px]"
>
{label}
</Text>
</Group>
<Text mt={3} fz="15px" fw={800} c="#10202F" truncate>
{label}
</Text>
<Text mt={3} fz="15px" fw={700} c="#10202F" truncate>
{value}
</Text>
</Box>
@@ -99,21 +69,6 @@ export function StatusHero({
const negative = isNegative(status);
const draft = isDraftLike(status);
const tone: "green" | "slate" | "red" = negative
? "red"
: draft
? "slate"
: "green";
const tileBg =
tone === "red" ? "#FBEAE7" : tone === "slate" ? "#F1F4F7" : "#ECF6F1";
const tileFg =
tone === "red" ? "#C0392B" : tone === "slate" ? "#475569" : "#0EA371";
const HeroIcon = negative
? AlertTriangle
: draft
? FileText
: (PROGRESS_STAGES[cfg.stage]?.icon ?? History);
const chipLabel = draft ? "Last edited" : negative ? "Updated" : "Scheduled";
const chipValue = fmtDate(
draft || negative ? booking.updatedAt : booking.scheduledDate,
@@ -121,52 +76,29 @@ export function StatusHero({
return (
<SectionCard p={28}>
<Group justify="space-between" align="center" wrap="wrap" gap="md">
<Group gap={16} align="center" wrap="nowrap">
<div
className="flex items-center justify-center rounded-2xl shrink-0"
style={{
width: 56,
height: 56,
backgroundColor: tileBg,
color: tileFg,
}}
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Box>
<Text fz="21px" fw={800} c="#10202F">
{cfg.title}
</Text>
<Text mt={5} fz="14px" c="#6B7C8E">
{cfg.description}
</Text>
</Box>
<Box ta="right">
<Text
fz="11px"
fw={700}
c="#6B7C8E"
tt="uppercase"
style={{ letterSpacing: "0.06em" }}
>
<HeroIcon size={26} />
</div>
<Box>
<Text fz="21px" fw={800} c="#10202F">
{cfg.title}
</Text>
<Text mt={5} fz="14px" c="#6B7C8E">
{cfg.description}
</Text>
</Box>
</Group>
<Group
gap={11}
align="center"
wrap="nowrap"
px="md"
py="sm"
className="rounded-[14px] border border-[#E6ECF2] bg-[#F7FAFC]"
>
<History size={22} color="#64748B" />
<Box>
<Text
fz="10.5px"
fw={700}
c="#9AA8B5"
tt="uppercase"
className="tracking-[0.6px]"
>
{chipLabel}
</Text>
<Text fz="sm" fw={700} c="#10202F">
{chipValue}
</Text>
</Box>
</Group>
{chipLabel}
</Text>
<Text mt={3} fz="sm" fw={700} c="#10202F">
{chipValue}
</Text>
</Box>
</Group>
<Box my={26} h={1} w="100%" bg="#EEF2F6" />

View File

@@ -1,33 +1,30 @@
import { Box, Button, Group, Paper, Text } from "@mantine/core";
import { Button, Group, Paper, Text } from "@mantine/core";
import { FileText, MessageSquare, XCircle } from "lucide-react";
export function SupportCard({ onCancel }: { onCancel?: () => void }) {
return (
<Paper radius={20} p={22} bg="#0C1A2B">
<Group gap={12} align="center" wrap="nowrap">
<Box
style={{
width: 42,
height: 42,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 12,
backgroundColor: "#16273A",
}}
>
<MessageSquare size={20} color="#fff" />
</Box>
<Box>
<Text fz="15px" fw={800} c="#fff">
Need help?
</Text>
<Text fz="12px" c="#9AA8B5">
EDR operations team
</Text>
</Box>
</Group>
<Text mt={14} fz="13px" c="#C4D0DB" style={{ lineHeight: 1.45 }}>
<Paper
radius={16}
p={22}
bg="white"
style={{
border: "1px solid #E6ECF2",
boxShadow: "0 1px 2px rgba(16,24,40,0.04)",
}}
>
<Text
fz="11px"
fw={700}
c="#6B7C8E"
tt="uppercase"
style={{ letterSpacing: "0.06em" }}
>
Need help?
</Text>
<Text mt={6} fz="15px" fw={700} c="#10202F">
EDR operations team
</Text>
<Text mt={10} fz="13px" c="#6B7C8E" style={{ lineHeight: 1.45 }}>
Questions about this shipment, documents, or delivery? Our operations
team can help.
</Text>
@@ -43,14 +40,14 @@ export function SupportCard({ onCancel }: { onCancel?: () => void }) {
</Button>
<Button
onClick={onCancel}
variant="default"
radius={10}
color="#16273A"
leftSection={
onCancel ? <XCircle size={16} /> : <FileText size={16} />
}
styles={{
root: { height: 44, paddingInline: 16 },
label: { fontWeight: 700, color: "#fff" },
label: { fontWeight: 700, color: "#10202F" },
}}
>
{onCancel ? "Cancel" : "Report"}

View File

@@ -38,9 +38,20 @@ interface SectionCardProps extends PaperProps {
ref?: Ref<HTMLDivElement>;
}
export function SectionCard({ children, ref, ...props }: SectionCardProps) {
export function SectionCard({ children, ref, style, ...props }: SectionCardProps) {
return (
<Paper ref={ref} radius={20} p="lg" withBorder bg="white" {...props}>
<Paper
ref={ref}
radius={16}
p="lg"
bg="white"
style={{
border: "1px solid #E6ECF2",
boxShadow: "0 1px 2px rgba(16,24,40,0.04)",
...(style as object),
}}
{...props}
>
{children}
</Paper>
);
@@ -48,7 +59,13 @@ export function SectionCard({ children, ref, ...props }: SectionCardProps) {
export function CardTitle({ children }: { children: ReactNode }) {
return (
<Text fz="16px" fw={800} c="#10202F">
<Text
fz="11px"
fw={700}
c="#6B7C8E"
tt="uppercase"
style={{ letterSpacing: "0.06em" }}
>
{children}
</Text>
);

View File

@@ -1,884 +0,0 @@
import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Box,
Button,
Card,
Group,
Menu,
Paper,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import type { LucideIcon } from "lucide-react";
import {
ArrowRight,
CheckCircle2,
FileEdit,
LayoutList,
MoreVertical,
Package,
Plus,
Search,
Train,
Wallet,
X,
} from "lucide-react";
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton";
import { BookingActionButton } from "./clearance/BookingActionButton";
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
import {
BookingTypeBadge,
CargoModeCell,
PaymentBadge,
SchedulingCell,
} from "./booking-display";
// Bookings that have left (or are leaving) the yard can be tracked live.
const TRACKABLE_STATUSES = new Set([
"PAID",
"IN_TRANSIT",
"COMPLETED",
"DELIVERED",
]);
import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service";
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
import type { Freight } from "@edr/types";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
} from "@edr/ui-common";
// ── Status filter options (grouped by lifecycle) ──────────────────────────────
const STATUS_FILTERS = [
{
key: "all",
label: "All bookings",
statuses: undefined as string | undefined,
},
{
key: "active",
label: "In progress",
statuses:
"SUBMITTED,CHANGES_REQUESTED,PENDING_APPROVAL,APPROVED_PENDING_SIGNATURE,APPROVED,CONTRACT_READY,SIGNED_CUSTOMER,FULLY_EXECUTED,PENDING_CONSOLIDATION,CONSOLIDATED",
},
{ key: "draft", label: "Drafts", statuses: "DRAFT" },
{
key: "payment",
label: "Awaiting payment",
statuses:
"SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED",
},
{ key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" },
{ key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
{
key: "closed",
label: "Cancelled / rejected",
statuses: "CANCELLED,REJECTED",
},
] as const;
type StatusFilterKey = (typeof STATUS_FILTERS)[number]["key"];
const SELECT_DATA = STATUS_FILTERS.map((f) => ({
value: f.key,
label: f.label,
}));
// ── Summary stat cards (clickable lifecycle filters) ──────────────────────────
const STAT_CARDS: Array<{
key: StatusFilterKey;
label: string;
icon: LucideIcon;
iconBg: string;
iconColor: string;
}> = [
{
key: "all",
label: "All bookings",
icon: LayoutList,
iconBg: "#ECF6F1",
iconColor: "#0A8A5F",
},
{
key: "active",
label: "In progress",
icon: Package,
iconBg: "#FDF3E0",
iconColor: "#C77F09",
},
{
key: "payment",
label: "Awaiting payment",
icon: Wallet,
iconBg: "#FEF6E6",
iconColor: "#F2A516",
},
{
key: "draft",
label: "Drafts",
icon: FileEdit,
iconBg: "#F1F4F7",
iconColor: "#475569",
},
{
key: "done",
label: "Completed",
icon: CheckCircle2,
iconBg: "#ECF6F1",
iconColor: "#0A8A5F",
},
];
// ── Status badge (reuses the shared portal status config) ─────────────────────
function StatusBadge({ status }: { status: string }) {
const cfg = STATUS_CONFIG[status];
const label = cfg?.badgeLabel ?? status.replace(/_/g, " ");
const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
const text = cfg
? `var(--mantine-color-${cfg.badgeText}-7, #475569)`
: "#475569";
const dot = cfg
? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)`
: "#94A3B8";
return (
<Group
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: bg,
padding: "5px 11px",
}}
>
<Box
style={{
width: 6,
height: 6,
borderRadius: "50%",
backgroundColor: dot,
flexShrink: 0,
}}
/>
<Text fz={11} fw={700} style={{ color: text, whiteSpace: "nowrap" }}>
{label}
</Text>
</Group>
);
}
// ── Context-sensitive action button ───────────────────────────────────────────
function PrimaryAction({
booking,
onNavigate,
}: {
booking: Freight.IBooking;
onNavigate: (path: string) => void;
}) {
const { status, id } = booking;
const go = () => onNavigate(`/bookings/${id}`);
// A general contract is payable as soon as it's FULLY_EXECUTED (signed); a
// one-time booking only after it's SELECTED_FOR_BATCH.
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
if (status === "DRAFT") {
return (
<Button
size="xs"
radius="md"
fw={700}
fz={13}
rightSection={<ArrowRight size={14} />}
style={{
backgroundColor: "var(--mantine-color-edr-ink-0)",
color: "#fff",
}}
onClick={go}
>
Continue
</Button>
);
}
// CHANGES_REQUESTED + clearance/operation steps are handled in place by a
// modal (update & resubmit, upload clearance docs, schedule & proceed).
if (bookingHasInlineAction(booking)) {
return <BookingActionButton booking={booking} size="xs" />;
}
const payableStatus = isGeneralContract
? "FULLY_EXECUTED"
: "SELECTED_FOR_BATCH";
if (status === payableStatus && booking.paymentStatus !== "PAID") {
return <PayNowButton booking={booking} />;
}
return (
<Button
size="xs"
radius="md"
variant="default"
fw={600}
fz={13}
onClick={go}
>
View
</Button>
);
}
function ColHeader({ label }: { label: string }) {
return (
<Text
fz={11}
fw={700}
c="edr-muted"
style={{
letterSpacing: "0.6px",
textTransform: "uppercase",
whiteSpace: "nowrap",
}}
>
{label}
</Text>
);
}
const hMeta = { headerClassName: "bg-[#F4F7FA]" };
function fmtDate(iso?: string | null): string {
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(d.getTime())
? ""
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
// ── Main component ────────────────────────────────────────────────────────────
// Lightweight count query for a single lifecycle filter (reads only `total`).
function useStatusCount(statuses: string | undefined): number | undefined {
const { data } = useQuery(
api.bookings.list.queryOptions({
input: { statuses, page: 1, pageSize: 1 },
staleTime: 30_000,
}),
);
return data?.meta?.total;
}
function StatCard({
card,
active,
count,
onSelect,
}: {
card: (typeof STAT_CARDS)[number];
active: boolean;
count: number | undefined;
onSelect: () => void;
}) {
const Icon = card.icon;
return (
<Paper
role="button"
tabIndex={0}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
p="md"
radius="lg"
withBorder
style={{
cursor: "pointer",
transition: "box-shadow 140ms ease, border-color 140ms ease",
borderColor: active ? "#F2A516" : "var(--mantine-color-edr-border-0)",
boxShadow: active ? "0 0 0 1px #F2A516" : "none",
}}
>
<Group gap={12} wrap="nowrap" align="center">
<Box
style={{
width: 42,
height: 42,
borderRadius: 11,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: card.iconBg,
color: card.iconColor,
}}
>
<Icon size={20} strokeWidth={2} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz={24} fw={800} lh={1.05} c="edr-text">
{count ?? "—"}
</Text>
<Text fz={12} fw={600} c="edr-muted" truncate>
{card.label}
</Text>
</Box>
</Group>
</Paper>
);
}
export default function MyBookings() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [statusFilter, setStatusFilter] = useState<StatusFilterKey>("all");
const [query, setQuery] = useState("");
const [typeFilter, setTypeFilter] = useState<string | null>(null);
const [freightFilter, setFreightFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<string>("");
const [createdTo, setCreatedTo] = useState<string>("");
const [trackingBooking, setTrackingBooking] =
useState<Freight.IBooking | null>(null);
const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
const selectFilter = (key: StatusFilterKey) => {
setStatusFilter(key);
resetPage();
};
const hasExtraFilters =
!!typeFilter || !!freightFilter || !!createdFrom || !!createdTo;
const clearExtraFilters = () => {
setTypeFilter(null);
setFreightFilter(null);
setCreatedFrom("");
setCreatedTo("");
resetPage();
};
const filter: BookingListFilter = useMemo(
() => ({
statuses,
bookingType: typeFilter ?? undefined,
freightType: freightFilter ?? undefined,
createdFrom: createdFrom || undefined,
// include the whole selected end day
createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
}),
[
statuses,
typeFilter,
freightFilter,
createdFrom,
createdTo,
pagination.pageIndex,
pagination.pageSize,
],
);
const { data, isLoading, isError } = useQuery(
api.bookings.list.queryOptions({ input: filter }),
);
// Per-card lifecycle counts (one cheap query each, total-only).
const allCount = useStatusCount(undefined);
const activeCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "active")!.statuses,
);
const paymentCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "payment")!.statuses,
);
const draftCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "draft")!.statuses,
);
const doneCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "done")!.statuses,
);
const transitCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "transit")!.statuses,
);
const closedCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "closed")!.statuses,
);
const cardCounts: Record<StatusFilterKey, number | undefined> = {
all: allCount,
active: activeCount,
payment: paymentCount,
draft: draftCount,
done: doneCount,
transit: transitCount,
closed: closedCount,
};
const allItems = data?.items ?? [];
const total = data?.meta?.total ?? allItems.length;
// Server handles status + pagination; reference search is applied on the page.
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return allItems;
return allItems.filter((b) =>
[b.reference, b.originYard?.label, b.destinationYard?.label]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q)),
);
}, [allItems, query]);
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
const showEmpty = !isLoading && !isError && rows.length === 0;
const columns: ColumnDef<Freight.IBooking>[] = [
{
id: "booking",
size: 244,
meta: hMeta,
header: () => <ColHeader label="Booking" />,
cell: ({ row }) => {
const b = row.original;
const cargoLabel =
b.freightType === "BULK" ? "Bulk cargo" : "Container";
return (
<Group gap={12} wrap="nowrap" align="center">
<Box
style={{
width: 36,
height: 36,
borderRadius: 9,
flexShrink: 0,
backgroundColor: "var(--mantine-color-edr-soft-0)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Package
size={18}
color="var(--mantine-color-edr-green-7)"
strokeWidth={2}
/>
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} c="edr-text" truncate>
{b.reference}
</Text>
<Text fz={12} c="edr-muted">
{cargoLabel}
</Text>
</Box>
</Group>
);
},
},
{
id: "type",
size: 150,
meta: hMeta,
header: () => <ColHeader label="Type" />,
cell: ({ row }) => <BookingTypeBadge booking={row.original} />,
},
{
id: "cargo",
size: 168,
meta: hMeta,
header: () => <ColHeader label="Cargo" />,
cell: ({ row }) => <CargoModeCell booking={row.original} />,
},
{
id: "route",
size: 196,
meta: hMeta,
header: () => <ColHeader label="Route" />,
cell: ({ row }) => {
const b = row.original;
const origin = b.originYard?.label ?? b.originYard?.code ?? "—";
const dest = b.destinationYard?.label ?? b.destinationYard?.code ?? "—";
const sub = fmtDate(b.scheduledDate ?? b.createdAt);
return (
<Box>
<Text fz={13} fw={600} c="edr-text">
{origin} {dest}
</Text>
{sub && (
<Text fz={12} c="edr-muted">
{sub}
</Text>
)}
</Box>
);
},
},
{
id: "payment",
size: 130,
meta: hMeta,
header: () => <ColHeader label="Payment" />,
cell: ({ row }) => <PaymentBadge status={row.original.paymentStatus} />,
},
{
id: "scheduling",
size: 140,
meta: hMeta,
header: () => <ColHeader label="Train" />,
cell: ({ row }) => <SchedulingCell booking={row.original} />,
},
{
id: "status",
size: 190,
meta: hMeta,
header: () => <ColHeader label="Status" />,
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "amount",
size: 140,
meta: hMeta,
header: () => <ColHeader label="Amount" />,
cell: ({ row }) => {
const b = row.original as Freight.IBooking & {
totalAmount?: number;
amount?: number;
};
const amount = b.totalAmount ?? b.amount ?? null;
if (!amount) {
return (
<Text fz={14} fw={700} style={{ color: "#94A3B8" }}>
</Text>
);
}
return (
<Text fz={14} fw={700} c="edr-text">
ETB {amount.toLocaleString()}
</Text>
);
},
},
{
id: "actions",
meta: hMeta,
header: () => null,
cell: ({ row }) => {
const booking = row.original;
const trackable = TRACKABLE_STATUSES.has(booking.status);
return (
<Group
justify="flex-end"
gap={8}
wrap="nowrap"
onClick={(e) => e.stopPropagation()}
>
{trackable && (
<Button
size="xs"
radius="md"
variant="light"
color="edr-green"
fw={700}
fz={13}
leftSection={<Train size={14} />}
onClick={() => setTrackingBooking(booking)}
>
Track
</Button>
)}
<PrimaryAction booking={booking} onNavigate={navigate} />
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
<Menu.Target>
<ActionIcon
variant="transparent"
size={30}
radius="md"
aria-label="More options"
>
<MoreVertical size={16} color="#9AA8B5" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => navigate(`/bookings/${booking.id}`)}>
View details
</Menu.Item>
{trackable && (
<Menu.Item
leftSection={<Train size={15} />}
onClick={() => setTrackingBooking(booking)}
>
Track shipment
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Group>
);
},
},
];
return (
<Box style={{ padding: "28px 32px 32px" }}>
<Stack gap="lg">
{/* ── Page header ─────────────────────────────────────────────── */}
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
<Box>
<Group gap={10} align="center">
<Title
order={1}
fw={800}
fz={26}
style={{ letterSpacing: "-0.01em" }}
>
Bookings
</Title>
</Group>
<Text size="sm" c="edr-muted" mt={4}>
Track every cargo booking from draft to delivery.
</Text>
</Box>
<Button
component={Link}
to="/bookings/new"
state={{ fresh: true }}
color="edr-green"
radius="md"
leftSection={<Plus size={16} />}
>
New booking
</Button>
</Group>
{/* ── Summary stat cards ──────────────────────────────────────── */}
<SimpleGrid cols={{ base: 2, sm: 3, lg: 5 }} spacing="md">
{STAT_CARDS.map((card) => (
<StatCard
key={card.key}
card={card}
active={statusFilter === card.key}
count={cardCounts[card.key]}
onSelect={() => selectFilter(card.key)}
/>
))}
</SimpleGrid>
{/* ── Bookings table card ──────────────────────────────────────── */}
<Card p={0} style={{ overflow: "hidden" }}>
<Group
justify="space-between"
gap={12}
px={20}
py={14}
wrap="wrap"
style={{
borderBottom: "1px solid var(--mantine-color-edr-border-0)",
}}
>
<Group gap={10} wrap="wrap" style={{ flex: 1, minWidth: 260 }}>
<TextInput
placeholder="Search reference or route…"
leftSection={<Search size={16} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
variant="transparent"
color="gray"
onClick={() => setQuery("")}
>
<X size={14} />
</ActionIcon>
) : null
}
radius="md"
style={{ flex: 1, minWidth: 200, maxWidth: 340 }}
/>
<Select
data={SELECT_DATA}
value={statusFilter}
onChange={(value) =>
selectFilter((value as StatusFilterKey) ?? "all")
}
allowDeselect={false}
radius="md"
checkIconPosition="right"
comboboxProps={{ withinPortal: true }}
style={{ width: 190 }}
aria-label="Filter by status"
/>
<Select
placeholder="Any type"
data={[
{ value: "ONE_TIME", label: "One-time" },
{ value: "GENERAL_CONTRACT", label: "General contract" },
]}
value={typeFilter}
onChange={(v) => {
setTypeFilter(v);
resetPage();
}}
clearable
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 170 }}
aria-label="Filter by booking type"
/>
<Select
placeholder="Any cargo"
data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
value={freightFilter}
onChange={(v) => {
setFreightFilter(v);
resetPage();
}}
clearable
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 150 }}
aria-label="Filter by cargo type"
/>
<TextInput
type="date"
value={createdFrom}
onChange={(e) => {
setCreatedFrom(e.currentTarget.value);
resetPage();
}}
radius="md"
style={{ width: 150 }}
aria-label="Created from"
placeholder="From"
/>
<TextInput
type="date"
value={createdTo}
onChange={(e) => {
setCreatedTo(e.currentTarget.value);
resetPage();
}}
radius="md"
style={{ width: 150 }}
aria-label="Created to"
placeholder="To"
/>
{hasExtraFilters && (
<Button
variant="subtle"
color="gray"
radius="md"
size="sm"
leftSection={<X size={14} />}
onClick={clearExtraFilters}
>
Clear
</Button>
)}
</Group>
<Text fz={12} c="edr-muted">
{total} booking{total !== 1 ? "s" : ""}
</Text>
</Group>
{showEmpty ? (
<Stack align="center" gap={4} px="lg" py={64} ta="center">
<ThemeIcon
size={56}
radius="lg"
color="edr-green"
variant="light"
mb="xs"
>
<Package size={28} />
</ThemeIcon>
<Text size="sm" fw={600} c="edr-text">
{query
? "No bookings match your search"
: "No bookings here yet"}
</Text>
<Text size="xs" c="edr-muted" maw={320}>
{query
? "Try a different reference or clear the search."
: "Create your first booking to get started."}
</Text>
{!query && (
<Button
component={Link}
to="/bookings/new"
state={{ fresh: true }}
size="sm"
mt="md"
/>
)}
</Stack>
) : (
<DataTable
columns={columns}
data={rows}
status={dataTableStatus}
onRowClick={(row) =>
navigate(`/bookings/${(row as Freight.IBooking).id}`)
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none rounded-none"
footer={DataTableFooter}
/>
)}
</Card>
</Stack>
<ShipmentTrackingModal
opened={trackingBooking !== null}
onClose={() => setTrackingBooking(null)}
bookingId={trackingBooking?.id ?? ""}
bookingReference={trackingBooking?.reference ?? ""}
originLabel={
trackingBooking?.originYard?.label ??
trackingBooking?.originYard?.code
}
destinationLabel={
trackingBooking?.destinationYard?.label ??
trackingBooking?.destinationYard?.code
}
/>
</Box>
);
}

View File

@@ -1,8 +1,18 @@
import "leaflet/dist/leaflet.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Box, Combobox, InputBase, Loader, Text, useCombobox } from "@mantine/core";
import { MapPin, Search } from "lucide-react";
import {
Box,
Button,
Combobox,
Group,
InputBase,
Loader,
Modal,
Text,
useCombobox,
} from "@mantine/core";
import { Check, MapPin, Search } from "lucide-react";
import L from "leaflet";
import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from "react-leaflet";
@@ -42,13 +52,43 @@ const PINNED_ZOOM = 14;
const NOMINATIM_URL = "https://nominatim.openstreetmap.org/search";
const NOMINATIM_REVERSE_URL = "https://nominatim.openstreetmap.org/reverse";
// Search only fires once the user pauses typing for this long. Slightly longer
// than a keystroke burst so we make one request per pause, not per character
// and it keeps us within Nominatim's 1 req/s fair-use limit.
const SEARCH_DEBOUNCE_MS = 450;
// than a keystroke burst so we make one request per pause, not per character.
const SEARCH_DEBOUNCE_MS = 550;
const MIN_QUERY_LEN = 2;
// Bias geocoding toward the EDR corridor countries so local addresses surface
// first (Nominatim still returns global matches if nothing local fits).
const SEARCH_COUNTRYCODES = "et,dj";
// Nominatim's fair-use policy allows at most 1 request/second. We keep a hard
// floor a touch above 1s so a flurry of map clicks / searches can never trip
// the 429 ("Too Many Requests") wall.
const MIN_REQUEST_INTERVAL_MS = 1100;
// Reverse-geocode precision: coordinates are rounded to ~11m before caching so
// near-identical pin drags resolve from cache instead of re-hitting the API.
const REVERSE_COORD_PRECISION = 4;
// ── Module-level rate-limited request queue ─────────────────────────────────
// Every Nominatim call (forward + reverse, across ALL picker instances on the
// page) funnels through one promise chain that spaces requests ≥1.1s apart.
let lastRequestAt = 0;
let queueTail: Promise<unknown> = Promise.resolve();
function scheduleRequest<T>(run: () => Promise<T>): Promise<T> {
const result = queueTail.then(async () => {
const now = Date.now();
const wait = Math.max(0, lastRequestAt + MIN_REQUEST_INTERVAL_MS - now);
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
lastRequestAt = Date.now();
return run();
});
// Keep the chain alive even if this request rejects, so one failure doesn't
// stall every queued request behind it.
queueTail = result.catch(() => undefined);
return result;
}
// Simple in-memory caches keyed by the normalized query / rounded coordinate.
const searchCache = new Map<string, GeocodeResult[]>();
const reverseCache = new Map<string, string>();
/** One Nominatim forward-geocode request. `countryCodes` biases to a region. */
async function nominatimSearch(
@@ -81,30 +121,58 @@ async function nominatimSearch(
}
/**
* Forward-geocode a free-text query. We try the EDR corridor (ET/DJ) first so
* local addresses rank highest, then fall back to a global search when nothing
* local matches — so the field never looks "broken" for an out-of-region query.
* Forward-geocode a free-text query. Served from cache when possible; otherwise
* queued (rate-limited) and tried EDR-corridor-first, then global, so local
* addresses rank highest without the field ever looking "broken".
*/
async function searchPlaces(query: string, signal: AbortSignal): Promise<GeocodeResult[]> {
const local = await nominatimSearch(query, signal, SEARCH_COUNTRYCODES);
if (local.length > 0) return local;
return nominatimSearch(query, signal);
async function searchPlaces(
query: string,
signal: AbortSignal,
): Promise<GeocodeResult[]> {
const key = query.trim().toLowerCase();
const cached = searchCache.get(key);
if (cached) return cached;
const found = await scheduleRequest(async () => {
if (signal.aborted) return [];
const local = await nominatimSearch(query, signal, SEARCH_COUNTRYCODES);
if (local.length > 0) return local;
return nominatimSearch(query, signal);
});
if (found.length > 0) searchCache.set(key, found);
return found;
}
/** Reverse-geocode a dropped pin to its nearest address. */
async function reverseGeocode(lat: number, lng: number): Promise<string> {
/** Reverse-geocode a dropped pin to its nearest address (cached + queued). */
async function reverseGeocode(
lat: number,
lng: number,
signal?: AbortSignal,
): Promise<string> {
const key = `${lat.toFixed(REVERSE_COORD_PRECISION)},${lng.toFixed(
REVERSE_COORD_PRECISION,
)}`;
const cached = reverseCache.get(key);
if (cached != null) return cached;
const params = new URLSearchParams({
lat: String(lat),
lon: String(lng),
format: "json",
});
try {
const res = await fetch(`${NOMINATIM_REVERSE_URL}?${params}`, {
headers: { Accept: "application/json" },
const address = await scheduleRequest(async () => {
if (signal?.aborted) return "";
const res = await fetch(`${NOMINATIM_REVERSE_URL}?${params}`, {
signal,
headers: { Accept: "application/json", "Accept-Language": "en" },
});
if (!res.ok) return "";
const data = (await res.json()) as { display_name?: string };
return data.display_name ?? "";
});
if (!res.ok) return "";
const data = (await res.json()) as { display_name?: string };
return data.display_name ?? "";
reverseCache.set(key, address);
return address;
} catch {
return "";
}
@@ -150,6 +218,12 @@ export interface LocationPickerProps {
label: string;
placeholder?: string;
error?: string;
/**
* "inline" (default) renders the search + map directly. "modal" renders a
* compact read-only trigger that opens the map picker in a centered modal —
* cleaner for forms with several mile sections stacked together.
*/
variant?: "inline" | "modal";
}
/**
@@ -158,19 +232,123 @@ export interface LocationPickerProps {
* - or click anywhere on the map to drop a pin (Nominatim reverse geocoding).
* Reports the resolved address and coordinates up via `onChange`.
*/
export function LocationPicker({
export function LocationPicker(props: LocationPickerProps) {
if (props.variant === "modal") return <LocationPickerModal {...props} />;
return <LocationPickerInline {...props} />;
}
/** Compact trigger + modal wrapper around the inline picker. */
function LocationPickerModal({
value,
onChange,
label,
placeholder = "Search an address or click the map…",
error,
}: LocationPickerProps) {
const [opened, setOpened] = useState(false);
const hasPin = value.lat != null && value.lng != null;
return (
<Box>
<Text fz={13} fw={600} c="#10202F" mb={6}>
{label}
</Text>
<Box
onClick={() => setOpened(true)}
style={{
display: "flex",
alignItems: "center",
gap: 10,
cursor: "pointer",
borderRadius: 12,
minHeight: 46,
padding: "8px 12px",
border: `1px solid ${error ? "#E03131" : hasPin ? "#CDEBDD" : "#E6ECF2"}`,
background: hasPin
? "linear-gradient(135deg, #F6FBF8 0%, #FFFFFF 70%)"
: "#fff",
transition: "border-color 130ms ease, background 130ms ease",
}}
>
<Box
style={{
width: 30,
height: 30,
flexShrink: 0,
borderRadius: 9,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: hasPin ? "#ECF6F1" : "#F1F4F7",
color: hasPin ? "#0A6F4D" : "#64748B",
}}
>
<MapPin size={16} />
</Box>
<Text fz={13.5} c={hasPin ? "#10202F" : "#94A3B8"} lineClamp={1} style={{ flex: 1 }}>
{hasPin ? value.address || "Pinned location" : placeholder}
</Text>
<Text fz={12.5} fw={600} c="#0A6F4D" style={{ flexShrink: 0 }}>
{hasPin ? "Change" : "Pick on map"}
</Text>
</Box>
{error && (
<Text fz={12} c="red" mt={5}>
{error}
</Text>
)}
<Modal
opened={opened}
onClose={() => setOpened(false)}
title={label}
size="lg"
centered
radius="lg"
overlayProps={{ blur: 2, backgroundOpacity: 0.45 }}
styles={{ title: { fontWeight: 700, color: "#10202F" } }}
>
<LocationPickerInline
value={value}
onChange={onChange}
label=""
placeholder={placeholder}
mapHeight={320}
withinPortal={false}
/>
<Group justify="flex-end" mt="md">
<Button
color="edr-green"
radius="md"
leftSection={<Check size={16} />}
disabled={!hasPin}
onClick={() => setOpened(false)}
>
Done
</Button>
</Group>
</Modal>
</Box>
);
}
/** The original inline search + map experience. */
function LocationPickerInline({
value,
onChange,
label,
placeholder = "Search an address or click the map…",
error,
mapHeight = 260,
withinPortal = true,
}: LocationPickerProps & { mapHeight?: number; withinPortal?: boolean }) {
const combobox = useCombobox();
const [query, setQuery] = useState("");
const [results, setResults] = useState<GeocodeResult[]>([]);
const [searching, setSearching] = useState(false);
const [resolving, setResolving] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const reverseAbortRef = useRef<AbortController | null>(null);
const hasPin = value.lat != null && value.lng != null;
@@ -212,6 +390,9 @@ export function LocationPicker({
};
}, [query, combobox]);
// Abort any in-flight reverse lookup when the picker unmounts.
useEffect(() => () => reverseAbortRef.current?.abort(), []);
const selectResult = useCallback(
(r: GeocodeResult) => {
onChange({ address: r.displayName, lat: r.lat, lng: r.lng });
@@ -226,8 +407,13 @@ export function LocationPicker({
async (lat: number, lng: number) => {
// Show the pin immediately; fill the address once reverse geocoding lands.
onChange({ address: value.address, lat, lng });
// Cancel any in-flight reverse lookup — only the latest dropped pin counts.
reverseAbortRef.current?.abort();
const controller = new AbortController();
reverseAbortRef.current = controller;
setResolving(true);
const address = await reverseGeocode(lat, lng);
const address = await reverseGeocode(lat, lng, controller.signal);
if (controller.signal.aborted) return; // a newer pin superseded this one
setResolving(false);
onChange({
address: address || `${lat.toFixed(5)}, ${lng.toFixed(5)}`,
@@ -246,10 +432,15 @@ export function LocationPicker({
return (
<Box>
<Combobox store={combobox} withinPortal shadow="md" radius="md">
<Combobox
store={combobox}
withinPortal={withinPortal}
shadow="md"
radius="md"
>
<Combobox.Target>
<InputBase
label={label}
label={label || undefined}
placeholder={placeholder}
value={inputValue}
error={error}
@@ -297,7 +488,7 @@ export function LocationPicker({
<Box
mt={10}
style={{
height: 260,
height: mapHeight,
borderRadius: 12,
overflow: "hidden",
border: "1px solid #E6ECF2",

View File

@@ -270,7 +270,46 @@ export function StepHeader({
/** Shared Mantine input styling so every field in the form matches. */
export const fieldStyles = {
label: { fontWeight: 600, fontSize: 13, color: INK, marginBottom: 6 },
input: { borderRadius: 10, minHeight: 44, height: 44, borderColor: BORDER },
input: {
borderRadius: 12,
minHeight: 46,
height: 46,
paddingTop: 0,
paddingBottom: 0,
fontSize: 14,
fontWeight: 500,
color: INK,
borderColor: BORDER,
background: "linear-gradient(180deg, #FFFFFF 0%, #FCFDFE 100%)",
boxShadow: "0 1px 2px rgba(16,24,40,0.04)",
transition:
"border-color 130ms ease, box-shadow 130ms ease, background 130ms ease",
"&:hover": { borderColor: "#CBD8E4" },
"&:focus, &:focusWithin": {
borderColor: GREEN,
background: "#FFFFFF",
boxShadow: `0 0 0 3px ${GREEN}22, 0 1px 2px rgba(16,24,40,0.05)`,
},
},
section: { color: MUTED },
dropdown: {
borderRadius: 14,
border: `1px solid ${BORDER}`,
boxShadow: "0 12px 32px rgba(16,24,40,0.12)",
padding: 6,
},
option: {
borderRadius: 9,
fontSize: 13.5,
fontWeight: 500,
padding: "9px 10px",
"&[data-combobox-selected]": {
background: `linear-gradient(135deg, ${GREEN}1A, ${GREEN}0D)`,
color: GREEN_DARK,
fontWeight: 600,
},
"&[data-combobox-active]": { background: "#F1F6FA" },
},
} as const;
export function SelectField({

View File

@@ -13,20 +13,22 @@ import {
Paper,
SimpleGrid,
Stack,
Tabs,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import {
ArrowLeft,
CalendarClock,
CheckCircle2,
Download,
FileSignature,
FileText,
Flame,
Inbox,
Layers,
type LucideIcon,
MapPin,
Package,
PackagePlus,
@@ -37,7 +39,6 @@ import {
} from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { api } from "@/services/api";
import { contractsService } from "@/services/contracts.service";
@@ -49,7 +50,6 @@ import {
INK,
MetaItem,
MUTED,
StatCard,
} from "./contract-ui";
// Statuses where Path A customers may create a shipment booking themselves.
@@ -68,6 +68,7 @@ export default function ContractDetailPage() {
const [signOpen, setSignOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
const [tab, setTab] = useState<string>("details");
const {
data: contract,
@@ -142,6 +143,7 @@ export default function ContractDetailPage() {
const isGeneral = contract.contractKind === "GENERAL";
const routes = contract.routes ?? [];
const pricing = contract.pricingBreakdown;
const files = contract.files ?? [];
const canSign = contract.status === "CONTRACT_READY";
const customsPath = contract.customsClearingEnabled;
@@ -177,29 +179,19 @@ export default function ContractDetailPage() {
>
<ArrowLeft size={18} />
</Button>
<Group gap={12} wrap="nowrap" align="center">
<ThemeIcon
size={48}
radius="lg"
variant="light"
color={isGeneral ? "violet" : "edr-green"}
>
<Layers size={23} />
</ThemeIcon>
<div>
<Group gap={10} align="center">
<Title order={2} fw={800} fz={22} style={{ color: INK }}>
{contract.reference}
</Title>
<ContractStatusBadge status={contract.status} />
</Group>
<Text size="sm" c="dimmed" mt={2}>
{isGeneral ? "General contract" : "One-time contract"} ·{" "}
{isContainer ? "Containerised" : "Bulk"} ·{" "}
{contract.tradeDirection ?? "—"}
</Text>
</div>
</Group>
<div>
<Group gap={10} align="center">
<Title order={2} fw={800} fz={22} style={{ color: INK }}>
{contract.reference}
</Title>
<ContractStatusBadge status={contract.status} />
</Group>
<Text size="sm" c="dimmed" mt={2}>
{isGeneral ? "General contract" : "One-time contract"} ·{" "}
{isContainer ? "Containerised" : "Bulk"} ·{" "}
{contract.tradeDirection ?? "—"}
</Text>
</div>
</Group>
<Group gap="sm">
@@ -243,31 +235,47 @@ export default function ContractDetailPage() {
</Group>
</Group>
{/* Key facts strip */}
<Paper withBorder radius={20} p="md" style={{ borderColor: BORDER }}>
<SimpleGrid cols={{ base: 1, xs: 2, md: 3, xl: 5 }} spacing="lg">
<KeyFact
icon={<Layers size={17} />}
{/* Key facts — one premium gradient card */}
<Paper
radius={18}
withBorder
style={{
borderColor: "#DCEBE3",
overflow: "hidden",
background:
"linear-gradient(120deg, #F1FAF5 0%, #F7FCF9 34%, #FFFFFF 78%)",
boxShadow: "0 8px 24px rgba(14,163,113,0.08)",
}}
>
<SimpleGrid
cols={{ base: 2, sm: 3, xl: 5 }}
spacing={0}
verticalSpacing={0}
>
<FactCell
icon={Layers}
label="Kind"
value={isGeneral ? "General" : "One-Time"}
/>
<KeyFact
icon={<Package size={17} />}
<FactCell
icon={Package}
label="Cargo"
value={isContainer ? "Container" : "Bulk"}
color="violet"
/>
<KeyFact
icon={<MapPin size={17} />}
<FactCell
icon={MapPin}
label="Routes"
value={String(routes.length || 1)}
/>
<KeyFact
icon={<Ship size={17} />}
<FactCell
icon={Ship}
label="Trade"
value={contract.tradeDirection ?? "—"}
color="orange"
/>
<KeyFact
icon={<CalendarClock size={17} />}
<FactCell
icon={CalendarClock}
label="Valid until"
value={
contract.contractValidUntil
@@ -278,6 +286,39 @@ export default function ContractDetailPage() {
</SimpleGrid>
</Paper>
{/* Tabs: Details · Documents · Bookings */}
<Tabs
value={tab}
onChange={(v) => setTab(v ?? "details")}
color="edr-green"
keepMounted={false}
>
<Tabs.List mb="lg">
<Tabs.Tab value="details" leftSection={<FileText size={15} />}>
Details
</Tabs.Tab>
<Tabs.Tab value="documents" leftSection={<Download size={15} />}>
Documents
</Tabs.Tab>
<Tabs.Tab value="bookings" leftSection={<Package size={15} />}>
Bookings
{contractBookings.length > 0 && (
<Badge
size="xs"
variant="light"
color="edr-green"
ml={8}
radius="sm"
>
{contractBookings.length}
</Badge>
)}
</Tabs.Tab>
</Tabs.List>
{/* ── Details tab ───────────────────────────────────────────── */}
<Tabs.Panel value="details">
<Stack gap="lg">
{/* Summary meta */}
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<Group gap={48} wrap="wrap">
@@ -311,32 +352,6 @@ export default function ContractDetailPage() {
</Group>
</Paper>
{/* Stat strip */}
<Group gap="md" wrap="wrap" align="stretch">
<StatCard
label="Shipments"
hint="booked under this contract"
value={contractBookings.length}
icon={Package}
color="violet"
/>
<StatCard
label="Routes covered"
value={routes.length || 1}
icon={MapPin}
color="edr-green"
/>
<StatCard
label="Valid until"
value={
contract.contractValidUntil
? new Date(contract.contractValidUntil).toLocaleDateString()
: "—"
}
icon={CalendarClock}
color="edr-accent"
/>
</Group>
{/* Path B notice */}
{customsPath && PATH_B_CLEARANCE.includes(contract.status) && (
@@ -344,39 +359,53 @@ export default function ContractDetailPage() {
withBorder
radius="lg"
p="lg"
style={{ borderColor: "#CDEBDD", background: "#F6FBF8" }}
style={{
borderColor: "#CDEBDD",
background: "#F6FBF8",
position: "relative",
overflow: "hidden",
}}
>
<Group gap={12} align="flex-start" wrap="nowrap">
<ThemeIcon size={40} radius="md" variant="light" color="edr-green">
<Upload size={20} />
</ThemeIcon>
<Box>
<Text fw={700} fz={15} c={INK}>
Customs clearance shipment
</Text>
<Text fz={13} c="dimmed" mt={2}>
{contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
? "Upload your clearance documents so Global Logistics can review them. After approval, Global Logistics creates your booking — you only pay the freight."
: contract.status === "CLEARANCE_UNDER_REVIEW"
? "Global Logistics is reviewing your clearance documents. Re-upload any queried documents to proceed."
: "Your documents are cleared. Global Logistics will create your booking shortly — you will be notified when payment is due."}
</Text>
</Box>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: GREEN,
}}
/>
<Group gap={10} align="center" mb={6}>
<Upload size={16} color={GREEN} />
<Text fw={700} fz={15} c={INK}>
Customs clearance shipment
</Text>
</Group>
<Text fz={13} c="dimmed">
{contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
? "Upload your clearance documents so Global Logistics can review them. After approval, Global Logistics creates your booking — you only pay the freight."
: contract.status === "CLEARANCE_UNDER_REVIEW"
? "Global Logistics is reviewing your clearance documents. Re-upload any queried documents to proceed."
: "Your documents are cleared. Global Logistics will create your booking shortly — you will be notified when payment is due."}
</Text>
</Paper>
)}
{/* Unit-rate schedule */}
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<Group gap={8} mb="md">
<Text fw={700} fz={16} style={{ color: INK }}>
Pricing schedule
</Text>
<Card
withBorder
radius="lg"
p="lg"
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
>
<Group justify="space-between" align="center" mb={4}>
<SectionLabel>Pricing schedule</SectionLabel>
<Badge size="sm" variant="light" color="edr-green" radius="sm">
Unit rates
</Badge>
</Group>
<Text fz={13} c="dimmed" mb="md" mt={-6}>
<Text fz={13} c="dimmed" mb="md">
Per-unit rates frozen at submission. The final amount on each shipment
is computed from the quantities you ship.
</Text>
@@ -418,11 +447,14 @@ export default function ContractDetailPage() {
{/* Routes + cargo scope */}
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg">
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<Group gap={8} mb="md">
<Text fw={700} fz={16} style={{ color: INK }}>
Routes
</Text>
<Card
withBorder
radius="lg"
p="lg"
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
>
<Group justify="space-between" align="center" mb="md">
<SectionLabel>Routes</SectionLabel>
<Badge size="sm" variant="light" color="violet" radius="sm">
{routes.length || 1}
</Badge>
@@ -436,41 +468,35 @@ export default function ContractDetailPage() {
{routes.map((route) => (
<Group
key={route.id}
justify="space-between"
gap={12}
wrap="nowrap"
p="sm"
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
>
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
size={38}
radius="md"
variant="light"
color="edr-green"
>
<MapPin size={18} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} style={{ color: INK }} truncate>
{route.originYard?.label ?? route.originYardId} {" "}
{route.destinationYard?.label ?? route.destinationYardId}
<MapPin size={16} color={MUTED} style={{ flexShrink: 0 }} />
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} style={{ color: INK }} truncate>
{route.originYard?.label ?? route.originYardId} {" "}
{route.destinationYard?.label ?? route.destinationYardId}
</Text>
{route.km != null && (
<Text fz={12} c="dimmed">
{route.km} km
</Text>
{route.km != null && (
<Text fz={12} c="dimmed">
{route.km} km
</Text>
)}
</Box>
</Group>
)}
</Box>
</Group>
))}
</Stack>
</Card>
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<Text fw={700} fz={16} style={{ color: INK }} mb="md">
Cargo scope
</Text>
<Card
withBorder
radius="lg"
p="lg"
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
>
<SectionLabel mb="md">Cargo scope</SectionLabel>
<Stack gap={10}>
{(contract.cargoScope ?? []).map((scope) => (
<Group
@@ -480,14 +506,11 @@ export default function ContractDetailPage() {
p="sm"
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
>
<ThemeIcon
size={34}
radius="md"
variant="light"
color={isContainer ? "edr-green" : "orange"}
>
{isContainer ? <Package size={16} /> : <Weight size={16} />}
</ThemeIcon>
{isContainer ? (
<Package size={16} color={MUTED} style={{ flexShrink: 0 }} />
) : (
<Weight size={16} color={MUTED} style={{ flexShrink: 0 }} />
)}
<Text fz={14} fw={600} style={{ color: INK }}>
{scope.containerSize ??
scope.cargoFreeText ??
@@ -528,10 +551,13 @@ export default function ContractDetailPage() {
{/* Signatures */}
{(contract.signatures ?? []).length > 0 && (
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<Text fw={700} fz={16} style={{ color: INK }} mb="md">
Signatures
</Text>
<Card
withBorder
radius="lg"
p="lg"
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
>
<SectionLabel mb="md">Signatures</SectionLabel>
<Stack gap={10}>
{(contract.signatures ?? []).map((sig) => (
<Group
@@ -542,14 +568,11 @@ export default function ContractDetailPage() {
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
>
<Group gap={12} wrap="nowrap">
<ThemeIcon
size={34}
radius="md"
variant="light"
color="edr-green"
>
<CheckCircle2 size={16} />
</ThemeIcon>
<CheckCircle2
size={16}
color={GREEN}
style={{ flexShrink: 0 }}
/>
<Box>
<Text fz={14} fw={600} style={{ color: INK }}>
{sig.signerDisplayName}
@@ -567,68 +590,170 @@ export default function ContractDetailPage() {
</Stack>
</Card>
)}
</Stack>
</Tabs.Panel>
{/* Shipments under this contract */}
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<Group justify="space-between" align="center" mb="md">
<Text fw={700} fz={16} style={{ color: INK }}>
Shipments
</Text>
<Badge variant="light" color="violet" radius="sm">
{contractBookings.length}
</Badge>
</Group>
{contractBookings.length === 0 ? (
<Stack align="center" gap={8} py="xl">
<ThemeIcon size={48} radius="xl" variant="light" color="gray">
<Inbox size={22} />
</ThemeIcon>
<Text fz={13} c="dimmed" ta="center" maw={380}>
{canBookShipment
? "No shipments yet. Use “New shipment booking” to ship against this contract."
: customsPath
? "No shipments yet. After your clearance documents are approved, Global Logistics creates the booking on your behalf."
: "Shipments appear here once the contract is fully executed."}
</Text>
</Stack>
) : (
<Stack gap={10}>
{contractBookings.map((booking) => (
<Group
key={booking.id}
justify="space-between"
wrap="nowrap"
p="sm"
style={{ borderRadius: 12, border: `1px solid ${BORDER}`, cursor: "pointer" }}
onClick={() => navigate(`/bookings/${booking.id}`)}
>
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
size={38}
radius="md"
variant="light"
color="violet"
{/* ── Documents tab ─────────────────────────────────────────── */}
<Tabs.Panel value="documents">
<Card
withBorder
radius="lg"
p="lg"
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
>
<Group justify="space-between" align="center" mb="md">
<SectionLabel>Contract documents</SectionLabel>
{contract.contractGeneratedAt && (
<Badge size="sm" variant="light" color="edr-green" radius="sm">
Generated
</Badge>
)}
</Group>
{files.length === 0 ? (
<Stack align="center" gap={10} py="xl">
<FileText size={26} color={MUTED} style={{ opacity: 0.5 }} />
<Text fz={13} c="dimmed" ta="center" maw={420}>
No documents yet. The signed contract and any uploaded
clearance documents will appear here.
</Text>
</Stack>
) : (
<Stack gap={10}>
{files.map((file) => (
<Group
key={file.id}
justify="space-between"
wrap="nowrap"
p="sm"
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
>
<Package size={18} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} style={{ color: INK }} truncate>
{booking.reference}
</Text>
{booking.scheduledDate && (
<Text fz={12} c="dimmed" truncate>
Ship{" "}
{new Date(booking.scheduledDate).toLocaleDateString()}
</Text>
)}
</Box>
</Group>
<ContractStatusBadge status={booking.status} />
</Group>
))}
</Stack>
)}
</Card>
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<FileText
size={16}
color={MUTED}
style={{ flexShrink: 0 }}
/>
<Box style={{ minWidth: 0 }}>
<Text
fz={14}
fw={600}
style={{ color: INK }}
truncate
>
{file.name}
</Text>
<Text fz={12} c="dimmed">
{file.mimeType?.split("/")[1]?.toUpperCase() ??
"FILE"}
{file.size
? ` · ${(file.size / 1024).toFixed(0)} KB`
: ""}
</Text>
</Box>
</Group>
<Button
component="a"
href={file.signedUrl ?? file.url}
target="_blank"
rel="noopener noreferrer"
variant="light"
color="edr-green"
size="xs"
radius="md"
leftSection={<Download size={14} />}
>
Open
</Button>
</Group>
))}
</Stack>
)}
</Card>
</Tabs.Panel>
{/* ── Bookings tab ──────────────────────────────────────────── */}
<Tabs.Panel value="bookings">
<Card
withBorder
radius="lg"
p="lg"
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
>
<Group justify="space-between" align="center" mb="md">
<SectionLabel>Bookings under this contract</SectionLabel>
{canBookShipment && (
<Button
color="edr-green"
radius="md"
size="xs"
leftSection={<PackagePlus size={14} />}
onClick={() =>
navigate(`/contracts/${contract.id}/bookings/new`)
}
>
New booking
</Button>
)}
</Group>
{contractBookings.length === 0 ? (
<Stack align="center" gap={10} py="xl">
<Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} />
<Text fz={13} c="dimmed" ta="center" maw={380}>
{canBookShipment
? "No bookings yet. Use “New booking” to ship against this contract."
: customsPath
? "No bookings yet. After your clearance documents are approved, Global Logistics creates the booking on your behalf."
: "Bookings appear here once the contract is fully executed."}
</Text>
</Stack>
) : (
<Stack gap={10}>
{contractBookings.map((booking) => (
<Group
key={booking.id}
justify="space-between"
wrap="nowrap"
p="sm"
style={{
borderRadius: 12,
border: `1px solid ${BORDER}`,
cursor: "pointer",
}}
onClick={() => navigate(`/bookings/${booking.id}`)}
>
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<Package
size={16}
color={MUTED}
style={{ flexShrink: 0 }}
/>
<Box style={{ minWidth: 0 }}>
<Text
fz={14}
fw={700}
style={{ color: INK }}
truncate
>
{booking.reference}
</Text>
{booking.scheduledDate && (
<Text fz={12} c="dimmed" truncate>
Ship{" "}
{new Date(
booking.scheduledDate,
).toLocaleDateString()}
</Text>
)}
</Box>
</Group>
<ContractStatusBadge status={booking.status} />
</Group>
))}
</Stack>
)}
</Card>
</Tabs.Panel>
</Tabs>
</Stack>
{/* Sign modal */}
@@ -680,37 +805,100 @@ export default function ContractDetailPage() {
);
}
function KeyFact({
icon,
label,
value,
// Subtle card elevation shared across the detail page sections.
const CARD_SHADOW = "0 1px 2px rgba(16,24,40,0.04)";
// Accent hues matching the contract-ui StatCard rail palette.
const KEY_FACT_ACCENT: Record<string, string> = {
"edr-green": GREEN,
violet: "#6A40B8",
orange: "#C77F09",
};
/** A small uppercase eyebrow used as a section heading. */
function SectionLabel({
children,
mb,
}: {
icon: React.ReactNode;
label: string;
value: React.ReactNode;
children: React.ReactNode;
mb?: number | string;
}) {
return (
<Group gap={10} wrap="nowrap" align="flex-start">
<Text
fz={11}
fw={700}
tt="uppercase"
c="dimmed"
mb={mb}
style={{ letterSpacing: "0.06em" }}
>
{children}
</Text>
);
}
/**
* One fact inside the unified key-facts card: a soft accent-tinted icon chip,
* an uppercase label, and the value. Hairline dividers between cells make the
* row read as a single card rather than five tiles.
*/
function FactCell({
icon: Icon,
label,
value,
color = "edr-green",
}: {
icon: LucideIcon;
label: string;
value: React.ReactNode;
color?: string;
}) {
const accent = KEY_FACT_ACCENT[color] ?? GREEN;
return (
<Group
gap={12}
align="center"
wrap="nowrap"
p="lg"
style={{
minWidth: 0,
borderRight: `1px solid rgba(16,24,40,0.06)`,
borderBottom: `1px solid rgba(16,24,40,0.06)`,
}}
>
<Box
style={{
width: 34,
height: 34,
borderRadius: 9,
width: 40,
height: 40,
flexShrink: 0,
background: "#F1F6FA",
color: "#0A6F4D",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: accent,
background: `${accent}14`,
}}
>
{icon}
<Icon size={19} strokeWidth={2} />
</Box>
<Box miw={0}>
<Text fz="11.5px" fw={600} c="#9AA8B5">
<Box style={{ minWidth: 0 }}>
<Text
fz={10.5}
fw={700}
tt="uppercase"
c="dimmed"
style={{ letterSpacing: "0.06em" }}
>
{label}
</Text>
<Text mt={2} fz="14px" fw={700} c="#10202F" truncate>
<Text
fz={16}
fw={800}
lh={1.2}
mt={3}
truncate
style={{ color: INK, letterSpacing: "-0.01em" }}
>
{value}
</Text>
</Box>

View File

@@ -11,13 +11,11 @@ import {
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import {
CheckCircle2,
FileStack,
Layers,
Package,
Plus,
Search,
@@ -35,7 +33,7 @@ import {
type ColumnDef,
usePagination,
} from "@edr/ui-common";
import { BORDER, ContractStatusBadge, INK, StatCard } from "./contract-ui";
import { BORDER, ContractStatusBadge, INK, MUTED, StatCard } from "./contract-ui";
function primaryRoute(contract: Freight.IContract) {
const route = contract.routes?.[0];
@@ -138,26 +136,15 @@ export default function ContractsList() {
const c = row.original;
const isGeneral = c.contractKind === "GENERAL";
return (
<Group gap={12} wrap="nowrap" align="center">
<ThemeIcon
size={38}
radius="md"
variant="light"
color={isGeneral ? "violet" : "edr-green"}
style={{ flexShrink: 0 }}
>
<Layers size={18} />
</ThemeIcon>
<div>
<Text fz={14} fw={700} style={{ color: INK }}>
{c.reference}
</Text>
<Text fz={12} c="dimmed">
{isGeneral ? "General" : "One-Time"} ·{" "}
{c.freightType === "CONTAINER" ? "Containerised" : "Bulk"}
</Text>
</div>
</Group>
<div>
<Text fz={14} fw={700} style={{ color: INK }}>
{c.reference}
</Text>
<Text fz={12} c="dimmed">
{isGeneral ? "General" : "One-Time"} ·{" "}
{c.freightType === "CONTAINER" ? "Containerised" : "Bulk"}
</Text>
</div>
);
},
},
@@ -167,15 +154,12 @@ export default function ContractsList() {
cell: ({ row }) => {
const isContainer = row.original.freightType === "CONTAINER";
return (
<Group gap={8} wrap="nowrap" align="center">
<ThemeIcon
size={28}
radius="md"
variant="light"
color={isContainer ? "edr-green" : "orange"}
>
{isContainer ? <Package size={15} /> : <Weight size={15} />}
</ThemeIcon>
<Group gap={7} wrap="nowrap" align="center">
{isContainer ? (
<Package size={15} color={MUTED} />
) : (
<Weight size={15} color={MUTED} />
)}
<Text fz={13} style={{ color: INK }}>
{isContainer ? "Container" : "Bulk"}
</Text>
@@ -205,6 +189,42 @@ export default function ContractsList() {
);
},
},
{
id: "trade",
header: () => <ColHeader label="Trade" />,
cell: ({ row }) => {
const dir = row.original.tradeDirection;
const label = dir
? dir.charAt(0) + dir.slice(1).toLowerCase()
: "—";
return (
<Text fz={13} c={dir ? undefined : "dimmed"} style={{ color: dir ? INK : undefined }}>
{label}
</Text>
);
},
},
{
id: "currency",
header: () => <ColHeader label="Currency" />,
cell: ({ row }) => (
<Text fz={13} style={{ color: INK }}>
{row.original.paymentCurrency ?? "—"}
</Text>
),
},
{
id: "created",
header: () => <ColHeader label="Created" />,
cell: ({ row }) => {
const created = row.original.createdAt;
return (
<Text fz={13} c={created ? undefined : "dimmed"} style={{ color: created ? INK : undefined }}>
{created ? new Date(created).toLocaleDateString() : "—"}
</Text>
);
},
},
{
id: "validUntil",
header: () => <ColHeader label="Valid Until" />,
@@ -237,25 +257,20 @@ export default function ContractsList() {
<Stack gap="lg">
{/* Header */}
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
<Group gap={14} wrap="nowrap" align="center">
<ThemeIcon size={48} radius="lg" variant="light" color="violet">
<Layers size={24} />
</ThemeIcon>
<Box>
<Title
order={1}
fw={800}
fz={26}
style={{ letterSpacing: "-0.01em" }}
>
Contracts
</Title>
<Text size="sm" c="edr-muted" mt={4} maw={520}>
Your freight agreements one-time and general. Sign a contract,
then ship against it over its validity window.
</Text>
</Box>
</Group>
<Box>
<Title
order={1}
fw={800}
fz={26}
style={{ letterSpacing: "-0.01em" }}
>
Contracts
</Title>
<Text size="sm" c="edr-muted" mt={4} maw={520}>
Your freight agreements one-time and general. Sign a contract,
then ship against it over its validity window.
</Text>
</Box>
<Button
color="edr-green"
radius="md"

View File

@@ -36,7 +36,6 @@ import {
contractFormSchema,
contractStepFields,
initialContractFormValues,
type ContractDocuments,
type ContractFormValues,
type OperationType,
} from "./new-contract-form/schema";
@@ -60,6 +59,7 @@ import {
Step8Review,
StepDocuments,
} from "./new-contract-form/steps";
import { StepCard, StepHeader } from "./new-contract-form/shared";
import { formatRateUnit } from "./new-contract-form/unit-rates";
type PriceModalMode = "submit" | "draft";
@@ -329,10 +329,8 @@ export default function NewContractPage() {
: Freight.ContractFreightType.Bulk,
serviceTypeId: data.serviceTypeId,
paymentCurrency: data.paymentCurrency,
equipmentReturn:
data.equipmentReturn === "with_return"
? "WITH_RETURN"
: "WITHOUT_RETURN",
// Equipment return is decided at booking time, not on the contract. Omit
// it here so we don't send a value the contract API rejects.
isHazardous: data.isHazardous,
// Reefer is a contract-level flag for both container and bulk.
isReefer: data.isRefrigerated,
@@ -477,35 +475,52 @@ export default function NewContractPage() {
</Alert>
)}
{/* Step 0 — Setup: operation, contract, service, currency, miles. */}
{step === 0 && (
<Step0OperationType
form={form}
allowedOperations={allowedOperations}
onSelect={handleOperationSelect}
/>
<StepCard>
<StepHeader
title="Contract Setup"
description="Define the operation, contract kind, and the service this contract is for."
/>
<Stack gap={24}>
<Step0OperationType
form={form}
allowedOperations={allowedOperations}
onSelect={handleOperationSelect}
/>
<Step1ContractType form={form} referenceData={referenceData} />
<Step2ServiceType referenceData={referenceData} form={form} />
</Stack>
</StepCard>
)}
{/* Step 1 — Cargo & Route. */}
{step === 1 && (
<Step1ContractType form={form} referenceData={referenceData} />
)}
{step === 2 && (
<Step2ServiceType referenceData={referenceData} form={form} />
<StepCard>
<StepHeader
title="Cargo & Route"
description="Define what cargo this contract covers and the routes it runs. Quantities are captured later at booking."
/>
<Stack gap={24}>
<Step3CargoScope
form={form}
referenceData={referenceData}
isLoading={refDataLoading}
/>
<Step4Route
form={form}
referenceData={referenceData}
isLoading={refDataLoading}
/>
</Stack>
</StepCard>
)}
{/* Step 2 — Documents. */}
{step === 2 && <StepDocuments form={form} />}
{/* Step 3 — Review & Submit. */}
{step === 3 && (
<Step3CargoScope
form={form}
referenceData={referenceData}
isLoading={refDataLoading}
/>
)}
{step === 4 && (
<Step4Route
form={form}
referenceData={referenceData}
isLoading={refDataLoading}
/>
)}
{step === 6 && <StepDocuments form={form} />}
{step === 7 && (
<Step8Review
form={form}
setStep={setStep}

View File

@@ -1,4 +1,4 @@
import { Box, Badge, Group, Paper, Text, ThemeIcon } from "@mantine/core";
import { Box, Group, Paper, Text } from "@mantine/core";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
@@ -9,9 +9,18 @@ export const GREEN = "#0EA371";
export const GREEN_DARK = "#0A6F4D";
export const BORDER = "#E6ECF2";
// Accent hues used for the thin status rail on each KPI card.
const ACCENT: Record<string, string> = {
"edr-green": "#0EA371",
"edr-accent": "#F2A516",
violet: "#6A40B8",
orange: "#C77F09",
};
/**
* A compact KPI tile used on the contracts list + detail header strips. Icon in
* a tinted chip, big value, small label — consistent with the app's house cards.
* A clean KPI card for the contracts list + detail header strips. No filled
* background, no icon tile — just a thin colored accent rail, the value, and a
* muted label. The icon is a small ghost glyph in the corner.
*/
export function StatCard({
label,
@@ -26,27 +35,57 @@ export function StatCard({
icon: LucideIcon;
color?: string;
}) {
const accent = ACCENT[color] ?? GREEN;
return (
<Paper
withBorder
radius="lg"
p="md"
style={{ borderColor: BORDER, flex: 1, minWidth: 180 }}
p="lg"
style={{
borderColor: BORDER,
flex: 1,
minWidth: 180,
position: "relative",
overflow: "hidden",
// Soft accent-tinted wash instead of a hard left rail.
background: `linear-gradient(135deg, ${accent}0F 0%, ${accent}05 28%, #FFFFFF 70%)`,
}}
>
<Group gap={12} wrap="nowrap" align="center">
<ThemeIcon size={42} radius="md" variant="light" color={color}>
<Icon size={20} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fz={24} fw={800} lh={1.05} style={{ color: INK, letterSpacing: "-0.02em" }}>
{value}
</Text>
<Text fz={12} fw={600} c="dimmed" truncate>
{label}
{hint ? ` · ${hint}` : ""}
</Text>
</Box>
</Group>
{Icon && (
<Icon
size={18}
style={{
position: "absolute",
top: 16,
right: 16,
color: accent,
opacity: 0.45,
}}
/>
)}
<Text
fz={11}
fw={700}
tt="uppercase"
c="dimmed"
style={{ letterSpacing: "0.06em" }}
>
{label}
</Text>
<Text
fz={30}
fw={800}
lh={1.1}
mt={6}
style={{ color: INK, letterSpacing: "-0.02em" }}
>
{value}
</Text>
{hint && (
<Text fz={12} c="dimmed" mt={2} truncate>
{hint}
</Text>
)}
</Paper>
);
}
@@ -124,21 +163,30 @@ export function ContractStatusBadge({ status }: { status: string }) {
bg: "#EEF2F6",
};
return (
<Badge
variant="light"
radius="sm"
styles={{
root: {
backgroundColor: cfg.bg,
color: cfg.color,
fontWeight: 600,
textTransform: "none",
letterSpacing: 0,
},
<Group
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: cfg.bg,
padding: "4px 10px",
}}
>
{cfg.label}
</Badge>
<Box
style={{
width: 6,
height: 6,
borderRadius: "50%",
backgroundColor: cfg.color,
flexShrink: 0,
}}
/>
<Text fz={11.5} fw={700} style={{ color: cfg.color, whiteSpace: "nowrap" }}>
{cfg.label}
</Text>
</Group>
);
}

View File

@@ -8,7 +8,8 @@ const BORDER = "var(--mantine-color-edr-border-0)";
const MUTED = "var(--mantine-color-edr-muted-0)";
const INK = "var(--mantine-color-edr-text-0)";
type StepItem = (typeof CONTRACT_STEPS)[number];
// Structural so any wizard's step array works (contract + shipment wizards).
type StepItem = { id: number; label: string; short: string };
export function StepIndicator({
step,
@@ -69,16 +70,14 @@ export function StepIndicator({
</div>
<span
style={{
fontSize: 11,
fontSize: 12,
fontWeight: active ? 700 : 500,
textAlign: "center",
lineHeight: 1.2,
maxWidth: 72,
display: "none",
maxWidth: 110,
transition: "color 0.2s",
color: step >= item.id ? INK : MUTED,
}}
className="md:!block"
>
{item.short}
</span>

View File

@@ -1,21 +1,11 @@
import { Box, Group, Text } from "@mantine/core";
import { Banknote, Check, DollarSign } from "lucide-react";
import { Select, Text } from "@mantine/core";
import { Controller, type Control } from "react-hook-form";
import {
PAYMENT_CURRENCY_OPTIONS,
type ContractFormInputValues,
type ContractFormValues,
type PaymentCurrency,
} from "./schema";
import { OptionFieldError, StepLabel } from "./shared";
const CURRENCY_ICONS: Record<
PaymentCurrency,
{ icon: typeof DollarSign; color: string }
> = {
USD: { icon: DollarSign, color: "#4F46E5" },
ETB: { icon: Banknote, color: "#0A6F4D" },
};
import { fieldStyles } from "./shared";
export function PaymentCurrencyField({
control,
@@ -23,90 +13,40 @@ export function PaymentCurrencyField({
control: Control<ContractFormInputValues, any, ContractFormValues>;
}) {
return (
<Box mt={24}>
<StepLabel>Payment currency</StepLabel>
<Text fz={12} c="#6B7C8E" mt={4} mb={12}>
Choose the currency for your freight quote and invoices.
</Text>
<Controller
name="paymentCurrency"
control={control}
render={({ field, fieldState }) => (
<Controller
name="paymentCurrency"
control={control}
render={({ field, fieldState }) => {
const selected = PAYMENT_CURRENCY_OPTIONS.find(
(o) => o.value === field.value,
);
return (
<div>
<Group
gap={6}
wrap="nowrap"
p={4}
style={{
borderRadius: 12,
background: "#F1F4F7",
border: "1px solid #E6ECF2",
}}
>
{PAYMENT_CURRENCY_OPTIONS.map((option) => {
const Icon = CURRENCY_ICONS[option.value].icon;
const selected = field.value === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => field.onChange(option.value)}
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: "10px 14px",
borderRadius: 9,
cursor: "pointer",
border: "none",
background: selected ? "#fff" : "transparent",
boxShadow: selected
? "0 1px 3px rgba(16,32,47,0.10)"
: "none",
transition: "all 150ms ease",
}}
>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
color: selected
? CURRENCY_ICONS[option.value].color
: "#94A3B8",
}}
>
<Icon className="h-4 w-4" />
</Box>
<Text
fz={14}
fw={selected ? 700 : 600}
c={selected ? "#10202F" : "#64748B"}
>
{option.label}
</Text>
{selected && (
<Check
size={15}
color={CURRENCY_ICONS[option.value].color}
/>
)}
</button>
);
})}
</Group>
<Text fz={11.5} c="#6B7C8E" mt={8}>
{
PAYMENT_CURRENCY_OPTIONS.find((o) => o.value === field.value)
?.description
}
</Text>
<OptionFieldError error={fieldState.error} />
<Select
label="Payment Currency *"
placeholder="Select currency…"
data={PAYMENT_CURRENCY_OPTIONS.map((o) => ({
value: o.value,
label: o.label,
}))}
value={field.value || null}
onChange={(v) => v && field.onChange(v)}
onBlur={field.onBlur}
error={fieldState.error?.message}
allowDeselect={false}
radius={10}
checkIconPosition="right"
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
styles={fieldStyles}
/>
{selected && (
<Text fz={12} c="#6B7C8E" mt={6}>
{selected.description}
</Text>
)}
</div>
)}
/>
</Box>
);
}}
/>
);
}

View File

@@ -1,17 +1,15 @@
import { DeepPartial, Path } from "react-hook-form";
import * as z from "zod";
// Wizard steps for the contract creation flow. Mirrors the booking wizard but
// the cargo step collects SCOPE only (no quantities) and the route step collects
// a non-binding estimated shipment date (the binding date is at booking time).
// Wizard steps for the contract creation flow. Condensed to four steps: the
// pickers are dropdown selects so each step fits one screen without scrolling.
// Cargo collects SCOPE only (no quantities); the route step also collects a
// non-binding estimated shipment date (the binding date is at booking time).
export const CONTRACT_STEPS = [
{ id: 0, label: "Operation Type", short: "Operation" },
{ id: 1, label: "Contract Type", short: "Contract" },
{ id: 2, label: "Service Type & Mile", short: "Service" },
{ id: 3, label: "Cargo Scope", short: "Cargo" },
{ id: 4, label: "Route", short: "Route" },
{ id: 6, label: "Documents", short: "Documents" },
{ id: 7, label: "Review & Submit", short: "Submit" },
{ id: 0, label: "Setup", short: "Setup" },
{ id: 1, label: "Cargo & Route", short: "Cargo & Route" },
{ id: 2, label: "Documents", short: "Documents" },
{ id: 3, label: "Review & Submit", short: "Review" },
] as const;
export const OPERATION_TYPES = [
@@ -23,6 +21,55 @@ export const OPERATION_TYPES = [
] as const;
export type OperationType = (typeof OPERATION_TYPES)[number];
export const OPERATION_TYPE_OPTIONS: Array<{
value: OperationType;
label: string;
description: string;
}> = [
{
value: "import",
label: "Import",
description: "Cargo arriving into Ethiopia via Djibouti.",
},
{
value: "export",
label: "Export",
description: "Cargo leaving Ethiopia bound for Djibouti.",
},
{
value: "intercity",
label: "Intercity",
description: "Domestic movement between Ethiopian yards.",
},
{
value: "import_ff",
label: "Import as Freight Forwarder",
description: "Import handled on behalf of a client.",
},
{
value: "export_ff",
label: "Export as Freight Forwarder",
description: "Export handled on behalf of a client.",
},
];
export const CONTRACT_KIND_OPTIONS: Array<{
value: ContractKindOption;
label: string;
description: string;
}> = [
{
value: "one_time",
label: "One-Time Contract",
description: "A single shipment cycle — one active booking at a time.",
},
{
value: "general_contract",
label: "General Contract",
description: "Ship multiple times over the validity window across routes.",
},
];
export type ContractDocuments = Record<string, File | File[] | null>;
export const PAYMENT_CURRENCIES = ["USD", "ETB"] as const;
@@ -69,6 +116,8 @@ export const contractFormSchema = z
.object({
enabled: z.boolean().default(false),
pickUpAddress: z.string().default(""),
// Optional free-text exact location (gate, building, landmark…).
exactLocation: z.string().default(""),
lat: z.number().nullable().default(null),
lng: z.number().nullable().default(null),
})
@@ -80,6 +129,8 @@ export const contractFormSchema = z
.object({
enabled: z.boolean().default(false),
deliveryAddress: z.string().default(""),
// Optional free-text exact location (gate, building, landmark…).
exactLocation: z.string().default(""),
lat: z.number().nullable().default(null),
lng: z.number().nullable().default(null),
})
@@ -201,8 +252,8 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
serviceTypeId: "",
paymentCurrency: "USD",
firstMile: { enabled: false, pickUpAddress: "", lat: null, lng: null },
lastMile: { enabled: false, deliveryAddress: "", lat: null, lng: null },
firstMile: { enabled: false, pickUpAddress: "", exactLocation: "", lat: null, lng: null },
lastMile: { enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null },
equipmentReturn: "with_return",
customsClearingEnabled: false,
customsClearingAgent: "",
@@ -228,9 +279,12 @@ export const contractStepFields: Record<
number,
Array<Path<ContractFormValues>>
> = {
0: ["operationType"],
1: ["contractKind", "contractType", "previousContractRef"],
2: [
// Step 0 — Setup: operation, contract kind/type, service, currency, miles.
0: [
"operationType",
"contractKind",
"contractType",
"previousContractRef",
"serviceTypeId",
"paymentCurrency",
"equipmentReturn",
@@ -239,7 +293,8 @@ export const contractStepFields: Record<
"firstMile",
"lastMile",
],
3: [
// Step 1 — Cargo & Route: scope, sizes, flags, origin/destination, date.
1: [
"cargoType",
"enabledContainerSizes",
"cargoCommodityId",
@@ -247,13 +302,13 @@ export const contractStepFields: Record<
"cargoFreeText",
"isHazardous",
"isRefrigerated",
],
4: [
"originYard",
"destinationYard",
"extraRoutes",
"estimatedShipmentDate",
],
6: ["documents"],
7: ["notes"],
// Step 2 — Documents.
2: ["documents"],
// Step 3 — Review & Submit.
3: ["notes"],
};

View File

@@ -1,24 +1,12 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import {
ArrowDownToLine,
ArrowUpFromLine,
PackageCheck,
PackageOpen,
Truck,
} from "lucide-react";
import { Text } from "@mantine/core";
import { Select, Text } from "@mantine/core";
import {
ContractFormInputValues,
OPERATION_TYPE_OPTIONS,
type ContractFormValues,
type OperationType,
} from "./schema";
import {
AlertBox,
OptionCard,
OptionFieldError,
StepCard,
StepHeader,
} from "./shared";
import { AlertBox, fieldStyles } from "./shared";
type ContractForm = UseFormReturn<
ContractFormInputValues,
@@ -26,56 +14,11 @@ type ContractForm = UseFormReturn<
ContractFormValues
>;
const OPTIONS: Array<{
value: OperationType;
title: string;
description: string;
icon: React.ReactNode;
iconBg: string;
iconColor: string;
}> = [
{
value: "import",
title: "Import",
description: "Cargo arriving into Ethiopia via Djibouti.",
icon: <ArrowDownToLine className="h-5 w-5" />,
iconBg: "#ECF6F1",
iconColor: "#0A6F4D",
},
{
value: "export",
title: "Export",
description: "Cargo leaving Ethiopia bound for Djibouti.",
icon: <ArrowUpFromLine className="h-5 w-5" />,
iconBg: "#EAF1FB",
iconColor: "#2E5B96",
},
{
value: "intercity",
title: "Intercity",
description: "Domestic movement between Ethiopian yards.",
icon: <Truck className="h-5 w-5" />,
iconBg: "#F1ECFB",
iconColor: "#6A40B8",
},
{
value: "import_ff",
title: "Import as FF",
description: "Import handled on behalf of a client as a freight forwarder.",
icon: <PackageOpen className="h-5 w-5" />,
iconBg: "#ECF6F1",
iconColor: "#0A6F4D",
},
{
value: "export_ff",
title: "Export as FF",
description: "Export handled on behalf of a client as a freight forwarder.",
icon: <PackageCheck className="h-5 w-5" />,
iconBg: "#EAF1FB",
iconColor: "#2E5B96",
},
];
/**
* Operation type — a single dropdown. The available options reflect the
* operations the company is registered for; selecting one switches the active
* company profile so the rest of the wizard is stamped correctly.
*/
export function Step0OperationType({
form,
allowedOperations,
@@ -85,14 +28,12 @@ export function Step0OperationType({
allowedOperations: OperationType[];
onSelect?: (op: OperationType) => void;
}) {
return (
<StepCard>
<StepHeader
icon={<Truck size={22} />}
title="Operation Type"
description="Choose what this contract is for. The options available reflect the operations your company is registered for."
/>
const data = OPERATION_TYPE_OPTIONS.filter((opt) =>
allowedOperations.includes(opt.value),
).map((opt) => ({ value: opt.value, label: opt.label }));
return (
<div className="space-y-3">
{allowedOperations.length === 0 && (
<AlertBox tone="error">
Your company has no operational profile yet. Complete onboarding to
@@ -103,36 +44,44 @@ export function Step0OperationType({
<Controller
name="operationType"
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-4 md:grid-cols-3">
{OPTIONS.filter((opt) =>
allowedOperations.includes(opt.value),
).map((opt) => (
<OptionCard
key={opt.value}
selected={field.value === opt.value}
icon={opt.icon}
iconBg={opt.iconBg}
iconColor={opt.iconColor}
title={opt.title}
description={opt.description}
onClick={() => {
field.onChange(opt.value);
onSelect?.(opt.value);
}}
/>
))}
render={({ field, fieldState }) => {
const selected = OPERATION_TYPE_OPTIONS.find(
(o) => o.value === field.value,
);
return (
<div>
<Select
label="Operation Type *"
placeholder="Select an operation…"
data={data}
value={field.value || null}
onChange={(v) => {
if (!v) return;
field.onChange(v);
onSelect?.(v as OperationType);
}}
onBlur={field.onBlur}
error={fieldState.error?.message}
allowDeselect={false}
radius={10}
checkIconPosition="right"
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
styles={fieldStyles}
/>
{selected && (
<Text fz={12.5} c="#6B7C8E" mt={6}>
{selected.description}
</Text>
)}
</div>
<OptionFieldError error={fieldState.error} />
</div>
)}
);
}}
/>
<Text fz={12} c="edr-muted" mt={14}>
<Text fz={12} c="#6B7C8E">
Import and Export are stamped to your matching company profile; their
documents are attached automatically at submission.
</Text>
</StepCard>
</div>
);
}

View File

@@ -1,25 +1,20 @@
import { api } from "@/services/api";
import type { Freight } from "@edr/types";
import { useQuery } from "@tanstack/react-query";
import {
CalendarClock,
FileSignature,
FileText,
Layers,
RefreshCw,
} from "lucide-react";
import { useMemo, useState } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { Divider, Stack, Text } from "@mantine/core";
import { ContractFormInputValues, type ContractFormValues } from "./schema";
import { Select, Stack, Text } from "@mantine/core";
import {
AlertBox,
AsyncComboboxField,
OptionCard,
OptionFieldError,
StepCard,
StepHeader,
} from "./shared";
CONTRACT_KIND_OPTIONS,
ContractFormInputValues,
type ContractFormValues,
} from "./schema";
import { AlertBox, AsyncComboboxField, fieldStyles } from "./shared";
const CONTRACT_TYPE_OPTIONS = [
{ value: "new", label: "New Contract" },
{ value: "renewal", label: "Contract Renewal" },
];
type ContractForm = UseFormReturn<
ContractFormInputValues,
@@ -171,89 +166,73 @@ export function Step1ContractType({
};
return (
<StepCard>
<StepHeader
icon={<FileSignature size={22} />}
title="Contract Type"
description="Choose a one-time contract or a general contract you can ship against multiple times over its validity window."
/>
<Stack gap={16}>
<div className="grid gap-4 sm:grid-cols-2">
<Controller
name="contractKind"
control={form.control}
render={({ field }) => {
const selected = CONTRACT_KIND_OPTIONS.find(
(o) => o.value === (field.value ?? "one_time"),
);
return (
<div>
<Select
label="Contract Kind *"
data={CONTRACT_KIND_OPTIONS.map((o) => ({
value: o.value,
label: o.label,
}))}
value={field.value ?? "one_time"}
onChange={(v) => field.onChange(v ?? "one_time")}
allowDeselect={false}
radius={10}
checkIconPosition="right"
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
styles={fieldStyles}
/>
{selected && (
<Text fz={12} c="#6B7C8E" mt={6}>
{selected.description}
</Text>
)}
</div>
);
}}
/>
<Controller
name="contractKind"
control={form.control}
render={({ field }) => (
<div className="grid gap-4 md:grid-cols-2">
<OptionCard
selected={field.value !== "general_contract"}
icon={<CalendarClock className="h-5 w-5" />}
iconBg="#ECF6F1"
iconColor="#0A6F4D"
title="One-Time Contract"
description="A single shipment cycle — one active booking at a time under this contract."
onClick={() => field.onChange("one_time")}
/>
<OptionCard
selected={field.value === "general_contract"}
icon={<Layers className="h-5 w-5" />}
iconBg="#F1ECFB"
iconColor="#6A40B8"
title="General Contract"
description="Ship multiple times over the validity window across one or more routes — no quantity cap."
onClick={() => field.onChange("general_contract")}
/>
</div>
)}
/>
<Divider my={24} />
<Text fw={700} fz={15} mb={4} style={{ color: "#10202F" }}>
New or Renewal
</Text>
<Text fz={13} c="edr-muted" mb={16}>
Start a fresh contract or renew an existing one to reuse its details.
</Text>
<Controller
name="contractType"
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-4 md:grid-cols-2">
<OptionCard
selected={field.value === "new"}
icon={<FileText className="h-5 w-5" />}
iconBg="#ECF6F1"
iconColor="#0A6F4D"
title="New Contract"
description="Create a fresh freight contract from scratch."
onClick={() => {
field.onChange("new");
form.clearErrors(["contractType", "previousContractRef"]);
<Controller
name="contractType"
control={form.control}
render={({ field, fieldState }) => (
<Select
label="New or Renewal *"
placeholder="Select…"
data={CONTRACT_TYPE_OPTIONS}
value={field.value || null}
onChange={(v) => {
if (!v) return;
field.onChange(v);
form.clearErrors("contractType");
if (v === "new") {
form.clearErrors("previousContractRef");
form.setValue("previousContractRef", "");
}}
/>
<OptionCard
selected={field.value === "renewal"}
icon={<RefreshCw className="h-5 w-5" />}
iconBg="#EAF1FB"
iconColor="#2E5B96"
title="Contract Renewal"
description="Pick a previous reference to auto-fill historical parameters."
onClick={() => {
field.onChange("renewal");
form.clearErrors("contractType");
}}
/>
</div>
<OptionFieldError error={fieldState.error} />
</div>
)}
/>
}
}}
onBlur={field.onBlur}
error={fieldState.error?.message}
allowDeselect={false}
radius={10}
checkIconPosition="right"
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
styles={fieldStyles}
/>
)}
/>
</div>
{contractType === "renewal" && (
<Stack gap={12} mt={22}>
<Stack gap={12}>
{error && (
<AlertBox tone="error">
Failed to load previous contracts. Please try again later.
@@ -284,6 +263,6 @@ export function Step1ContractType({
)}
</Stack>
)}
</StepCard>
</Stack>
);
}

View File

@@ -1,10 +1,10 @@
import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core";
import { Box, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
import type { ReactNode } from "react";
import { Check, FileText, Info, Layers, Train, Truck } from "lucide-react";
import { useEffect, useRef } from "react";
import { FileText, Info, Truck } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { ContractFormInputValues, type ContractFormValues } from "./schema";
import { fieldStyles, OptionFieldError, StepCard, StepHeader, StepLabel } from "./shared";
import { fieldStyles, StepLabel } from "./shared";
import { PaymentCurrencyField } from "./payment-currency-field";
import { LocationPicker } from "@/pages/bookings/new-booking-form/LocationPicker";
@@ -37,7 +37,7 @@ export function Step2ServiceType({
useEffect(() => {
form.setValue(
"firstMile",
{ enabled: false, pickUpAddress: "", lat: null, lng: null },
{ enabled: false, pickUpAddress: "", exactLocation: "", lat: null, lng: null },
{ shouldValidate: true },
);
}, [includesFirstMile]);
@@ -45,7 +45,7 @@ export function Step2ServiceType({
useEffect(() => {
form.setValue(
"lastMile",
{ enabled: false, deliveryAddress: "", lat: null, lng: null },
{ enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null },
{ shouldValidate: true },
);
}, [includesLastMile]);
@@ -69,41 +69,50 @@ export function Step2ServiceType({
const showServiceSections =
serviceType != null || includesFirstMile || includesLastMile;
const serviceOptions = useMemo(
() =>
(referenceData?.service ?? [])
.filter((s) => s.canBeBookedAlone)
.map((s) => ({ value: s.id, label: s.serviceName })),
[referenceData],
);
return (
<StepCard>
<StepHeader
icon={<Layers size={22} />}
title="Service Type"
description="Choose the service combination, then configure your trucking and customs options."
/>
<Controller
name="serviceTypeId"
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-3 sm:grid-cols-2">
{referenceData?.service
.filter((s) => s.canBeBookedAlone)
.map((s) => (
<ServiceTypeCard
key={s.id}
selected={field.value === s.id}
onClick={() => field.onChange(s.id)}
title={s.serviceName}
description={s.description}
/>
))}
<Stack gap={16}>
<div className="grid gap-4 sm:grid-cols-2">
<Controller
name="serviceTypeId"
control={form.control}
render={({ field, fieldState }) => (
<div>
<Select
label="Service Type *"
placeholder="Select a service…"
data={serviceOptions}
value={field.value || null}
onChange={(v) => v && field.onChange(v)}
onBlur={field.onBlur}
error={fieldState.error?.message}
allowDeselect={false}
radius={10}
checkIconPosition="right"
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
styles={fieldStyles}
/>
{serviceType?.description && (
<Text fz={12} c="#6B7C8E" mt={6}>
{serviceType.description}
</Text>
)}
</div>
<OptionFieldError error={fieldState.error} />
</div>
)}
/>
)}
/>
<PaymentCurrencyField control={form.control} />
<PaymentCurrencyField control={form.control} />
</div>
{showServiceSections && (
<Stack gap={12} mt={24}>
<Stack gap={12}>
<StepLabel>Trucking & customs options</StepLabel>
{includesFirstMile && (
@@ -124,6 +133,7 @@ export function Step2ServiceType({
{
enabled: false,
pickUpAddress: "",
exactLocation: "",
lat: null,
lng: null,
},
@@ -133,12 +143,13 @@ export function Step2ServiceType({
}}
>
{firstMileEnabled && (
<Box mt="md">
<Stack mt="md" gap={12}>
<Controller
name="firstMile"
control={form.control}
render={({ field: mf, fieldState }) => (
<LocationPicker
variant="modal"
label="Pick-up location"
placeholder="Search the pick-up address…"
error={
@@ -165,7 +176,21 @@ export function Step2ServiceType({
/>
)}
/>
</Box>
<Controller
name="firstMile.exactLocation"
control={form.control}
render={({ field: ef }) => (
<TextInput
label="Exact location (optional)"
placeholder="Gate, building, floor, landmark…"
value={ef.value ?? ""}
onChange={(e) => ef.onChange(e.currentTarget.value)}
radius={10}
styles={fieldStyles}
/>
)}
/>
</Stack>
)}
</ServiceToggle>
)}
@@ -190,24 +215,23 @@ export function Step2ServiceType({
{
enabled: false,
deliveryAddress: "",
exactLocation: "",
lat: null,
lng: null,
},
{ shouldDirty: true, shouldValidate: true },
);
form.setValue("equipmentReturn", "with_return", {
shouldDirty: true,
});
}
}}
>
{lastMileEnabled && (
<Box mt="md">
<Stack mt="md" gap={12}>
<Controller
name="lastMile"
control={form.control}
render={({ field: mf, fieldState }) => (
<LocationPicker
variant="modal"
label="Delivery location"
placeholder="Search the delivery address…"
error={
@@ -234,34 +258,27 @@ export function Step2ServiceType({
/>
)}
/>
</Box>
<Controller
name="lastMile.exactLocation"
control={form.control}
render={({ field: ef }) => (
<TextInput
label="Exact location (optional)"
placeholder="Gate, building, floor, landmark…"
value={ef.value ?? ""}
onChange={(e) => ef.onChange(e.currentTarget.value)}
radius={10}
styles={fieldStyles}
/>
)}
/>
</Stack>
)}
</ServiceToggle>
)}
/>
)}
{includesLastMile && lastMileEnabled && (
<Controller
name="equipmentReturn"
control={form.control}
render={({ field }) => (
<ServiceToggle
icon={<Truck size={18} />}
title="Equipment Return"
description={
field.value === "with_return"
? "Container returned to EDR after unloading."
: "Container retained by the customer after delivery."
}
checked={field.value === "with_return"}
onChange={(v) =>
field.onChange(v ? "with_return" : "without_return")
}
/>
)}
/>
)}
{includesCustoms ? (
<Box
@@ -362,88 +379,7 @@ export function Step2ServiceType({
)}
</Stack>
)}
</StepCard>
);
}
function ServiceTypeCard({
selected,
onClick,
title,
description,
}: {
selected: boolean;
onClick: () => void;
title?: ReactNode;
description?: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
style={{
width: "100%",
textAlign: "left",
cursor: "pointer",
borderRadius: 12,
padding: "12px 14px",
transition: "all 140ms ease",
border: `1.5px solid ${selected ? "#12B981" : "#E6ECF2"}`,
background: selected ? "#F4FBF7" : "#fff",
boxShadow: selected
? "0 0 0 1px #12B981, 0 4px 12px rgba(14,163,113,0.10)"
: "0 1px 2px rgba(16,24,40,0.04)",
}}
onMouseEnter={(e) => {
if (!selected) e.currentTarget.style.borderColor = "#BFE3D2";
}}
onMouseLeave={(e) => {
if (!selected) e.currentTarget.style.borderColor = "#E6ECF2";
}}
>
<Group gap={11} align="center" wrap="nowrap">
<Box
style={{
width: 34,
height: 34,
flexShrink: 0,
borderRadius: 9,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: selected ? "#E3F4EC" : "#EEF0FB",
color: selected ? "#0A6F4D" : "#4F46E5",
}}
>
<Train size={17} />
</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text fz={13.5} fw={700} c="#10202F" truncate>
{title}
</Text>
{description && (
<Text fz={11.5} c="#6B7C8E" truncate style={{ lineHeight: 1.35 }}>
{description}
</Text>
)}
</Box>
<Box
style={{
width: 18,
height: 18,
flexShrink: 0,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: selected ? "none" : "1.5px solid #CBD5E1",
background: selected ? "#12B981" : "transparent",
}}
>
{selected && <Check size={11} color="#fff" strokeWidth={3} />}
</Box>
</Group>
</button>
</Stack>
);
}

View File

@@ -1,21 +1,23 @@
import { useEffect, useMemo, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { Flame, Package, Snowflake, Weight } from "lucide-react";
import { Box, Group, Skeleton, Stack, Switch, Text } from "@mantine/core";
import { Flame, Snowflake } from "lucide-react";
import { Box, Group, MultiSelect, Select, Skeleton, Stack, Switch, Text } from "@mantine/core";
import type { Freight } from "@edr/types";
import {
CONTAINER_SIZES,
ContractFormInputValues,
type ContractFormValues,
} from "./schema";
import {
OptionCard,
OptionFieldError,
SelectField,
StepCard,
StepHeader,
StepLabel,
} from "./shared";
import { fieldStyles, SelectField, StepLabel } from "./shared";
const CONTAINER_SIZE_OPTIONS = [
{ value: "20ft", label: "20ft Container (TEU)" },
{ value: "40ft", label: "40ft Container (FEU)" },
];
const CARGO_TYPE_OPTIONS = [
{ value: "container", label: "Containerized (20ft / 40ft)" },
{ value: "bulk", label: "General / Bulk cargo" },
];
type ContractForm = UseFormReturn<
ContractFormInputValues,
@@ -99,121 +101,79 @@ export function Step3CargoScope({
if (isLoading) {
return (
<StepCard>
<StepHeader
icon={<Package size={22} />}
title="Cargo Scope"
description="Define what cargo this contract covers — sizes and commodity, no quantities."
/>
<div className="space-y-4">
<Skeleton height={14} w={96} radius="sm" />
<div className="grid gap-4 sm:grid-cols-2">
<Skeleton height={96} radius="lg" />
<Skeleton height={96} radius="lg" />
</div>
<Skeleton height={44} radius="md" />
</div>
</StepCard>
<div className="space-y-4">
<Skeleton height={44} radius="md" />
<Skeleton height={44} radius="md" />
<Skeleton height={44} radius="md" />
</div>
);
}
return (
<StepCard>
<StepHeader
icon={<Package size={22} />}
title="Cargo Scope"
description="Define what cargo this contract covers. Quantities, container numbers and weights are captured later at booking."
/>
<div className="space-y-3">
<StepLabel>Cargo Type *</StepLabel>
<Stack gap={16}>
<div className="grid gap-4 sm:grid-cols-2">
<Controller
name="cargoType"
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-4 sm:grid-cols-2">
<OptionCard
selected={cargoType === "container"}
icon={<Package className="h-5 w-5" />}
iconBg="#ECF6F1"
iconColor="#0A6F4D"
title="Containerized"
description="Containerized cargo (20ft / 40ft)."
onClick={() => {
field.onChange("container");
form.setValue("cargoTypePath", [], { shouldDirty: true });
}}
/>
<OptionCard
selected={cargoType === "bulk"}
icon={<Weight className="h-5 w-5" />}
iconBg="#FDF3E0"
iconColor="#C77F09"
title="General Cargo"
description="Bulk commodities or break-bulk cargo."
onClick={() => {
field.onChange("bulk");
form.setValue("enabledContainerSizes", [], {
shouldDirty: true,
});
}}
/>
</div>
<OptionFieldError error={fieldState.error} />
</div>
<Select
label="Cargo Scope *"
placeholder="Select cargo type…"
data={CARGO_TYPE_OPTIONS}
value={field.value || null}
onChange={(v) => {
if (!v) return;
field.onChange(v);
if (v === "container") {
form.setValue("cargoTypePath", [], { shouldDirty: true });
} else {
form.setValue("enabledContainerSizes", [], {
shouldDirty: true,
});
}
}}
onBlur={field.onBlur}
error={fieldState.error?.message}
allowDeselect={false}
radius={10}
checkIconPosition="right"
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
styles={fieldStyles}
/>
)}
/>
</div>
{/* Container scope: enabled sizes + optional commodity label. */}
{cargoType === "container" && (
<Stack gap={14} mt={18}>
{/* Container scope: enabled sizes as a multi-select. */}
{cargoType === "container" && (
<Controller
name="enabledContainerSizes"
control={form.control}
render={({ field, fieldState }) => {
const selected = field.value ?? [];
const toggle = (size: "20ft" | "40ft") => {
const next = selected.includes(size)
? selected.filter((s) => s !== size)
: [...selected, size];
field.onChange(next);
};
return (
<div>
<StepLabel>Container sizes in scope *</StepLabel>
<Text fz={12} c="#6B7C8E" mt={4} mb={10}>
Enable each container size this contract may ship.
</Text>
<div className="grid gap-3 sm:grid-cols-2">
{CONTAINER_SIZES.map((size) => (
<OptionCard
key={size}
selected={selected.includes(size)}
icon={<Package className="h-5 w-5" />}
iconBg="#ECF6F1"
iconColor="#0A6F4D"
title={
size === "20ft"
? "20ft Container (TEU)"
: "40ft Container (FEU)"
}
description={
size === "20ft"
? "Twenty-foot equivalent unit."
: "Forty-foot equivalent unit."
}
onClick={() => toggle(size)}
/>
))}
</div>
<OptionFieldError error={fieldState.error} />
</div>
);
}}
render={({ field, fieldState }) => (
<MultiSelect
label="Container sizes in scope *"
placeholder={
(field.value ?? []).length ? undefined : "Select sizes…"
}
data={CONTAINER_SIZE_OPTIONS}
value={field.value ?? []}
onChange={(v) =>
field.onChange(v as ("20ft" | "40ft")[])
}
onBlur={field.onBlur}
error={fieldState.error?.message}
radius={10}
checkIconPosition="right"
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
styles={fieldStyles}
/>
)}
/>
)}
</div>
{/* Container commodity label (optional). */}
{cargoType === "container" && (
<Stack gap={14}>
{containerCommodityOptions.length > 0 && (
<Controller
name="cargoCommodityId"
@@ -274,7 +234,7 @@ export function Step3CargoScope({
)}
{/* Shared billing flags. */}
<Box mt={22}>
<Box>
<StepLabel>Cargo handling</StepLabel>
<Stack gap={12} mt={12}>
<Controller
@@ -287,7 +247,7 @@ export function Step3CargoScope({
iconColor="#C0392B"
title="Hazardous Material"
description="Applies a hazard surcharge as a per-container unit rate."
checked={field.value}
checked={field.value ?? false}
onChange={(v) => field.onChange(v)}
/>
)}
@@ -302,14 +262,14 @@ export function Step3CargoScope({
iconColor="#2E5B96"
title="Refrigerated Cargo"
description="Temperature-controlled transport applies a reefer surcharge."
checked={field.value}
checked={field.value ?? false}
onChange={(v) => field.onChange(v)}
/>
)}
/>
</Stack>
</Box>
</StepCard>
</Stack>
);
}

View File

@@ -1,12 +1,6 @@
import type { Freight } from "@edr/types";
import { Box, Button, Group, Skeleton, Stack, Text, TextInput } from "@mantine/core";
import {
CalendarDays,
MapPin,
Plus,
Route as RouteIcon,
Trash2,
} from "lucide-react";
import { CalendarDays, MapPin, Plus, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo } from "react";
import {
Controller,
@@ -15,7 +9,7 @@ import {
} from "react-hook-form";
import { ContractFormInputValues, type ContractFormValues } from "./schema";
import { getRouteDirection } from "./helpers";
import { SelectField, StepCard, StepHeader, StepLabel } from "./shared";
import { SelectField, StepLabel } from "./shared";
type ContractForm = UseFormReturn<
ContractFormInputValues,
@@ -138,13 +132,7 @@ export function Step4Route({
}, []);
return (
<StepCard>
<StepHeader
icon={<RouteIcon size={22} />}
title="Route"
description="Choose the origin and destination yards this contract covers, plus a non-binding estimated shipment date."
/>
<Stack gap={16}>
{isLoading ? (
<LoadingSkeleton />
) : (
@@ -300,7 +288,7 @@ export function Step4Route({
</Stack>
</Box>
)}
</StepCard>
</Stack>
);
}

View File

@@ -6,7 +6,6 @@ import {
Group,
Paper,
Stack,
Table,
Text,
Textarea,
} from "@mantine/core";
@@ -16,9 +15,10 @@ import {
CheckCircle2,
Circle,
ClipboardCheck,
Coins,
FileText,
MapPin,
Package,
Pencil,
Route,
Send,
Truck,
@@ -31,82 +31,56 @@ import {
import { StepHeader } from "./shared";
import { formatRateUnit } from "./unit-rates";
export const REVIEW_STEP_TARGETS = {
contract: 1,
service: 2,
cargo: 3,
route: 4,
schedule: 4,
documents: 6,
} as const;
type ContractForm = UseFormReturn<
ContractFormInputValues,
any,
ContractFormValues
>;
function OverviewSection({
/**
* A read-only detail tile: a soft icon chip, an uppercase label, and the value.
* Used in the review grid — no edit affordance, this page is summary-only.
*/
function SummaryItem({
icon,
title,
onEdit,
children,
label,
value,
}: {
icon: React.ReactNode;
title: string;
onEdit: () => void;
children: React.ReactNode;
label: string;
value: React.ReactNode;
}) {
return (
<Paper radius={16} p="lg" withBorder className="border-gray-200 bg-white">
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<Box
className="flex items-center justify-center rounded-lg"
style={{
width: 36,
height: 36,
backgroundColor: "var(--mantine-color-edr-green-0)",
color: "var(--mantine-color-edr-green-7)",
}}
>
{icon}
</Box>
<Text fw={700} size="sm" c="#10202F">
{title}
</Text>
</Group>
<Button
type="button"
variant="subtle"
color="edr-green"
size="compact-xs"
leftSection={<Pencil size={13} />}
onClick={onEdit}
>
Edit
</Button>
</Group>
{children}
</Paper>
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<Group justify="space-between" align="flex-start" wrap="nowrap" py={4}>
<Text
size="xs"
c="dimmed"
fw={600}
tt="uppercase"
className="tracking-wide"
<Group gap={12} align="flex-start" wrap="nowrap">
<Box
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--mantine-color-edr-green-0)",
color: "var(--mantine-color-edr-green-7)",
}}
>
{label}
</Text>
<Text size="sm" fw={500} ta="right" maw="60%">
{value || "—"}
</Text>
{icon}
</Box>
<Box style={{ minWidth: 0 }}>
<Text
fz={11}
c="dimmed"
fw={700}
tt="uppercase"
style={{ letterSpacing: "0.05em" }}
>
{label}
</Text>
<Text fz={14} fw={600} c="#10202F" mt={2} style={{ lineHeight: 1.4 }}>
{value || "—"}
</Text>
</Box>
</Group>
);
}
@@ -206,7 +180,6 @@ function UnitRatePanel({
export function Step8Review({
form,
setStep,
direction,
referenceData,
onboardingDocs = [],
@@ -217,7 +190,8 @@ export function Step8Review({
submitPending = false,
}: {
form: ContractForm;
setStep: (step: number) => void;
/** Retained for caller compatibility; the review page is read-only. */
setStep?: (step: number) => void;
direction: Freight.ScheduleTradeDirection;
referenceData?: Freight.BookingReferenceData;
onboardingDocs?: Array<{ name: string; size?: number }>;
@@ -243,7 +217,6 @@ export function Step8Review({
name: Array.isArray(v) ? (v[0]?.name ?? key) : ((v as File).name ?? key),
}));
const onboardingDocsCount = attachedDocs.length || onboardingDocs.length;
const docsToShow = attachedDocs.length > 0 ? attachedDocs : onboardingDocs;
const cargoValue = (() => {
if (values.cargoType === "container") {
@@ -286,41 +259,167 @@ export function Step8Review({
/>
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
{/* Left — contract summary */}
{/* Left — one scannable summary card */}
<Stack gap="md" className="min-w-0 flex-1">
<Paper
radius={20}
p="lg"
className="border border-emerald-100 bg-gradient-to-br from-white to-emerald-50/40"
radius={18}
withBorder
style={{
borderColor: "var(--mantine-color-edr-border-0)",
overflow: "hidden",
}}
>
{/* Header strip */}
<Group
justify="space-between"
align="flex-start"
wrap="wrap"
gap="md"
align="center"
wrap="nowrap"
px="lg"
py="md"
style={{
background: "linear-gradient(135deg, #F4FBF7 0%, #FFFFFF 70%)",
borderBottom: "1px solid var(--mantine-color-edr-border-0)",
}}
>
<Stack gap={4}>
<Text
size="xs"
fw={700}
tt="uppercase"
c="edr-green"
className="tracking-wider"
>
Contract overview
</Text>
<Text fw={800} size="xl" c="#10202F">
<Box style={{ minWidth: 0 }}>
<Text fw={800} fz={16} c="#10202F" truncate>
{isGeneralContract ? "General Contract" : "One-Time Contract"}
</Text>
<Text size="sm" c="dimmed">
<Text fz={12.5} c="dimmed" truncate>
{serviceType?.serviceName ?? "—"} · {originYardName} {" "}
{destinationYardName}
</Text>
</Stack>
<Badge size="lg" variant="light" color="edr-green" radius="md">
</Box>
<Badge
size="md"
variant="light"
color="edr-green"
radius="md"
style={{ flexShrink: 0 }}
>
{directionLabel}
</Badge>
</Group>
{/* Detail grid — read-only, every field shown */}
<Box p="lg" className="grid gap-x-6 gap-y-5 sm:grid-cols-2">
<SummaryItem
icon={<Package size={18} />}
label="Contract kind"
value={isGeneralContract ? "General Contract" : "One-Time"}
/>
<SummaryItem
icon={<FileText size={18} />}
label="Contract type"
value={
values.contractType === "new"
? "New contract"
: values.previousContractRef
? `Renewal · ${values.previousContractRef}`
: "Renewal"
}
/>
<SummaryItem
icon={<Truck size={18} />}
label="Service"
value={serviceType?.serviceName ?? "—"}
/>
<SummaryItem
icon={<Coins size={18} />}
label="Payment currency"
value={values.paymentCurrency ?? "USD"}
/>
<SummaryItem
icon={<Route size={18} />}
label="Primary route"
value={`${originYardName}${destinationYardName}`}
/>
<SummaryItem
icon={<MapPin size={18} />}
label="Trade direction"
value={
isGeneralContract
? `${directionLabel} · ${routesCount} routes`
: directionLabel
}
/>
<SummaryItem
icon={<Calendar size={18} />}
label="Estimated shipment"
value={scheduleLabel}
/>
<SummaryItem
icon={<Package size={18} />}
label="Cargo scope"
value={
<>
{cargoValue || "—"}
{values.cargoType === "container" &&
(values.enabledContainerSizes ?? []).length > 0 && (
<Group gap={6} mt={6}>
{(values.enabledContainerSizes ?? []).map((s) => (
<Badge
key={s}
size="sm"
variant="light"
color="edr-green"
radius="sm"
>
{s}
</Badge>
))}
</Group>
)}
</>
}
/>
<SummaryItem
icon={<Truck size={18} />}
label="First mile — pick-up"
value={
values.firstMile.enabled
? `${values.firstMile.pickUpAddress || "Pinned"}${
values.firstMile.exactLocation
? ` · ${values.firstMile.exactLocation}`
: ""
}`
: "Not requested"
}
/>
<SummaryItem
icon={<Truck size={18} />}
label="Last mile — delivery"
value={
values.lastMile.enabled
? `${values.lastMile.deliveryAddress || "Pinned"}${
values.lastMile.exactLocation
? ` · ${values.lastMile.exactLocation}`
: ""
}`
: "Not requested"
}
/>
<SummaryItem
icon={<FileText size={18} />}
label="Customs clearing"
value={
values.customsClearingEnabled
? values.customsClearingAgent
? `Agent: ${values.customsClearingAgent}`
: "Global Logistics"
: "Not requested"
}
/>
<SummaryItem
icon={<FileText size={18} />}
label="Documents"
value={
onboardingDocsCount > 0
? `${onboardingDocsCount} attached`
: "None attached"
}
/>
</Box>
</Paper>
{pricing && (
@@ -330,173 +429,6 @@ export function Step8Review({
/>
)}
<OverviewSection
icon={<Package size={18} />}
title="Contract & Service"
onEdit={() => setStep(REVIEW_STEP_TARGETS.contract)}
>
<DetailRow
label="Kind"
value={isGeneralContract ? "General Contract" : "One-Time"}
/>
<DetailRow
label="Contract"
value={values.contractType === "new" ? "New" : "Renewal"}
/>
{values.contractType === "renewal" && values.previousContractRef && (
<DetailRow
label="Previous ref"
value={values.previousContractRef}
/>
)}
<DetailRow label="Service" value={serviceType?.serviceName ?? ""} />
<DetailRow
label="Payment currency"
value={values.paymentCurrency ?? "USD"}
/>
</OverviewSection>
<OverviewSection
icon={<Route size={18} />}
title="Route"
onEdit={() => setStep(REVIEW_STEP_TARGETS.route)}
>
<DetailRow
label="Primary corridor"
value={`${originYardName}${destinationYardName}`}
/>
{isGeneralContract && (
<DetailRow label="Routes covered" value={String(routesCount)} />
)}
<DetailRow label="Trade direction" value={directionLabel} />
<DetailRow
label="Modifiers"
value={
[
values.isHazardous && "Hazardous",
values.isRefrigerated && "Refrigerated",
]
.filter(Boolean)
.join(", ") || "None"
}
/>
</OverviewSection>
<OverviewSection
icon={<Truck size={18} />}
title="Logistics"
onEdit={() => setStep(REVIEW_STEP_TARGETS.service)}
>
<DetailRow
label="First mile"
value={
values.firstMile.enabled
? values.firstMile.pickUpAddress
: "Not requested"
}
/>
<DetailRow
label="Last mile"
value={
values.lastMile.enabled
? values.lastMile.deliveryAddress
: "Not requested"
}
/>
<DetailRow
label="Equipment return"
value={
values.equipmentReturn === "with_return"
? "With return"
: "Without return"
}
/>
<DetailRow
label="Customs clearing"
value={
values.customsClearingEnabled
? values.customsClearingAgent
? `Enabled — agent: ${values.customsClearingAgent}`
: "Enabled (Global Logistics)"
: "Not requested"
}
/>
</OverviewSection>
<OverviewSection
icon={<Calendar size={18} />}
title="Schedule"
onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)}
>
<DetailRow label="Estimated shipment date" value={scheduleLabel} />
</OverviewSection>
<OverviewSection
icon={<Package size={18} />}
title="Cargo scope"
onEdit={() => setStep(REVIEW_STEP_TARGETS.cargo)}
>
<DetailRow label="Cargo" value={cargoValue} />
{values.cargoType === "container" &&
(values.enabledContainerSizes ?? []).length > 0 && (
<Table mt="sm" withTableBorder fz="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Container size in scope</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(values.enabledContainerSizes ?? []).map((s) => (
<Table.Tr key={s}>
<Table.Td>{s}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</OverviewSection>
<OverviewSection
icon={<FileText size={18} />}
title="Documents"
onEdit={() => setStep(REVIEW_STEP_TARGETS.documents)}
>
<Stack gap="xs">
{onboardingDocsCount > 0 ? (
docsToShow.map((doc, i) => (
<Group
key={`${doc.name}-${i}`}
justify="space-between"
wrap="nowrap"
>
<Group gap="xs" wrap="nowrap">
<CheckCircle2
size={16}
className="text-emerald-600 shrink-0"
/>
<Text size="sm" className="truncate max-w-[60%]">
{doc.name}
</Text>
</Group>
<Text size="xs" c="dimmed">
Attached
</Text>
</Group>
))
) : (
<Group gap="xs" wrap="nowrap">
<Circle size={16} className="text-gray-300 shrink-0" />
<Text size="sm" c="dimmed">
No documents attached yet.
</Text>
</Group>
)}
</Stack>
<Text size="xs" c="dimmed" mt="sm">
These documents will be attached to this contract.
</Text>
</OverviewSection>
<Controller
name="notes"
control={form.control}

View File

@@ -81,7 +81,11 @@ export function useContractDraft({
{ ...form.getValues(), ...draft.values },
{ keepDefaultValues: true },
);
if (typeof draft.step === "number") setStep(draft.step);
// Clamp to the current step range — older drafts may carry a step id that
// no longer exists after the wizard was condensed to 4 steps.
if (typeof draft.step === "number") {
setStep(Math.min(Math.max(draft.step, 0), 3));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

View File

@@ -99,10 +99,10 @@ export default function CheckPaymentPage() {
size="md"
radius={12}
color="orange"
onClick={() => navigate("/bookings")}
onClick={() => navigate("/contracts")}
styles={{ root: { height: 48, fontWeight: 700 } }}
>
Back to my bookings
Back to my contracts
</Button>
</Stack>
</PaymentCard>
@@ -176,10 +176,10 @@ export default function CheckPaymentPage() {
radius={12}
color="edr-green"
leftSection={<FileText size={17} />}
onClick={() => navigate("/bookings")}
onClick={() => navigate("/contracts")}
styles={{ root: { height: 48, fontWeight: 700, fontSize: 15 } }}
>
Go to my bookings
Go to my contracts
</Button>
<Button
fullWidth
@@ -258,10 +258,10 @@ export default function CheckPaymentPage() {
radius={12}
color="red"
leftSection={<RotateCcw size={17} />}
onClick={() => navigate("/bookings")}
onClick={() => navigate("/contracts")}
styles={{ root: { height: 48, fontWeight: 700, fontSize: 15 } }}
>
Back to my bookings
Back to my contracts
</Button>
<Button
fullWidth
@@ -342,10 +342,10 @@ export default function CheckPaymentPage() {
radius={12}
color="orange"
leftSection={<RotateCcw size={17} />}
onClick={() => navigate("/bookings")}
onClick={() => navigate("/contracts")}
styles={{ root: { height: 48, fontWeight: 700, fontSize: 15 } }}
>
Back to my bookings retry payment
Back to my contracts retry payment
</Button>
<Button
fullWidth

View File

@@ -135,12 +135,12 @@ export default function PaymentFailurePage() {
radius={12}
color="red"
leftSection={<RotateCcw size={17} />}
onClick={() => navigate("/bookings")}
onClick={() => navigate("/contracts")}
styles={{
root: { height: 48, fontWeight: 700, fontSize: 15 },
}}
>
Back to my bookings retry payment
Back to my contracts retry payment
</Button>
<Button
fullWidth

View File

@@ -134,12 +134,12 @@ export default function PaymentSuccessPage() {
radius={12}
color="edr-green"
leftSection={<FileText size={17} />}
onClick={() => navigate("/bookings")}
onClick={() => navigate("/contracts")}
styles={{
root: { height: 48, fontWeight: 700, fontSize: 15 },
}}
>
Go to my bookings
Go to my contracts
</Button>
<Button
fullWidth