mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
338
apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts
Normal file
338
apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts
Normal 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" },
|
||||||
|
};
|
||||||
74
apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts
Normal file
74
apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "./MyPortalPage";
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user