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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,27 @@
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 { 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 { AlertCircle, Check, ChevronLeft, ChevronRight } from "lucide-react";
import { AlertCircle, Check, ChevronLeft, ChevronRight, Send, XCircle } from "lucide-react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth";
import type { Freight } from "@/types";
import {
BookingFormInputValues,
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>({
defaultValues: initialBookingFormValues,
resolver: zodResolver(bookingFormSchema),
@@ -116,6 +182,21 @@ export default function NewBookingPage() {
return route;
}, [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() {
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
if (!valid) return;
@@ -123,14 +204,14 @@ export default function NewBookingPage() {
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
}
const handleSubmit = form.handleSubmit((data) => {
function buildApiPayload(data: BookingFormValues): CreateBookingPayload {
if (data.contractType === "renewal" && !data.previousContractRef) {
form.setError("previousContractRef", {
type: "manual",
message: "Select a previous contract reference.",
});
setStep(1);
return;
throw new Error("Validation failed");
}
const totalWeight =
@@ -141,7 +222,6 @@ export default function NewBookingPage() {
)
: Number(data.cargoWeight || 0);
// ── Reference data lookups ──────────────────────────────────────────
const shippingLines = referenceData?.shipping_line ?? [];
const cargoTree = referenceData?.cargo_type ?? [];
const containerGroups = referenceData?.containers ?? [];
@@ -174,8 +254,7 @@ export default function NewBookingPage() {
(s) => s.id === data.serviceTypeId,
)!;
// ── Build API payload ───────────────────────────────────────────────
const apiPayload: CreateBookingPayload = {
return {
scheduledDate: new Date().toISOString(),
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
@@ -222,8 +301,25 @@ export default function NewBookingPage() {
: {}),
...(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 (
@@ -270,7 +366,7 @@ export default function NewBookingPage() {
id="new-booking-form"
className="flex flex-col"
style={{ flex: 1 }}
onSubmit={handleSubmit}
onSubmit={handleDraftSubmit}
>
<Box flex={1} p="24px">
<Box mb="lg">
@@ -295,6 +391,24 @@ export default function NewBookingPage() {
</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 && (
<Step1ContractType form={form} referenceData={referenceData} />
)}
@@ -326,6 +440,15 @@ export default function NewBookingPage() {
setStep={setStep}
direction={direction!}
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>
@@ -366,24 +489,97 @@ export default function NewBookingPage() {
>
Continue
</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
type="submit"
form="new-booking-form"
type="button"
color="edr-green"
radius="md"
loading={createMutation.isPending}
leftSection={
createMutation.isPending ? undefined : <Check size={16} />
}
loading
>
{createMutation.isPending ? "Saving Draft..." : "Save as Draft"}
Generating price estimate
</Button>
)}
) : null}
</Group>
</Box>
</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>
);
}

View File

@@ -121,7 +121,6 @@ export const bookingFormSchema = z
consolidationEnabled: z.boolean(),
documents: z.record(z.string(), z.any()).default({}),
notes: z.string(),
termsAccepted: z.boolean(),
})
.refine(
(data) =>
@@ -165,23 +164,13 @@ export const bookingFormSchema = z
(data) => !(data.cargoType === "container" && data.containers.length === 0),
{ 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) => {
if (data.cargoType === "bulk") {
if (!data.cargoTypePath[0]) {
ctx.addIssue({
code: "custom",
path: ["cargoTypePath"],
message: "Select a freight type.",
});
} else if (!data.cargoTypePath[1]) {
ctx.addIssue({
code: "custom",
path: ["cargoTypePath"],
message: "Select a commodity.",
message: "Select a Cargo type.",
});
}
}
@@ -237,7 +226,6 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
consolidationEnabled: false,
documents: {},
notes: "",
termsAccepted: false,
};
export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
@@ -265,7 +253,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
],
5: ["scheduledDate", "trainScheduleId"],
6: ["documents"],
7: ["notes", "termsAccepted"],
7: ["notes"],
};
export interface ContainerConfig {
@@ -288,10 +276,10 @@ export function getRouteDirection(
return "DOMESTIC";
}
if (origin.country === "Ethiopia" && dest.country === "Djibouti") {
return "IMPORT";
return "EXPORT";
}
if (origin.country === "Djibouti" && dest.country === "Ethiopia") {
return "EXPORT";
return "IMPORT";
}
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 { 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 {
BookingFormInputValues,
type BookingFormValues,
@@ -56,25 +56,24 @@ export function Step4Route({
return true;
});
}, [yardOptions, destinationYard]);
console.log({ yardOptions, originYard, destinationYard });
const destData = useMemo(() => {
return yardOptions.filter((o) => o.value !== originYard);
}, [yardOptions, originYard]);
const direction = getRouteDirection(
referenceData?.yard.find((y) => y.id === originYard),
referenceData?.yard.find((y) => y.name === destinationYard),
);
const origin = referenceData?.yard.find((y) => y.id === originYard);
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
const direction = getRouteDirection(origin, dest);
console.log({ yardOptions, originYard, destinationYard, direction, origin, dest });
const directionStyle: Record<string, string> = {
export: "bg-sky-50 text-sky-800 border-sky-200",
import: "bg-amber-50 text-amber-800 border-amber-200",
domestic: "bg-gray-100 text-gray-600 border-gray-200",
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
IMPORT: "bg-amber-50 text-amber-800 border-amber-200",
DOMESTIC: "bg-gray-100 text-gray-600 border-gray-200",
};
const directionLabel: Record<string, string> = {
export: "Export workflow (inside country to outside country)",
import: "Import workflow (outside country to inside country)",
domestic: "Domestic corridor",
EXPORT: "Export workflow (inside country to outside country)",
IMPORT: "Import workflow (outside country to inside country)",
DOMESTIC: "Domestic corridor",
};
useEffect(() => {

View File

@@ -1,5 +1,17 @@
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 {
BookingFormInputValues,
BOOKING_DOCS_SETTING,
@@ -8,6 +20,7 @@ import {
} from "./schema";
import { StepHeader } from "./shared";
import type { Freight } from "@/types";
import type { GeneratePriceResponse } from "@/services/bookings.service";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -20,19 +33,32 @@ export function Step8Review({
setStep,
direction,
referenceData,
pricingPhase = "idle",
pricingData,
onConfirm,
onContinueLater,
onAbort,
confirmPending = false,
abortPending = false,
}: {
form: BookingForm;
setStep: (step: number) => void;
direction: Freight.ScheduleTradeDirection;
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 errors = form.formState.errors;
const serviceType = referenceData?.service.find(
(s) => s.id === values.serviceTypeId,
);
function Row({
function CompactRow({
label,
value,
target,
@@ -42,19 +68,19 @@ export function Step8Review({
target: number;
}) {
return (
<div className="flex items-start justify-between gap-4 py-2">
<div className="min-w-0">
<Text size="xs" c="dimmed">
<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">
{label}
</Text>
<Text size="sm" fw={500} mt={2} className="truncate">
<Text size="sm" fw={500} className="truncate">
{value || "—"}
</Text>
</div>
<button
type="button"
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
</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 =
values.cargoType === "container" && values.containers.length > 0
? values.containers
@@ -95,146 +143,211 @@ export function Step8Review({
return child ? `${group.name}${child.name}` : group.name;
})();
function ReviewCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<Card radius="lg" withBorder p={0} className="overflow-hidden">
<Box
px="md"
py="sm"
className="border-b border-[var(--mantine-color-gray-2)] bg-gray-50/60"
>
<Text
size="xs"
fw={600}
tt="uppercase"
c="dimmed"
className="tracking-wider"
>
{title}
</Text>
</Box>
<Box px="md" py="xs" className="divide-y divide-gray-100">
{children}
</Box>
</Card>
);
}
const originYardName = referenceData?.yard.find(
(y) => y.id === values.originYard,
)?.name ?? values.originYard;
const destinationYardName = referenceData?.yard.find(
(y) => y.id === values.destinationYard,
)?.name ?? values.destinationYard;
return (
<div className="space-y-6">
<Stack gap="md">
<StepHeader
title="Review & Submit"
description="Confirm your contract request before sending it for EDR staff review."
/>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<ReviewCard title="Contract & Service">
<Row
{/* Pricing Card - Prominent at top */}
{pricingPhase === "generating" && (
<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"
value={values.contractType === "new" ? "New Contract" : "Renewal"}
target={1}
/>
<Row label="Service" value={serviceType?.name ?? ""} target={2} />
</ReviewCard>
<CompactRow label="Service" value={serviceType?.name ?? ""} target={2} />
</CompactCard>
<ReviewCard title="First & Last Mile">
<Row
<CompactCard icon={<Route size={16} />} title="Route">
<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"
value={
values.firstMile.enabled
? values.firstMile.pickUpAddress
: "Not requested"
values.firstMile.enabled ? values.firstMile.pickUpAddress : "Not requested"
}
target={2}
/>
<Row
<CompactRow
label="Last Mile"
value={
values.lastMile.enabled
? values.lastMile.deliveryAddress
: "Not requested"
values.lastMile.enabled ? values.lastMile.deliveryAddress : "Not requested"
}
target={2}
/>
<Row
<CompactRow
label="Equipment Return"
value={
values.equipmentReturn === "with_return"
? "With Return"
: "Without Return"
values.equipmentReturn === "with_return" ? "With Return" : "Without Return"
}
target={2}
/>
<Row
<CompactRow
label="Customs Clearing"
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
target={2}
/>
</ReviewCard>
</CompactCard>
<ReviewCard title="Route & Cargo">
<Row
label="Route"
value={`${values.originYard}${values.destinationYard}`}
target={3}
/>
<Row
label="Workflow"
value={
direction
? direction.charAt(0).toUpperCase() + direction.slice(1)
: ""
}
target={3}
/>
<Row
<CompactCard icon={<Package size={16} />} title="Cargo Details">
<CompactRow
label="Weight (VGM)"
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
target={4}
/>
<Row label="Cargo" value={cargoValue} target={4} />
<Row
<CompactRow label="Cargo Type" value={cargoValue} target={4} />
<CompactRow
label="Modifiers"
value={
[
values.isHazardous && "Hazardous",
values.isRefrigerated && "Refrigerated",
]
[values.isHazardous && "Hazardous", values.isRefrigerated && "Refrigerated"]
.filter(Boolean)
.join(", ") || "None"
}
target={3}
/>
</ReviewCard>
</CompactCard>
<ReviewCard title="Container & Wagons">
<Row label="Containers" value={containerSummary || "—"} target={4} />
<Row
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : ""}
<CompactCard icon={<Package size={16} />} title="Containers">
<CompactRow
label="Count & Type"
value={containerSummary || "—"}
target={4}
/>
</ReviewCard>
<ReviewCard title="Documents">
<Row
label="Attached"
value={
docsAttached > 0
? `${docsAttached} of ${docsTotal} attached`
: "None — upload later from the booking page"
}
target={5}
<CompactRow
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
target={4}
/>
</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>
{/* Notes */}
<Controller
name="notes"
control={form.control}
@@ -243,35 +356,13 @@ export function Step8Review({
{...field}
id="notes"
label="Additional Notes"
placeholder="Any special instructions or notes for EDR operations..."
rows={3}
placeholder="Any special instructions or notes for EDR operations"
rows={2}
radius="md"
size="sm"
/>
)}
/>
<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>
</Stack>
);
}