mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 09:42:53 +00:00
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:
@@ -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');
|
||||
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user