Merge pull request #194 from Tria-plc/freight/develop

Freight/develop
This commit is contained in:
Nathnael Wondisha
2026-06-17 10:19:59 +03:00
committed by GitHub
27 changed files with 1880 additions and 1306 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,104 @@
import { Grid, Stack } from "@mantine/core";
import { useNavigate } from "react-router-dom";
import type { Currency } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import {
FreightVolumeSection,
HelloSection,
InvoicesSection,
RecentActivitySection,
SetupPrompt,
ShipmentsSection,
StatsSection,
} from "./components";
import { useMyPortalData } from "./hooks";
export default function MyPortalPage() {
const navigate = useNavigate();
const {
customer,
bookingsQuery,
dashboardQuery,
allBookings,
activeBookings,
newActiveThisWeek,
outstandingInvoices,
totalOutstanding,
companyName,
greeting,
recentInvoices,
dashboard,
volumePoints,
maxVolume,
} = useMyPortalData();
const handleBookingClick = (id: string) => {
navigate(`/bookings/${id}`);
};
return (
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
<HelloSection greeting={greeting} companyName={companyName} />
<SetupPrompt show={!customer} />
<StatsSection
activeBookingsLength={activeBookings.length}
newActiveThisWeek={newActiveThisWeek}
bookingsLoading={bookingsQuery.isPending}
outstandingInvoicesLength={outstandingInvoices.length}
totalOutstanding={totalOutstanding}
deliveredCount={dashboard?.deliveredCount.toString()}
completionRate={dashboard?.completionRate}
spendYtd={
dashboard
? formatCurrency(
dashboard.spendYtd,
dashboard.spendCurrency as Currency,
)
: undefined
}
spendYtdChangePct={dashboard?.spendYtdChangePct}
dashboardLoading={dashboardQuery.isPending}
/>
<Grid align="stretch">
<Grid.Col span={{ base: 12, lg: 8 }}>
<ShipmentsSection
bookings={allBookings}
isLoading={bookingsQuery.isPending}
onBookingClick={handleBookingClick}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<InvoicesSection invoices={recentInvoices} />
</Grid.Col>
</Grid>
<Grid align="stretch">
<Grid.Col span={{ base: 12, md: 5 }}>
<FreightVolumeSection
totalTonnes={dashboard?.freightVolume.totalTonnes ?? 0}
totalValue={dashboard?.freightVolume.totalValue ?? 0}
currency={
(dashboard?.freightVolume.currency ?? "ETB") as Currency
}
ytdChangePct={dashboard?.freightVolume.ytdChangePct ?? 0}
volumePoints={volumePoints}
maxVolume={maxVolume}
isLoading={dashboardQuery.isPending}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 7 }}>
<RecentActivitySection
bookings={allBookings}
isLoading={bookingsQuery.isPending}
onBookingClick={handleBookingClick}
/>
</Grid.Col>
</Grid>
</Stack>
);
}

View File

@@ -0,0 +1,61 @@
import { Box, Group, Text } from "@mantine/core";
import { format } from "date-fns";
import { memo } from "react";
import { STATUS_CONFIG, cv } from "../constants";
interface ActivityRowProps {
booking: any;
onClick: () => void;
}
export const ActivityRow = memo(function ActivityRow({
booking,
onClick,
}: ActivityRowProps) {
const cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT;
const Icon = cfg.icon;
const verb =
booking.status === "IN_TRANSIT"
? "departed"
: booking.status === "COMPLETED"
? "delivered"
: booking.status === "PENDING_APPROVAL"
? "quote ready"
: booking.status === "SUBMITTED"
? "submitted for review"
: "created";
return (
<Group
gap={12}
align="center"
wrap="nowrap"
py={9}
className="cursor-pointer"
onClick={onClick}
>
<Box
w={36}
h={36}
bg={cfg.tile}
className="flex shrink-0 items-center justify-center rounded-[10px]"
>
<Icon size={17} color={cv(cfg.iconColor)} />
</Box>
<Box className="min-w-0 flex-1">
<Text fz={13} fw={600} c="edr-text" truncate>
Booking {booking.reference} {verb}
</Text>
<Text fz={11} c="edr-muted" truncate>
{booking.originYard?.label ?? booking.originYard?.code ?? "—"} {" "}
{booking.destinationYard?.label ??
booking.destinationYard?.code ??
"—"}
</Text>
</Box>
<Text fz={11} c="edr-muted" className="shrink-0">
{format(new Date(booking.createdAt), "MMM d")}
</Text>
</Group>
);
});

View File

@@ -0,0 +1,104 @@
import { Box, Group, Stack, Text } from "@mantine/core";
import { memo } from "react";
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
import { Stepper } from "./Stepper";
interface BookingRowProps {
booking: any;
last: boolean;
onClick: () => void;
}
export const BookingRow = memo(function BookingRow({
booking,
last,
onClick,
}: BookingRowProps) {
const cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT;
const Icon = cfg.icon;
const AIcon = cfg.action.icon;
const ap = ACTION_PROPS[cfg.action.kind];
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
const dest =
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
const commodity =
(typeof booking.cargoType === "string"
? booking.cargoType
: booking.cargoType?.name) ??
booking.commodity ??
"Freight";
return (
<Box className={last ? undefined : "border-b border-edr-divider"}>
<Group
gap={16}
align="center"
wrap="nowrap"
py={14}
px={4}
className="cursor-pointer"
onClick={onClick}
>
<Box
w={46}
h={46}
bg={cfg.tile}
className="flex shrink-0 items-center justify-center rounded-xl"
>
<Icon size={22} color={cv(cfg.iconColor)} />
</Box>
<Box className="min-w-0 flex-1 lg:!flex-none lg:!w-[188px]">
<Text fz={15} fw={700} c="edr-text" truncate>
{booking.reference}
</Text>
<Text fz={12} c="edr-muted" truncate>
{commodity} · {origin} {dest}
</Text>
</Box>
<Box className="hidden min-w-0 flex-1 pr-2 lg:block">
<Text fz={12} fw={500} mb={8} c={cfg.iconColor} truncate>
{cfg.hint}
</Text>
<Stepper stage={cfg.stage} color={cfg.step} />
</Box>
<Stack gap={9} align="flex-end" className="shrink-0">
<Group
gap={6}
align="center"
px={11}
py={5}
bg={cfg.badgeBg}
className="rounded-full"
>
<Box w={6} h={6} bg={cfg.badgeDot} className="rounded-full" />
<Text fz={11} fw={700} c={cfg.badgeText}>
{cfg.badgeLabel}
</Text>
</Group>
<Group
gap={5}
align="center"
px={15}
py={8}
bg={ap.bg}
bd={ap.bd}
className="cursor-pointer rounded-[9px]"
>
<Text fz={13} fw={700} c={ap.c}>
{cfg.action.label}
</Text>
{AIcon && (
<AIcon
size={15}
color={ap.c === "white" ? "#fff" : cv("edr-text")}
/>
)}
</Group>
</Stack>
</Group>
</Box>
);
});

View File

@@ -0,0 +1,18 @@
import { Box } from "@mantine/core";
interface CardProps {
children: React.ReactNode;
className?: string;
padding?: number;
}
export function Card({ children, className = "", padding = 24 }: CardProps) {
return (
<Box
p={padding}
className={`rounded-[20px] border border-edr-border bg-edr-card ${className}`}
>
{children}
</Box>
);
}

View File

@@ -0,0 +1,18 @@
import { Box, Text } from "@mantine/core";
interface EmptyStateProps {
message: string;
}
export function EmptyState({ message }: EmptyStateProps) {
return (
<Box
py="xl"
className="rounded-xl border border-dashed border-edr-border text-center"
>
<Text size="sm" c="edr-muted">
{message}
</Text>
</Box>
);
}

View File

@@ -0,0 +1,82 @@
import { Box, Group, Skeleton, Text } from "@mantine/core";
import { memo } from "react";
import type { Currency } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import { formatPct } from "../constants";
import { Card } from "./Card";
interface FreightVolumeSectionProps {
totalTonnes: number;
totalValue: number;
currency: Currency;
ytdChangePct: number;
volumePoints: Array<{ month: string; tonnes: number }>;
maxVolume: number;
isLoading: boolean;
}
export const FreightVolumeSection = memo(function FreightVolumeSection({
totalTonnes,
totalValue,
currency,
ytdChangePct,
volumePoints,
maxVolume,
isLoading,
}: FreightVolumeSectionProps) {
return (
<Card className="h-full" padding={24}>
<Text fz={17} fw={700} c="edr-text">
Freight Volume
</Text>
<Group gap={10} align="baseline" mt={4} mb={22}>
{isLoading ? (
<Skeleton height={32} width={180} radius="sm" />
) : (
<>
<Text fz={26} fw={800} c="edr-text">
{totalTonnes.toLocaleString()} t
</Text>
<Text fz={13} c="edr-muted">
{formatCurrency(totalValue, currency)}
</Text>
<Text fz={12} fw={700} c="edr-green.7">
{formatPct(ytdChangePct)} YTD
</Text>
</>
)}
</Group>
{isLoading ? (
<Skeleton height={110} radius="md" />
) : volumePoints.length === 0 ? (
<Box className="flex h-[110px] items-center">
<Text fz={13} c="edr-muted">
No freight volume yet.
</Text>
</Box>
) : (
<Group align="flex-end" gap={10} className="h-[110px]">
{volumePoints.map((point, i) => {
const isLast = i === volumePoints.length - 1;
return (
<Box
key={point.month}
className="flex flex-1 flex-col items-center gap-2"
>
<Box
bg={isLast ? "edr-green" : "edr-soft"}
bd={isLast ? undefined : "1px solid edr-border"}
h={Math.round((point.tonnes / maxVolume) * 86)}
className="w-full rounded-t-md"
/>
<Text fz={11} c="edr-muted">
{point.month}
</Text>
</Box>
);
})}
</Group>
)}
</Card>
);
});

View File

@@ -0,0 +1,51 @@
import { Box, Group, Text } from "@mantine/core";
import { ArrowRight, Truck } from "lucide-react";
import { memo } from "react";
import { Link } from "react-router-dom";
import { cv } from "../constants";
interface HelloSectionProps {
greeting: string;
companyName: string;
}
export const HelloSection = memo(function HelloSection({
greeting,
companyName,
}: HelloSectionProps) {
return (
<Group justify="space-between" align="center" gap="md">
<Box>
<Text size="sm" c="edr-muted">
{greeting}
</Text>
<Text fz={26} fw={800} mt={2} c="edr-text" className="tracking-tight">
{companyName} 👋
</Text>
</Box>
<Link to="/bookings/new">
<Group
gap={14}
align="center"
wrap="nowrap"
bg="edr-green"
px={18}
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" />
<Box className="min-w-0 flex-1">
<Text fz={14} fw={700} c="white" lh={1.3}>
Book a shipment
</Text>
</Box>
<Box className="flex h-[32px] w-[32px] shrink-0 items-center justify-center rounded-full bg-white">
<ArrowRight size={18} color={cv("edr-green.7")} />
</Box>
</Group>
</Link>
</Group>
);
});

View File

@@ -0,0 +1,154 @@
import { Box, Group, Stack, Text } from "@mantine/core";
import { CheckCircle2, ChevronRight, Clock3, Zap } from "lucide-react";
import { format } from "date-fns";
import { memo } from "react";
import { Link } from "react-router-dom";
import type { Currency, InvoiceStatus } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import { cv, INVOICE_BADGE } from "../constants";
import { Card } from "./Card";
import { EmptyState } from "./EmptyState";
interface InvoicesSectionProps {
invoices: Array<{
id: string;
number: string;
bookingReference: string;
amount: number;
currency: Currency;
status: InvoiceStatus;
dueDate: string;
paidDate?: string;
}>;
}
export const InvoicesSection = memo(function InvoicesSection({
invoices,
}: InvoicesSectionProps) {
const outstandingInvoices = invoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue",
);
const totalOutstanding = outstandingInvoices.reduce(
(sum, inv) => sum + inv.amount,
0,
);
return (
<Card className="h-full" padding={24}>
<Group justify="space-between" align="center" mb={16}>
<Text fz={17} fw={700} c="edr-text">
Invoices
</Text>
<Link to="/billing">
<Group gap={3} align="center" className="no-underline">
<Text fz={13} fw={600} c="edr-green.7">
View all
</Text>
<ChevronRight size={15} color={cv("edr-green.7")} />
</Group>
</Link>
</Group>
<Box mb={16} p={16} bg="edr-amber-soft" className="rounded-[14px]">
<Text fz={12} fw={600} c="edr-amber-text">
Outstanding balance
</Text>
<Text fz={24} fw={800} mt={4} c="edr-text">
{formatCurrency(totalOutstanding || 377500, "ETB")}
</Text>
<Group
justify="space-between"
align="center"
mt={8}
wrap="nowrap"
>
<Text fz={12} c="edr-amber-text">
{outstandingInvoices.length || 2} invoices unpaid
</Text>
<Group
gap={5}
align="center"
px={14}
py={8}
bg="edr-accent"
className="cursor-pointer rounded-[9px]"
>
<Zap size={15} color="#fff" />
<Text fz={13} fw={700} c="white">
Pay all
</Text>
</Group>
</Group>
</Box>
{invoices.length === 0 ? (
<EmptyState message="No invoices yet." />
) : (
<Stack gap={0}>
{invoices.map((invoice, i) => {
const badge = INVOICE_BADGE[invoice.status];
const dueText =
invoice.status === "Paid"
? `Paid ${format(new Date(invoice.paidDate ?? invoice.dueDate), "MMM d")}`
: invoice.status === "Overdue"
? "Overdue 3 days"
: `Due ${invoice.dueDate}`;
const DueIcon =
invoice.status === "Paid" ? CheckCircle2 : Clock3;
const dueIconColor =
invoice.status === "Paid"
? cv("edr-green.5")
: cv("edr-muted");
return (
<Box key={invoice.id}>
{i > 0 && <Box h={1} bg="edr-divider" />}
<Stack gap={8} py={10}>
<Group
justify="space-between"
align="flex-start"
wrap="nowrap"
>
<Box>
<Text fz={13} fw={700} c="edr-text">
{invoice.number}
</Text>
<Text fz={11} c="edr-muted">
{invoice.bookingReference}
</Text>
</Box>
<Text fz={14} fw={700} c="edr-text">
{formatCurrency(invoice.amount, invoice.currency)}
</Text>
</Group>
<Group
justify="space-between"
align="center"
wrap="nowrap"
>
<Group gap={5} align="center">
<DueIcon size={13} color={dueIconColor} />
<Text fz={12} c="edr-muted">
{dueText}
</Text>
</Group>
<Box
bg={badge.bg}
px={10}
py={4}
className="rounded-full"
>
<Text fz={11} fw={700} c={badge.text}>
{badge.label}
</Text>
</Box>
</Group>
</Stack>
</Box>
);
})}
</Stack>
)}
</Card>
);
});

View File

@@ -0,0 +1,58 @@
import { ChevronRight } from "lucide-react";
import { Group, Skeleton, Stack, Text } from "@mantine/core";
import { memo } from "react";
import { Link } from "react-router-dom";
import { cv } from "../constants";
import { ActivityRow } from "./ActivityRow";
import { Card } from "./Card";
import { EmptyState } from "./EmptyState";
interface RecentActivitySectionProps {
bookings: any[];
isLoading: boolean;
onBookingClick: (id: string) => void;
}
export const RecentActivitySection = memo(function RecentActivitySection({
bookings,
isLoading,
onBookingClick,
}: RecentActivitySectionProps) {
return (
<Card className="h-full" padding={24}>
<Group justify="space-between" align="center" mb={16}>
<Text fz={17} fw={700} c="edr-text">
Recent Activity
</Text>
<Link to="/bookings">
<Group gap={3} align="center" className="no-underline">
<Text fz={13} fw={600} c="edr-green.7">
View all
</Text>
<ChevronRight size={15} color={cv("edr-green.7")} />
</Group>
</Link>
</Group>
{isLoading ? (
<Stack gap={10}>
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} height={44} radius="md" />
))}
</Stack>
) : bookings.length === 0 ? (
<EmptyState message="No recent activity." />
) : (
<Stack gap={2}>
{bookings.slice(0, 6).map((booking) => (
<ActivityRow
key={booking.id}
booking={booking}
onClick={() => onBookingClick(booking.id)}
/>
))}
</Stack>
)}
</Card>
);
});

View File

@@ -0,0 +1,40 @@
import { Box, Group, Text } from "@mantine/core";
import { ArrowRight, Truck } from "lucide-react";
import { memo } from "react";
import { Link } from "react-router-dom";
import { cv } from "../constants";
interface SetupPromptProps {
show: boolean;
}
export const SetupPrompt = memo(function SetupPrompt({ show }: SetupPromptProps) {
if (!show) return null;
return (
<Box className="rounded-2xl border border-edr-border bg-gradient-to-r from-edr-blue/5 to-edr-green/5 px-7 py-6">
<Group justify="space-between" align="center" wrap="nowrap">
<Box className="flex-1">
<Text fz={15} fw={700} c="edr-text" mb={6}>
Setup your Company Profile
</Text>
<Text fz={13} c="edr-muted" mb={12}>
Complete your company information to unlock all features and start
booking shipments.
</Text>
<Link to="/settings" className="no-underline">
<Group gap={8} align="center" className="w-fit">
<Text fz={13} fw={600} c="edr-green.7">
Complete Setup
</Text>
<ArrowRight size={16} color={cv("edr-green.7")} />
</Group>
</Link>
</Box>
<Box className="hidden shrink-0 sm:block">
<Truck size={48} color={cv("edr-blue")} opacity={0.3} />
</Box>
</Group>
</Box>
);
});

View File

@@ -0,0 +1,53 @@
import { Box, Group, Skeleton, Stack, Text } from "@mantine/core";
import { memo } from "react";
import { BookingRow } from "./BookingRow";
import { Card } from "./Card";
import { EmptyState } from "./EmptyState";
interface ShipmentsSectionProps {
bookings: any[];
isLoading: boolean;
onBookingClick: (id: string) => void;
}
export const ShipmentsSection = memo(function ShipmentsSection({
bookings,
isLoading,
onBookingClick,
}: ShipmentsSectionProps) {
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">
My Shipments
</Text>
<Text fz={13} c="edr-muted">
From draft to delivery every booking in one place
</Text>
</Box>
</Group>
{isLoading ? (
<Stack gap={6}>
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} height={64} radius="md" />
))}
</Stack>
) : bookings.length === 0 ? (
<EmptyState message="No bookings in this view." />
) : (
<Stack gap={0}>
{bookings.map((booking, i) => (
<BookingRow
key={booking.id}
booking={booking}
last={i === bookings.length - 1}
onClick={() => onBookingClick(booking.id)}
/>
))}
</Stack>
)}
</Card>
);
});

View File

@@ -0,0 +1,46 @@
import { Box, Group, Text } from "@mantine/core";
import { memo } from "react";
import type { LucideIcon } from "lucide-react";
import { cv } from "../constants";
interface StatKpiProps {
icon: LucideIcon;
label: string;
value: string;
delta: string;
deltaColor: string;
divider?: boolean;
}
export const StatKpi = memo(function StatKpi({
icon: Icon,
label,
value,
delta,
deltaColor,
divider,
}: StatKpiProps) {
return (
<Box
px={4}
className={
divider ? "lg:border-l lg:border-edr-border lg:pl-7" : undefined
}
>
<Group gap={6} align="center" mb={7} wrap="nowrap">
<Icon size={15} color={cv("edr-muted")} className="shrink-0" />
<Text fz={12} fw={600} c="edr-muted" truncate>
{label}
</Text>
</Group>
<Group gap={8} align="flex-end" wrap="nowrap">
<Text fz={22} fw={800} lh={1} c="edr-text" truncate>
{value}
</Text>
<Text fz={12} fw={600} lh={1.3} c={deltaColor} truncate>
{delta}
</Text>
</Group>
</Box>
);
});

View File

@@ -0,0 +1,76 @@
import { Box, SimpleGrid } from "@mantine/core";
import {
CheckCircle2,
Clock3,
Truck,
Wallet,
} from "lucide-react";
import { memo } from "react";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import { formatPct } from "../constants";
import { Card } from "./Card";
import { StatKpi } from "./StatKpi";
interface StatsSectionProps {
activeBookingsLength: number;
newActiveThisWeek: number;
bookingsLoading: boolean;
outstandingInvoicesLength: number;
totalOutstanding: number;
deliveredCount: string | undefined;
completionRate: string | undefined;
spendYtd: string | undefined;
spendYtdChangePct: number | undefined;
dashboardLoading: boolean;
}
export const StatsSection = memo(function StatsSection({
activeBookingsLength,
newActiveThisWeek,
bookingsLoading,
outstandingInvoicesLength,
totalOutstanding,
deliveredCount,
completionRate,
spendYtd,
spendYtdChangePct,
dashboardLoading,
}: StatsSectionProps) {
return (
<Card className="rounded-2xl border border-edr-border bg-gradient-to-br from-white to-edr-soft px-7 py-5">
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing={0} verticalSpacing={20}>
<StatKpi
icon={Truck}
label="Active Shipments"
value={bookingsLoading ? "—" : activeBookingsLength.toString()}
delta={bookingsLoading ? "" : `+${newActiveThisWeek} this week`}
deltaColor="edr-green.7"
/>
<StatKpi
icon={Clock3}
label="Awaiting Payment"
value={outstandingInvoicesLength.toString()}
delta={`${formatCurrency(totalOutstanding || 0, "ETB")} due`}
deltaColor="edr-amber-text"
divider
/>
<StatKpi
icon={CheckCircle2}
label="Delivered (YTD)"
value={deliveredCount ?? "—"}
delta={completionRate ? `${completionRate}% completed` : ""}
deltaColor="edr-muted"
divider
/>
<StatKpi
icon={Wallet}
label="Spend YTD"
value={spendYtd ?? "—"}
delta={spendYtdChangePct ? `${formatPct(spendYtdChangePct)} YoY` : ""}
deltaColor="edr-green.7"
divider
/>
</SimpleGrid>
</Card>
);
});

View File

@@ -0,0 +1,42 @@
import { Box, Group } from "@mantine/core";
import { memo } from "react";
interface StepperProps {
stage: number;
color: string;
}
export const Stepper = memo(function Stepper({ stage, color }: StepperProps) {
return (
<Group gap={0} align="center" wrap="nowrap" className="h-3.5 w-full">
{[0, 1, 2, 3, 4].map((i) => {
const done = i < stage;
const active = i === stage;
const size = active ? 12 : done ? 9 : 8;
return (
<Group
key={i}
gap={0}
align="center"
wrap="nowrap"
className={i < 4 ? "flex-1" : undefined}
>
<Box
w={size}
h={size}
bg={done || active ? color : "edr-step-idle"}
className="shrink-0 rounded-full"
/>
{i < 4 && (
<Box
h={3}
bg={i < stage ? color : "edr-conn-idle"}
className="flex-1 rounded-full"
/>
)}
</Group>
);
})}
</Group>
);
});

View File

@@ -0,0 +1,13 @@
export { ActivityRow } from "./ActivityRow";
export { BookingRow } from "./BookingRow";
export { Card } from "./Card";
export { EmptyState } from "./EmptyState";
export { FreightVolumeSection } from "./FreightVolumeSection";
export { HelloSection } from "./HelloSection";
export { InvoicesSection } from "./InvoicesSection";
export { RecentActivitySection } from "./RecentActivitySection";
export { SetupPrompt } from "./SetupPrompt";
export { ShipmentsSection } from "./ShipmentsSection";
export { StatKpi } from "./StatKpi";
export { StatsSection } from "./StatsSection";
export { Stepper } from "./Stepper";

View File

@@ -0,0 +1,338 @@
import {
ArrowRight,
CheckCircle2,
Clock3,
FileCheck2,
FilePen,
MapPin,
Truck,
Wallet,
type LucideIcon,
} from "lucide-react";
import type { Currency, InvoiceStatus } from "@/pages/billing/invoices.mock";
export const cv = (token: string) => {
const [name, shade] = token.split(".");
return `var(--mantine-color-${name}-${shade ?? "6"})`;
};
export const formatPct = (n: number) => `${n >= 0 ? "+" : ""}${n}%`;
export const ACTIVE_STATUSES = [
"DRAFT",
"SUBMITTED",
"PENDING_APPROVAL",
"IN_TRANSIT",
];
export interface StageConfig {
stage: number;
icon: LucideIcon;
iconColor: string;
tile: string;
hint: string;
step: string;
badgeLabel: string;
badgeBg: string;
badgeText: string;
badgeDot: string;
action: {
label: string;
kind: "dark" | "amber" | "outline";
icon?: LucideIcon;
};
}
export const STATUS_CONFIG: Record<string, StageConfig> = {
DRAFT: {
stage: 0,
icon: FilePen,
iconColor: "edr-slate",
tile: "edr-slate-soft",
hint: "Draft saved · not yet submitted",
step: "edr-step",
badgeLabel: "Draft",
badgeBg: "edr-slate-soft",
badgeText: "edr-slate",
badgeDot: "edr-step",
action: { label: "Continue", kind: "dark" },
},
SUBMITTED: {
stage: 1,
icon: FileCheck2,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "Quote being prepared by EDR",
step: "edr-blue-dot",
badgeLabel: "Reviewing",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "View", kind: "outline" },
},
CHANGES_REQUESTED: {
stage: 1,
icon: FilePen,
iconColor: "edr-amber-text",
tile: "edr-amber-soft",
hint: "Changes requested · please update",
step: "edr-accent",
badgeLabel: "Revise",
badgeBg: "edr-amber-soft",
badgeText: "edr-amber-text",
badgeDot: "edr-accent",
action: { label: "Update", kind: "dark" },
},
PENDING_APPROVAL: {
stage: 2,
icon: FileCheck2,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "Pending internal approval",
step: "edr-blue-dot",
badgeLabel: "Pending",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "View", kind: "outline" },
},
APPROVED_PENDING_SIGNATURE: {
stage: 2,
icon: FileCheck2,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "Approved · awaiting signature",
step: "edr-blue-dot",
badgeLabel: "For Signature",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "Review", kind: "outline" },
},
APPROVED: {
stage: 2,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Quote approved · ready to sign",
step: "edr-green.5",
badgeLabel: "Approved",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
CONTRACT_READY: {
stage: 2,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Contract ready · awaiting signature",
step: "edr-green.5",
badgeLabel: "Contract Ready",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "Review", kind: "outline" },
},
SIGNED_CUSTOMER: {
stage: 3,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Signed by customer · internal processing",
step: "edr-green.5",
badgeLabel: "Signed",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
FULLY_EXECUTED: {
stage: 3,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Fully executed · generating PNR",
step: "edr-green.5",
badgeLabel: "Executed",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
PNR_GENERATED: {
stage: 3,
icon: FileCheck2,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "PNR generated · awaiting payment verification",
step: "edr-blue-dot",
badgeLabel: "PNR Ready",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "View", kind: "outline" },
},
PAYMENT_VERIFICATION_IN_PROGRESS: {
stage: 2,
icon: Clock3,
iconColor: "edr-amber-text",
tile: "edr-amber-soft",
hint: "Verifying payment · please wait",
step: "edr-accent",
badgeLabel: "Verifying",
badgeBg: "edr-amber-soft",
badgeText: "edr-amber-text",
badgeDot: "edr-accent",
action: { label: "View", kind: "outline" },
},
SELECTED_FOR_BATCH: {
stage: 2,
icon: Wallet,
iconColor: "edr-amber-text",
tile: "edr-amber-soft",
hint: "Selected for batch · payment due within 1 hour",
step: "edr-accent",
badgeLabel: "Pay Now",
badgeBg: "edr-amber-soft",
badgeText: "edr-amber-text",
badgeDot: "edr-accent",
action: { label: "Pay now", kind: "amber", icon: ArrowRight },
},
EXPIRED: {
stage: 1,
icon: Clock3,
iconColor: "edr-red",
tile: "edr-red-soft",
hint: "Payment window expired · contact support",
step: "edr-red",
badgeLabel: "Expired",
badgeBg: "edr-red-soft",
badgeText: "edr-red",
badgeDot: "edr-red",
action: { label: "Contact", kind: "outline" },
},
PAID: {
stage: 3,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Payment received · awaiting dispatch",
step: "edr-green.5",
badgeLabel: "Paid",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
IN_TRANSIT: {
stage: 3,
icon: Truck,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "In transit · on schedule",
step: "edr-green.5",
badgeLabel: "In Transit",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "Track", kind: "outline", icon: MapPin },
},
COMPLETED: {
stage: 4,
icon: CheckCircle2,
iconColor: "edr-slate",
tile: "edr-slate-soft2",
hint: "Completed · awaiting delivery",
step: "edr-green.5",
badgeLabel: "Completed",
badgeBg: "edr-slate-soft2",
badgeText: "edr-slate",
badgeDot: "edr-step",
action: { label: "View", kind: "outline" },
},
DELIVERED: {
stage: 4,
icon: CheckCircle2,
iconColor: "edr-slate",
tile: "edr-slate-soft2",
hint: "Delivered · POD ready",
step: "edr-green.5",
badgeLabel: "Delivered",
badgeBg: "edr-slate-soft2",
badgeText: "edr-slate",
badgeDot: "edr-step",
action: { label: "View POD", kind: "outline" },
},
CANCELLED: {
stage: 0,
icon: FilePen,
iconColor: "edr-red",
tile: "edr-red-soft",
hint: "Cancelled",
step: "edr-red",
badgeLabel: "Cancelled",
badgeBg: "edr-red-soft",
badgeText: "edr-red",
badgeDot: "edr-red",
action: { label: "View", kind: "outline" },
},
REJECTED: {
stage: 0,
icon: FilePen,
iconColor: "edr-red",
tile: "edr-red-soft",
hint: "Rejected · contact support",
step: "edr-red",
badgeLabel: "Rejected",
badgeBg: "edr-red-soft",
badgeText: "edr-red",
badgeDot: "edr-red",
action: { label: "Contact", kind: "outline" },
},
PENDING_CONSOLIDATION: {
stage: 3,
icon: Clock3,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "Awaiting consolidation",
step: "edr-blue-dot",
badgeLabel: "Consolidating",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "View", kind: "outline" },
},
CONSOLIDATED: {
stage: 3,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Consolidated · ready for dispatch",
step: "edr-green.5",
badgeLabel: "Consolidated",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
};
export const ACTION_PROPS: Record<string, { bg: string; c: string; bd?: string }> =
{
dark: { bg: "edr-ink", c: "white" },
amber: { bg: "edr-accent", c: "white" },
outline: { bg: "edr-card", c: "edr-text", bd: "1px solid edr-border" },
};
export const INVOICE_BADGE: Record<
InvoiceStatus,
{ label: string; bg: string; text: string }
> = {
Draft: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" },
Sent: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" },
Paid: { label: "Paid", bg: "edr-soft", text: "edr-green.7" },
Overdue: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" },
Cancelled: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
};

View File

@@ -0,0 +1,74 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import useAuth from "@/hooks/useAuth";
import { getMyInvoices } from "@/lib/currentCustomer";
import { api } from "@/services/api";
import { ACTIVE_STATUSES } from "./constants";
export function useMyPortalData() {
const { user, customer } = useAuth();
const myInvoices = useMemo(() => getMyInvoices(), []);
const bookingsQuery = useQuery(
api.bookings.list.queryOptions({
input: { sortBy: "createdAt", sortOrder: "DESC" },
}),
);
const dashboardQuery = useQuery(api.companies.getDashboard.queryOptions());
const allBookings = bookingsQuery.data?.items ?? [];
const activeBookings = allBookings.filter((b) =>
ACTIVE_STATUSES.includes(b.status),
);
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
const newActiveThisWeek = activeBookings.filter(
(b) => new Date(b.createdAt).getTime() >= weekAgo,
).length;
const outstandingInvoices = myInvoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue",
);
const totalOutstanding = outstandingInvoices.reduce(
(sum, inv) => sum + inv.amount,
0,
);
const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—";
const companyName = (customer as any)?.companyName ?? displayName;
const hour = new Date().getHours();
const greeting =
hour < 12
? "Good morning,"
: hour < 18
? "Good afternoon,"
: "Good evening,";
const recentInvoices = myInvoices.slice(0, 3);
const dashboard = dashboardQuery.data;
const volumePoints = dashboard?.freightVolume.monthly ?? [];
const maxVolume = Math.max(1, ...volumePoints.map((p) => p.tonnes));
return {
user,
customer,
bookingsQuery,
dashboardQuery,
allBookings,
activeBookings,
newActiveThisWeek,
outstandingInvoices,
totalOutstanding,
companyName,
greeting,
recentInvoices,
dashboard,
volumePoints,
maxVolume,
myInvoices,
};
}

View File

@@ -0,0 +1 @@
export { default } from "./MyPortalPage";

View File

@@ -14,7 +14,7 @@ export function StatusHero({
booking: Freight.IBooking; booking: Freight.IBooking;
children?: React.ReactNode; children?: React.ReactNode;
}) { }) {
const status = booking.status as string; const status = booking.status;
const cfg = STATUS_MAP[status] ?? STATUS_MAP.DRAFT; const cfg = STATUS_MAP[status] ?? STATUS_MAP.DRAFT;
const negative = isNegative(status); const negative = isNegative(status);
const draft = isDraftLike(status); const draft = isDraftLike(status);
@@ -45,7 +45,12 @@ export function StatusHero({
<Group gap={16} align="center" wrap="nowrap"> <Group gap={16} align="center" wrap="nowrap">
<div <div
className="flex items-center justify-center rounded-2xl shrink-0" className="flex items-center justify-center rounded-2xl shrink-0"
style={{ width: 56, height: 56, backgroundColor: tileBg, color: tileFg }} style={{
width: 56,
height: 56,
backgroundColor: tileBg,
color: tileFg,
}}
> >
<HeroIcon size={26} /> <HeroIcon size={26} />
</div> </div>
@@ -77,7 +82,7 @@ export function StatusHero({
> >
{chipLabel} {chipLabel}
</Text> </Text>
<Text fz="13.5px" fw={700} c="#10202F"> <Text fz="sm" fw={700} c="#10202F">
{chipValue} {chipValue}
</Text> </Text>
</Box> </Box>
@@ -114,8 +119,13 @@ function ProgressTracker({
return ( return (
/* Scrollable on mobile so 5 stages never overflow */ /* Scrollable on mobile so 5 stages never overflow */
<Box <Box
className="overflow-x-auto" className="overflow-x-auto pt-2"
style={{ scrollbarWidth: "none", WebkitOverflowScrolling: "touch" } as React.CSSProperties} style={
{
scrollbarWidth: "none",
WebkitOverflowScrolling: "touch",
} as React.CSSProperties
}
> >
<div className="flex items-start" style={{ minWidth: 440 }}> <div className="flex items-start" style={{ minWidth: 440 }}>
{PROGRESS_STAGES.map((stage, idx) => { {PROGRESS_STAGES.map((stage, idx) => {
@@ -131,14 +141,15 @@ function ProgressTracker({
: state === "active" : state === "active"
? activeFill ? activeFill
: "#0EA371"; : "#0EA371";
const circleBorder = state === "idle" ? "1px solid #E1E7EE" : undefined; const circleBorder =
state === "idle" ? "1px solid #E1E7EE" : undefined;
const circleShadow = const circleShadow =
state === "active" ? `0 0 0 4px ${activeRing}` : undefined; state === "active" ? `0 0 0 4px ${activeRing}` : undefined;
return ( return (
<div <div
key={stage.label} key={stage.label}
className="flex flex-1 flex-col items-center gap-[10px]" className="flex flex-1 flex-col items-center"
> >
<div className="flex w-full items-center"> <div className="flex w-full items-center">
{/* left connector */} {/* left connector */}
@@ -147,15 +158,19 @@ function ProgressTracker({
style={{ style={{
height: 3, height: 3,
background: background:
idx === 0 ? "transparent" : reachedLeft ? "#0EA371" : "#E1E7EE", idx === 0
? "transparent"
: reachedLeft
? "#0EA371"
: "#E1E7EE",
}} }}
/> />
{/* stage circle */} {/* stage circle */}
<div <div
className="flex items-center justify-center rounded-full shrink-0" className="flex items-center justify-center mb-2 rounded-full shrink-0"
style={{ style={{
width: 40, width: 32,
height: 40, height: 32,
backgroundColor: circleBg, backgroundColor: circleBg,
border: circleBorder, border: circleBorder,
boxShadow: circleShadow, boxShadow: circleShadow,
@@ -173,32 +188,22 @@ function ProgressTracker({
style={{ style={{
height: 3, height: 3,
background: background:
idx === last ? "transparent" : reachedRight ? "#0EA371" : "#E1E7EE", idx === last
? "transparent"
: reachedRight
? "#0EA371"
: "#E1E7EE",
}} }}
/> />
</div> </div>
<Text <Text
fz="13.5px" fz="14px"
fw={state === "active" ? 800 : 700} fw={700}
ta="center" ta="center"
c={state === "idle" ? "#9AA8B5" : "#10202F"} c={state === "idle" ? "#9AA8B5" : "#10202F"}
> >
{stage.label} {stage.label}
</Text> </Text>
<Text
fz="11.5px"
fw={state === "active" ? 700 : 500}
ta="center"
c={state === "active" ? activeSub : "#9AA8B5"}
>
{state === "done"
? "Completed"
: state === "active"
? negative
? "Stopped"
: "In progress"
: "Pending"}
</Text>
</div> </div>
); );
})} })}

View File

@@ -1,5 +1,5 @@
import { Box, Button, Group, Stack, Text } from "@mantine/core"; import { Box, Group, Stack, Text } from "@mantine/core";
import { CheckCircle2, Clock, FileText } from "lucide-react"; import { CheckCircle2, Clock } from "lucide-react";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
@@ -173,16 +173,16 @@ export function PaymentCard({
</Group> </Group>
</> </>
)} )}
<Button {/* <Button */}
fullWidth {/* fullWidth */}
mt={16} {/* mt={16} */}
variant="default" {/* variant="default" */}
radius={10} {/* radius={10} */}
leftSection={<FileText size={17} color="#475569" />} {/* leftSection={<FileText size={17} color="#475569" />} */}
styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" } }} {/* styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" } }} */}
> {/* > */}
Download invoice {/* Download invoice */}
</Button> {/* </Button> */}
</SectionCard> </SectionCard>
); );
} }

View File

@@ -3,6 +3,7 @@ import {
FileText, FileText,
PackageCheck, PackageCheck,
ShieldCheck, ShieldCheck,
Ship,
Train, Train,
} from "lucide-react"; } from "lucide-react";
@@ -15,32 +16,41 @@ export const PROGRESS_STAGES = [
{ {
label: "Submitted", label: "Submitted",
icon: ClipboardCheck, icon: ClipboardCheck,
statuses: ["SUBMITTED", "PENDING_APPROVAL"], statuses: ["SUBMITTED"],
}, },
{ {
label: "Approved", label: "Approval",
icon: ShieldCheck,
statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE", "APPROVED"],
},
{
label: "Contract",
icon: ShieldCheck,
statuses: ["CONTRACT_READY", "SIGNED_CUSTOMER"],
},
{
label: "Payment",
icon: ShieldCheck, icon: ShieldCheck,
statuses: [ statuses: [
"APPROVED_PENDING_SIGNATURE",
"APPROVED",
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED", "FULLY_EXECUTED",
"SELECTED_FOR_BATCH",
"PAYMENT_VERIFICATION_IN_PROGRESS",
],
},
{
label: "Loading",
icon: Ship,
statuses: [
"PAID",
"PNR_GENERATED",
"PENDING_CONSOLIDATION",
"CONSOLIDATED",
], ],
}, },
{ {
label: "In Transit", label: "In Transit",
icon: Train, icon: Train,
statuses: [ statuses: ["EXPIRED", "IN_TRANSIT"],
"SELECTED_FOR_BATCH",
"EXPIRED",
"PNR_GENERATED",
"PAYMENT_VERIFICATION_IN_PROGRESS",
"PAID",
"IN_TRANSIT",
"PENDING_CONSOLIDATION",
"CONSOLIDATED",
],
}, },
{ {
label: "Complete", label: "Complete",
@@ -72,7 +82,7 @@ export const STATUS_MAP: Record<
PENDING_APPROVAL: { PENDING_APPROVAL: {
title: "Pending approval", title: "Pending approval",
description: "Your booking is moving through the approval process.", description: "Your booking is moving through the approval process.",
stage: 1, stage: 2,
}, },
APPROVED_PENDING_SIGNATURE: { APPROVED_PENDING_SIGNATURE: {
title: "Approved — awaiting signature", title: "Approved — awaiting signature",
@@ -88,71 +98,71 @@ export const STATUS_MAP: Record<
title: "Contract ready to sign", title: "Contract ready to sign",
description: description:
"Your contract is ready. Review and apply your signature to proceed.", "Your contract is ready. Review and apply your signature to proceed.",
stage: 2, stage: 3,
}, },
SIGNED_CUSTOMER: { SIGNED_CUSTOMER: {
title: "Signed — awaiting staff", title: "Signed — awaiting staff",
description: description:
"Your signature has been submitted. Awaiting the final staff signature.", "Your signature has been submitted. Awaiting the final staff signature.",
stage: 2, stage: 3,
}, },
FULLY_EXECUTED: { FULLY_EXECUTED: {
title: "Contract fully executed", title: "Contract fully executed",
description: "Signed by all parties. You can now proceed to payment.", description: "Signed by all parties. You can now proceed to payment.",
stage: 2, stage: 4,
}, },
SELECTED_FOR_BATCH: { SELECTED_FOR_BATCH: {
title: "Selected for a train — payment due", title: "Selected for a train — payment due",
description: description:
"Your booking was selected for a scheduled train. Complete payment within the pay window to secure your slot.", "Your booking was selected for a scheduled train. Complete payment within the pay window to secure your slot.",
stage: 3, stage: 4,
}, },
EXPIRED: { EXPIRED: {
title: "Pay window expired", title: "Pay window expired",
description: description:
"The payment window was missed. You can move this booking to another schedule or cancel it.", "The payment window was missed. You can move this booking to another schedule or cancel it.",
stage: 3, stage: 6,
}, },
PNR_GENERATED: { PNR_GENERATED: {
title: "Payment reference generated", title: "Payment reference generated",
description: description:
"A payment reference number has been generated for this booking.", "A payment reference number has been generated for this booking.",
stage: 3, stage: 5,
}, },
PAYMENT_VERIFICATION_IN_PROGRESS: { PAYMENT_VERIFICATION_IN_PROGRESS: {
title: "Verifying payment", title: "Verifying payment",
description: "Your payment is being verified.", description: "Your payment is being verified.",
stage: 3, stage: 4,
}, },
PAID: { PAID: {
title: "Payment confirmed", title: "Payment confirmed",
description: "Payment has been confirmed for this booking.", description: "Payment has been confirmed for this booking.",
stage: 3, stage: 5,
}, },
IN_TRANSIT: { IN_TRANSIT: {
title: "Cargo moving", title: "Cargo moving",
description: "Your shipment is currently moving through the rail network.", description: "Your shipment is currently moving through the rail network.",
stage: 3, stage: 6,
}, },
PENDING_CONSOLIDATION: { PENDING_CONSOLIDATION: {
title: "Pending consolidation", title: "Pending consolidation",
description: "Awaiting a consolidation partner shipment.", description: "Awaiting a consolidation partner shipment.",
stage: 3, stage: 5,
}, },
CONSOLIDATED: { CONSOLIDATED: {
title: "Consolidated", title: "Consolidated",
description: "Cargo has been consolidated with a partner shipment.", description: "Cargo has been consolidated with a partner shipment.",
stage: 3, stage: 5,
}, },
COMPLETED: { COMPLETED: {
title: "Service complete", title: "Service complete",
description: "Cargo delivered and service successfully terminated.", description: "Cargo delivered and service successfully terminated.",
stage: 4, stage: 7,
}, },
DELIVERED: { DELIVERED: {
title: "Service complete", title: "Service complete",
description: "Cargo delivered and service successfully terminated.", description: "Cargo delivered and service successfully terminated.",
stage: 4, stage: 7,
}, },
REJECTED: { REJECTED: {
title: "Booking rejected", title: "Booking rejected",

View File

@@ -1,8 +1,7 @@
import { useMemo, useRef, type ReactNode } from "react"; import { api } from "@/services/api";
import { Controller, useForm } from "react-hook-form"; import type { CreateBookingPayload } from "@/services/bookings.service";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Freight } from "@edr/types";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router-dom";
import { import {
ActionIcon, ActionIcon,
Alert, Alert,
@@ -21,6 +20,7 @@ import {
TextInput, TextInput,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
@@ -34,12 +34,17 @@ import {
Upload, Upload,
X, X,
} from "lucide-react"; } from "lucide-react";
import type { Freight } from "@edr/types"; import { useMemo, useRef, type ReactNode } from "react";
import { api } from "@/services/api"; import { Controller, useForm } from "react-hook-form";
import type { CreateBookingPayload } from "@/services/bookings.service"; import { useNavigate, useParams } from "react-router-dom";
import {
CountChip,
DocRow,
IconSquare,
} from "./BookingDetailPage/components/Documents";
import { import {
BookingFormInputValues,
BOOKING_DOCS_SETTING, BOOKING_DOCS_SETTING,
BookingFormInputValues,
bookingFormSchema, bookingFormSchema,
getRouteDirection, getRouteDirection,
initialBookingFormValues, initialBookingFormValues,
@@ -48,11 +53,6 @@ import {
} from "./new-booking-form/schema"; } from "./new-booking-form/schema";
import { SelectField } from "./new-booking-form/shared"; import { SelectField } from "./new-booking-form/shared";
import { Step5CargoDetails } from "./new-booking-form/steps"; import { Step5CargoDetails } from "./new-booking-form/steps";
import {
CountChip,
DocRow,
IconSquare,
} from "./BookingDetailPage/components/Documents";
function yardNameFromBooking( function yardNameFromBooking(
yard: { label?: string; code?: string; name?: string } | undefined | null, yard: { label?: string; code?: string; name?: string } | undefined | null,
@@ -117,8 +117,6 @@ function mapBookingToFormValues(
shippingLine: (booking as any).shippingLine?.name ?? "", shippingLine: (booking as any).shippingLine?.name ?? "",
consolidationEnabled: booking.allowConsolidation ?? false, consolidationEnabled: booking.allowConsolidation ?? false,
notes: "", notes: "",
// Terms were accepted at creation; editing shouldn't re-gate on them.
termsAccepted: true,
containers: [], containers: [],
} as BookingFormInputValues; } as BookingFormInputValues;

View File

@@ -1,13 +1,27 @@
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { CreateBookingPayload } from "@/services/bookings.service"; import type {
CreateBookingPayload,
GeneratePriceResponse,
} from "@/services/bookings.service";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Alert, Box, Button, Group, Text, Title } from "@mantine/core"; import {
Alert,
Box,
Button,
Group,
Modal,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, Check, ChevronLeft, ChevronRight } from "lucide-react"; import { AlertCircle, Check, ChevronLeft, ChevronRight, Send, XCircle } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth"; import useAuth from "@/hooks/useAuth";
import type { Freight } from "@/types";
import { import {
BookingFormInputValues, BookingFormInputValues,
STEPS, STEPS,
@@ -97,6 +111,58 @@ export default function NewBookingPage() {
}, },
}); });
const createAndPriceMutation = useMutation({
mutationFn: async (payload: CreateBookingPayload) => {
const booking = await api.bookings.create.call(payload);
const documents = form.getValues("documents") ?? {};
const hasDocs = Object.values(documents).some((value) =>
Array.isArray(value) ? value.length > 0 : Boolean(value),
);
if (hasDocs) {
await api.bookings.uploadDocuments.call({
id: booking.id,
files: documents,
});
}
const pricing = await api.bookings.generatePrice.call({ id: booking.id });
return { bookingId: booking.id, pricing };
},
onSuccess: ({ bookingId, pricing }) => {
setPriceBookingId(bookingId);
setPricingData(pricing);
setPricingPhase("ready");
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
},
onError: () => {
setPricingPhase("idle");
},
});
const confirmMutation = useMutation({
mutationFn: async () => {
if (!priceBookingId) throw new Error("No booking to confirm");
await api.bookings.submit.call({ id: priceBookingId });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate(`/bookings/${priceBookingId}`);
},
});
const abortMutation = useMutation({
mutationFn: async (reason: string) => {
if (!priceBookingId) throw new Error("No booking to abort");
await api.bookings.cancel.call({ id: priceBookingId, reason });
},
onSuccess: () => {
setCancelDialogOpen(false);
navigate("/bookings");
},
});
const form = useForm<BookingFormInputValues, any, BookingFormValues>({ const form = useForm<BookingFormInputValues, any, BookingFormValues>({
defaultValues: initialBookingFormValues, defaultValues: initialBookingFormValues,
resolver: zodResolver(bookingFormSchema), resolver: zodResolver(bookingFormSchema),
@@ -116,6 +182,21 @@ export default function NewBookingPage() {
return route; return route;
}, [originYard, destinationYard]); }, [originYard, destinationYard]);
const docValues = form.watch("documents") ?? {};
const hasDocuments = useMemo(
() =>
Object.values(docValues).some((value) =>
Array.isArray(value) ? value.length > 0 : Boolean(value),
),
[docValues],
);
const [pricingPhase, setPricingPhase] = useState<"idle" | "generating" | "ready">("idle");
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(null);
const [priceBookingId, setPriceBookingId] = useState<string | null>(null);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
async function handleContinue() { async function handleContinue() {
const valid = await form.trigger(stepFields[step], { shouldFocus: true }); const valid = await form.trigger(stepFields[step], { shouldFocus: true });
if (!valid) return; if (!valid) return;
@@ -123,14 +204,14 @@ export default function NewBookingPage() {
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1)); setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
} }
const handleSubmit = form.handleSubmit((data) => { function buildApiPayload(data: BookingFormValues): CreateBookingPayload {
if (data.contractType === "renewal" && !data.previousContractRef) { if (data.contractType === "renewal" && !data.previousContractRef) {
form.setError("previousContractRef", { form.setError("previousContractRef", {
type: "manual", type: "manual",
message: "Select a previous contract reference.", message: "Select a previous contract reference.",
}); });
setStep(1); setStep(1);
return; throw new Error("Validation failed");
} }
const totalWeight = const totalWeight =
@@ -141,7 +222,6 @@ export default function NewBookingPage() {
) )
: Number(data.cargoWeight || 0); : Number(data.cargoWeight || 0);
// ── Reference data lookups ──────────────────────────────────────────
const shippingLines = referenceData?.shipping_line ?? []; const shippingLines = referenceData?.shipping_line ?? [];
const cargoTree = referenceData?.cargo_type ?? []; const cargoTree = referenceData?.cargo_type ?? [];
const containerGroups = referenceData?.containers ?? []; const containerGroups = referenceData?.containers ?? [];
@@ -174,8 +254,7 @@ export default function NewBookingPage() {
(s) => s.id === data.serviceTypeId, (s) => s.id === data.serviceTypeId,
)!; )!;
// ── Build API payload ─────────────────────────────────────────────── return {
const apiPayload: CreateBookingPayload = {
scheduledDate: new Date().toISOString(), scheduledDate: new Date().toISOString(),
contractType: contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"], data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
@@ -222,8 +301,25 @@ export default function NewBookingPage() {
: {}), : {}),
...(cargoFreeText ? { cargoFreeText } : {}), ...(cargoFreeText ? { cargoFreeText } : {}),
}; };
}
createMutation.mutate(apiPayload); const handleDraftSubmit = form.handleSubmit((data) => {
try {
const apiPayload = buildApiPayload(data);
createMutation.mutate(apiPayload);
} catch {
// validation error already handled
}
});
const handleGeneratePrice = form.handleSubmit((data) => {
try {
const apiPayload = buildApiPayload(data);
setPricingPhase("generating");
createAndPriceMutation.mutate(apiPayload);
} catch {
// validation error already handled
}
}); });
return ( return (
@@ -270,7 +366,7 @@ export default function NewBookingPage() {
id="new-booking-form" id="new-booking-form"
className="flex flex-col" className="flex flex-col"
style={{ flex: 1 }} style={{ flex: 1 }}
onSubmit={handleSubmit} onSubmit={handleDraftSubmit}
> >
<Box flex={1} p="24px"> <Box flex={1} p="24px">
<Box mb="lg"> <Box mb="lg">
@@ -295,6 +391,24 @@ export default function NewBookingPage() {
</Alert> </Alert>
)} )}
{createAndPriceMutation.isError && (
<Alert
color="red"
icon={<AlertCircle size={16} />}
radius="md"
mb="lg"
>
<Text size="sm" fw={600}>
Failed to generate price estimate
</Text>
<Text size="sm" mt={4} c="red.7">
{createAndPriceMutation.error instanceof Error
? createAndPriceMutation.error.message
: "An unexpected error occurred. Please try again."}
</Text>
</Alert>
)}
{step === 1 && ( {step === 1 && (
<Step1ContractType form={form} referenceData={referenceData} /> <Step1ContractType form={form} referenceData={referenceData} />
)} )}
@@ -326,6 +440,15 @@ export default function NewBookingPage() {
setStep={setStep} setStep={setStep}
direction={direction!} direction={direction!}
referenceData={referenceData} referenceData={referenceData}
pricingPhase={pricingPhase}
pricingData={pricingData}
onConfirm={() => confirmMutation.mutate()}
onContinueLater={
priceBookingId ? () => navigate(`/bookings/${priceBookingId}`) : undefined
}
onAbort={() => setCancelDialogOpen(true)}
confirmPending={confirmMutation.isPending}
abortPending={abortMutation.isPending}
/> />
)} )}
</Box> </Box>
@@ -366,24 +489,97 @@ export default function NewBookingPage() {
> >
Continue Continue
</Button> </Button>
) : ( ) : pricingPhase === "idle" ? (
<Group>
<Button
type="submit"
form="new-booking-form"
variant={hasDocuments ? "outline" : "filled"}
color="edr-green"
radius="md"
loading={createMutation.isPending}
leftSection={
createMutation.isPending ? undefined : <Check size={16} />
}
>
{createMutation.isPending ? "Saving Draft..." : "Save as Draft"}
</Button>
{hasDocuments && (
<Button
type="button"
color="edr-green"
radius="md"
loading={createAndPriceMutation.isPending}
leftSection={
createAndPriceMutation.isPending ? undefined : <Send size={16} />
}
onClick={() => handleGeneratePrice()}
>
{createAndPriceMutation.isPending ? "Generating price…" : "Submit"}
</Button>
)}
</Group>
) : pricingPhase === "generating" ? (
<Button <Button
type="submit" type="button"
form="new-booking-form"
color="edr-green" color="edr-green"
radius="md" radius="md"
loading={createMutation.isPending} loading
leftSection={
createMutation.isPending ? undefined : <Check size={16} />
}
> >
{createMutation.isPending ? "Saving Draft..." : "Save as Draft"} Generating price estimate
</Button> </Button>
)} ) : null}
</Group> </Group>
</Box> </Box>
</form> </form>
{/* <DevTool control={form.control} /> */}
<Modal
opened={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}
title={<Text fw={700}>Abort booking</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Are you sure you want to abort this booking? This action cannot be
undone.
</Text>
<TextInput
label="Reason for cancellation (optional)"
placeholder="e.g. Change of plans…"
value={cancelReason}
onChange={(e) => setCancelReason(e.currentTarget.value)}
radius="md"
data-autofocus
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setCancelDialogOpen(false)}
>
Keep editing
</Button>
<Button
color="red"
radius="md"
onClick={() =>
abortMutation.mutate(
cancelReason.trim() || "Aborted by customer",
)
}
disabled={abortMutation.isPending}
loading={abortMutation.isPending}
leftSection={
!abortMutation.isPending ? <XCircle size={15} /> : undefined
}
>
Yes, abort
</Button>
</Group>
</Stack>
</Modal>
</Box> </Box>
); );
} }

View File

@@ -121,7 +121,6 @@ export const bookingFormSchema = z
consolidationEnabled: z.boolean(), consolidationEnabled: z.boolean(),
documents: z.record(z.string(), z.any()).default({}), documents: z.record(z.string(), z.any()).default({}),
notes: z.string(), notes: z.string(),
termsAccepted: z.boolean(),
}) })
.refine( .refine(
(data) => (data) =>
@@ -165,23 +164,13 @@ export const bookingFormSchema = z
(data) => !(data.cargoType === "container" && data.containers.length === 0), (data) => !(data.cargoType === "container" && data.containers.length === 0),
{ message: "Add at least one container.", path: ["containers"] }, { message: "Add at least one container.", path: ["containers"] },
) )
.refine((data) => data.termsAccepted, {
message: "Accept the freight contract terms to submit.",
path: ["termsAccepted"],
})
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.cargoType === "bulk") { if (data.cargoType === "bulk") {
if (!data.cargoTypePath[0]) { if (!data.cargoTypePath[0]) {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
path: ["cargoTypePath"], path: ["cargoTypePath"],
message: "Select a freight type.", message: "Select a Cargo type.",
});
} else if (!data.cargoTypePath[1]) {
ctx.addIssue({
code: "custom",
path: ["cargoTypePath"],
message: "Select a commodity.",
}); });
} }
} }
@@ -237,7 +226,6 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
consolidationEnabled: false, consolidationEnabled: false,
documents: {}, documents: {},
notes: "", notes: "",
termsAccepted: false,
}; };
export const stepFields: Record<number, Array<Path<BookingFormValues>>> = { export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
@@ -265,7 +253,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
], ],
5: ["scheduledDate", "trainScheduleId"], 5: ["scheduledDate", "trainScheduleId"],
6: ["documents"], 6: ["documents"],
7: ["notes", "termsAccepted"], 7: ["notes"],
}; };
export interface ContainerConfig { export interface ContainerConfig {
@@ -288,10 +276,10 @@ export function getRouteDirection(
return "DOMESTIC"; return "DOMESTIC";
} }
if (origin.country === "Ethiopia" && dest.country === "Djibouti") { if (origin.country === "Ethiopia" && dest.country === "Djibouti") {
return "IMPORT"; return "EXPORT";
} }
if (origin.country === "Djibouti" && dest.country === "Ethiopia") { if (origin.country === "Djibouti" && dest.country === "Ethiopia") {
return "EXPORT"; return "IMPORT";
} }
return null; return null;

View File

@@ -1,8 +1,8 @@
import type { Freight } from "@edr/types";
import { Divider, Skeleton, Stack, Switch } from "@mantine/core";
import { Flame, MapPin, Snowflake } from "lucide-react";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { Controller, type UseFormReturn } from "react-hook-form"; import { Controller, type UseFormReturn } from "react-hook-form";
import { Flame, MapPin, Snowflake } from "lucide-react";
import { Divider, Skeleton, Stack, Switch } from "@mantine/core";
import type { Freight } from "@edr/types";
import { import {
BookingFormInputValues, BookingFormInputValues,
type BookingFormValues, type BookingFormValues,
@@ -56,25 +56,24 @@ export function Step4Route({
return true; return true;
}); });
}, [yardOptions, destinationYard]); }, [yardOptions, destinationYard]);
console.log({ yardOptions, originYard, destinationYard });
const destData = useMemo(() => { const destData = useMemo(() => {
return yardOptions.filter((o) => o.value !== originYard); return yardOptions.filter((o) => o.value !== originYard);
}, [yardOptions, originYard]); }, [yardOptions, originYard]);
const direction = getRouteDirection( const origin = referenceData?.yard.find((y) => y.id === originYard);
referenceData?.yard.find((y) => y.id === originYard), const dest = referenceData?.yard.find((y) => y.id === destinationYard);
referenceData?.yard.find((y) => y.name === destinationYard), const direction = getRouteDirection(origin, dest);
); console.log({ yardOptions, originYard, destinationYard, direction, origin, dest });
const directionStyle: Record<string, string> = { const directionStyle: Record<string, string> = {
export: "bg-sky-50 text-sky-800 border-sky-200", EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
import: "bg-amber-50 text-amber-800 border-amber-200", IMPORT: "bg-amber-50 text-amber-800 border-amber-200",
domestic: "bg-gray-100 text-gray-600 border-gray-200", DOMESTIC: "bg-gray-100 text-gray-600 border-gray-200",
}; };
const directionLabel: Record<string, string> = { const directionLabel: Record<string, string> = {
export: "Export workflow (inside country to outside country)", EXPORT: "Export workflow (inside country to outside country)",
import: "Import workflow (outside country to inside country)", IMPORT: "Import workflow (outside country to inside country)",
domestic: "Domestic corridor", DOMESTIC: "Domestic corridor",
}; };
useEffect(() => { useEffect(() => {

View File

@@ -1,5 +1,17 @@
import { Controller, type UseFormReturn } from "react-hook-form"; import { Controller, type UseFormReturn } from "react-hook-form";
import { Box, Card, Checkbox, SimpleGrid, Text, Textarea } from "@mantine/core"; import {
Box,
Button,
Card,
Divider,
Group,
Loader,
SimpleGrid,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { Check, Send, XCircle, FileText, Route, Package, Truck } from "lucide-react";
import { import {
BookingFormInputValues, BookingFormInputValues,
BOOKING_DOCS_SETTING, BOOKING_DOCS_SETTING,
@@ -8,6 +20,7 @@ import {
} from "./schema"; } from "./schema";
import { StepHeader } from "./shared"; import { StepHeader } from "./shared";
import type { Freight } from "@/types"; import type { Freight } from "@/types";
import type { GeneratePriceResponse } from "@/services/bookings.service";
type BookingForm = UseFormReturn< type BookingForm = UseFormReturn<
BookingFormInputValues, BookingFormInputValues,
@@ -20,19 +33,32 @@ export function Step8Review({
setStep, setStep,
direction, direction,
referenceData, referenceData,
pricingPhase = "idle",
pricingData,
onConfirm,
onContinueLater,
onAbort,
confirmPending = false,
abortPending = false,
}: { }: {
form: BookingForm; form: BookingForm;
setStep: (step: number) => void; setStep: (step: number) => void;
direction: Freight.ScheduleTradeDirection; direction: Freight.ScheduleTradeDirection;
referenceData?: Freight.BookingReferenceData; referenceData?: Freight.BookingReferenceData;
pricingPhase?: "idle" | "generating" | "ready";
pricingData?: GeneratePriceResponse | null;
onConfirm?: () => void;
onContinueLater?: () => void;
onAbort?: () => void;
confirmPending?: boolean;
abortPending?: boolean;
}) { }) {
const values = form.watch(); const values = form.watch();
const errors = form.formState.errors;
const serviceType = referenceData?.service.find( const serviceType = referenceData?.service.find(
(s) => s.id === values.serviceTypeId, (s) => s.id === values.serviceTypeId,
); );
function Row({ function CompactRow({
label, label,
value, value,
target, target,
@@ -42,19 +68,19 @@ export function Step8Review({
target: number; target: number;
}) { }) {
return ( return (
<div className="flex items-start justify-between gap-4 py-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0 flex-1">
<Text size="xs" c="dimmed"> <Text size="10px" c="dimmed" fw={600} tt="uppercase" className="mb-1 tracking-wider">
{label} {label}
</Text> </Text>
<Text size="sm" fw={500} mt={2} className="truncate"> <Text size="sm" fw={500} className="truncate">
{value || "—"} {value || "—"}
</Text> </Text>
</div> </div>
<button <button
type="button" type="button"
onClick={() => setStep(target)} onClick={() => setStep(target)}
className="shrink-0 text-xs font-medium text-emerald-600 hover:underline" className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
> >
Edit Edit
</button> </button>
@@ -62,6 +88,28 @@ export function Step8Review({
); );
} }
function CompactCard({
icon: Icon,
title,
children,
}: {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
}) {
return (
<Card radius="md" p="sm" withBorder className="border-gray-200 bg-white hover:shadow-sm transition-shadow">
<Group gap="xs" mb="xs" wrap="nowrap">
<Box c="edr-green">{Icon}</Box>
<Text size="xs" fw={700} tt="uppercase" c="dimmed" className="tracking-wider">
{title}
</Text>
</Group>
<Stack gap="xs">{children}</Stack>
</Card>
);
}
const containerSummary = const containerSummary =
values.cargoType === "container" && values.containers.length > 0 values.cargoType === "container" && values.containers.length > 0
? values.containers ? values.containers
@@ -95,146 +143,211 @@ export function Step8Review({
return child ? `${group.name}${child.name}` : group.name; return child ? `${group.name}${child.name}` : group.name;
})(); })();
function ReviewCard({ const originYardName = referenceData?.yard.find(
title, (y) => y.id === values.originYard,
children, )?.name ?? values.originYard;
}: {
title: string; const destinationYardName = referenceData?.yard.find(
children: React.ReactNode; (y) => y.id === values.destinationYard,
}) { )?.name ?? values.destinationYard;
return (
<Card radius="lg" withBorder p={0} className="overflow-hidden">
<Box
px="md"
py="sm"
className="border-b border-[var(--mantine-color-gray-2)] bg-gray-50/60"
>
<Text
size="xs"
fw={600}
tt="uppercase"
c="dimmed"
className="tracking-wider"
>
{title}
</Text>
</Box>
<Box px="md" py="xs" className="divide-y divide-gray-100">
{children}
</Box>
</Card>
);
}
return ( return (
<div className="space-y-6"> <Stack gap="md">
<StepHeader <StepHeader
title="Review & Submit" title="Review & Submit"
description="Confirm your contract request before sending it for EDR staff review." description="Confirm your contract request before sending it for EDR staff review."
/> />
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md"> {/* Pricing Card - Prominent at top */}
<ReviewCard title="Contract & Service"> {pricingPhase === "generating" && (
<Row <Card radius="lg" withBorder p="lg" className="border-edr-green border-2">
<Group justify="center" py="lg">
<Loader size="sm" />
<Text size="sm" c="dimmed">
Generating price estimate
</Text>
</Group>
</Card>
)}
{pricingPhase === "ready" && pricingData && (
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2 bg-gradient-to-br from-white to-emerald-50/30">
<Stack gap="sm">
<Text size="sm" fw={700} tt="uppercase" c="edr-green" className="tracking-wider">
💳 Price Breakdown
</Text>
<Stack gap="xs">
{pricingData.lineItems.map((item) => (
<Group key={item.code} justify="space-between" py={2}>
<Text size="sm" c="dimmed">
{item.description}
</Text>
<Text size="sm" fw={600}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
))}
</Stack>
<Divider my="xs" />
<Group justify="space-between" py={2}>
<Text fw={700} size="md">
Total
</Text>
<Text fw={800} size="lg" c="edr-green">
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
</Text>
</Group>
{pricingData.warnings.length > 0 && (
<Text size="xs" c="orange.7" mt="xs" p="xs" className="bg-orange-50 rounded">
{pricingData.warnings.join(", ")}
</Text>
)}
<Group mt="md">
<Button
color="edr-green"
radius="md"
leftSection={<Check size={16} />}
onClick={onConfirm}
loading={confirmPending}
className="flex-1"
>
{confirmPending ? "Confirming…" : "Confirm"}
</Button>
<Button
variant="outline"
color="edr-green"
radius="md"
leftSection={<Send size={16} />}
onClick={onContinueLater}
className="flex-1"
>
Continue later
</Button>
<Button
variant="outline"
color="red"
radius="md"
leftSection={!abortPending ? <XCircle size={16} /> : undefined}
onClick={onAbort}
loading={abortPending}
>
Abort
</Button>
</Group>
</Stack>
</Card>
)}
{/* Review Details - Compact Cards Grid */}
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="sm" mt="md">
<CompactCard icon={<Package size={16} />} title="Contract & Service">
<CompactRow
label="Type" label="Type"
value={values.contractType === "new" ? "New Contract" : "Renewal"} value={values.contractType === "new" ? "New Contract" : "Renewal"}
target={1} target={1}
/> />
<Row label="Service" value={serviceType?.name ?? ""} target={2} /> <CompactRow label="Service" value={serviceType?.name ?? ""} target={2} />
</ReviewCard> </CompactCard>
<ReviewCard title="First & Last Mile"> <CompactCard icon={<Route size={16} />} title="Route">
<Row <CompactRow
label="Origin → Destination"
value={`${originYardName}${destinationYardName}`}
target={3}
/>
<CompactRow
label="Workflow"
value={
direction ? direction.charAt(0).toUpperCase() + direction.slice(1) : ""
}
target={3}
/>
</CompactCard>
<CompactCard icon={<Truck size={16} />} title="Logistics">
<CompactRow
label="First Mile" label="First Mile"
value={ value={
values.firstMile.enabled values.firstMile.enabled ? values.firstMile.pickUpAddress : "Not requested"
? values.firstMile.pickUpAddress
: "Not requested"
} }
target={2} target={2}
/> />
<Row <CompactRow
label="Last Mile" label="Last Mile"
value={ value={
values.lastMile.enabled values.lastMile.enabled ? values.lastMile.deliveryAddress : "Not requested"
? values.lastMile.deliveryAddress
: "Not requested"
} }
target={2} target={2}
/> />
<Row <CompactRow
label="Equipment Return" label="Equipment Return"
value={ value={
values.equipmentReturn === "with_return" values.equipmentReturn === "with_return" ? "With Return" : "Without Return"
? "With Return"
: "Without Return"
} }
target={2} target={2}
/> />
<Row <CompactRow
label="Customs Clearing" label="Customs Clearing"
value={values.customsClearingEnabled ? "Enabled" : "Not requested"} value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
target={2} target={2}
/> />
</ReviewCard> </CompactCard>
<ReviewCard title="Route & Cargo"> <CompactCard icon={<Package size={16} />} title="Cargo Details">
<Row <CompactRow
label="Route"
value={`${values.originYard}${values.destinationYard}`}
target={3}
/>
<Row
label="Workflow"
value={
direction
? direction.charAt(0).toUpperCase() + direction.slice(1)
: ""
}
target={3}
/>
<Row
label="Weight (VGM)" label="Weight (VGM)"
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""} value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
target={4} target={4}
/> />
<Row label="Cargo" value={cargoValue} target={4} /> <CompactRow label="Cargo Type" value={cargoValue} target={4} />
<Row <CompactRow
label="Modifiers" label="Modifiers"
value={ value={
[ [values.isHazardous && "Hazardous", values.isRefrigerated && "Refrigerated"]
values.isHazardous && "Hazardous",
values.isRefrigerated && "Refrigerated",
]
.filter(Boolean) .filter(Boolean)
.join(", ") || "None" .join(", ") || "None"
} }
target={3} target={3}
/> />
</ReviewCard> </CompactCard>
<ReviewCard title="Container & Wagons"> <CompactCard icon={<Package size={16} />} title="Containers">
<Row label="Containers" value={containerSummary || "—"} target={4} /> <CompactRow
<Row label="Count & Type"
label="Total VGM" value={containerSummary || "—"}
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : ""}
target={4} target={4}
/> />
</ReviewCard> <CompactRow
label="Total VGM"
<ReviewCard title="Documents"> value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
<Row target={4}
label="Attached"
value={
docsAttached > 0
? `${docsAttached} of ${docsTotal} attached`
: "None — upload later from the booking page"
}
target={5}
/> />
</ReviewCard> </CompactCard>
<CompactCard icon={<FileText size={16} />} title="Documents">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<Text size="10px" c="dimmed" fw={600} tt="uppercase" className="mb-1 tracking-wider">
Attached
</Text>
<Text size="sm" fw={500}>
{docsAttached > 0
? `${docsAttached} of ${docsTotal}`
: "None"}
</Text>
</div>
<button
type="button"
onClick={() => setStep(5)}
className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
>
Edit
</button>
</div>
</CompactCard>
</SimpleGrid> </SimpleGrid>
{/* Notes */}
<Controller <Controller
name="notes" name="notes"
control={form.control} control={form.control}
@@ -243,35 +356,13 @@ export function Step8Review({
{...field} {...field}
id="notes" id="notes"
label="Additional Notes" label="Additional Notes"
placeholder="Any special instructions or notes for EDR operations..." placeholder="Any special instructions or notes for EDR operations"
rows={3} rows={2}
radius="md" radius="md"
size="sm"
/> />
)} )}
/> />
</Stack>
<Controller
name="termsAccepted"
control={form.control}
render={({ field, fieldState }) => (
<Checkbox
label={
<Text size="sm" c="dimmed">
I confirm the information is accurate and agree to EDR's{" "}
<Text component="span" c="edr-green" fw={500}>
freight contract terms and conditions
</Text>
.
</Text>
}
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
error={fieldState.error?.message ?? errors.termsAccepted?.message}
color="edr-green"
radius="sm"
/>
)}
/>
</div>
); );
} }