mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge pull request #193 from Tria-plc/freight/feat/portal-fixes
Freight/feat/portal fixes
This commit is contained in:
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;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const status = booking.status as string;
|
||||
const status = booking.status;
|
||||
const cfg = STATUS_MAP[status] ?? STATUS_MAP.DRAFT;
|
||||
const negative = isNegative(status);
|
||||
const draft = isDraftLike(status);
|
||||
@@ -45,7 +45,12 @@ export function StatusHero({
|
||||
<Group gap={16} align="center" wrap="nowrap">
|
||||
<div
|
||||
className="flex items-center justify-center rounded-2xl shrink-0"
|
||||
style={{ width: 56, height: 56, backgroundColor: tileBg, color: tileFg }}
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
backgroundColor: tileBg,
|
||||
color: tileFg,
|
||||
}}
|
||||
>
|
||||
<HeroIcon size={26} />
|
||||
</div>
|
||||
@@ -77,7 +82,7 @@ export function StatusHero({
|
||||
>
|
||||
{chipLabel}
|
||||
</Text>
|
||||
<Text fz="13.5px" fw={700} c="#10202F">
|
||||
<Text fz="sm" fw={700} c="#10202F">
|
||||
{chipValue}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -114,8 +119,13 @@ function ProgressTracker({
|
||||
return (
|
||||
/* Scrollable on mobile so 5 stages never overflow */
|
||||
<Box
|
||||
className="overflow-x-auto"
|
||||
style={{ scrollbarWidth: "none", WebkitOverflowScrolling: "touch" } as React.CSSProperties}
|
||||
className="overflow-x-auto pt-2"
|
||||
style={
|
||||
{
|
||||
scrollbarWidth: "none",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="flex items-start" style={{ minWidth: 440 }}>
|
||||
{PROGRESS_STAGES.map((stage, idx) => {
|
||||
@@ -131,14 +141,15 @@ function ProgressTracker({
|
||||
: state === "active"
|
||||
? activeFill
|
||||
: "#0EA371";
|
||||
const circleBorder = state === "idle" ? "1px solid #E1E7EE" : undefined;
|
||||
const circleBorder =
|
||||
state === "idle" ? "1px solid #E1E7EE" : undefined;
|
||||
const circleShadow =
|
||||
state === "active" ? `0 0 0 4px ${activeRing}` : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stage.label}
|
||||
className="flex flex-1 flex-col items-center gap-[10px]"
|
||||
className="flex flex-1 flex-col items-center"
|
||||
>
|
||||
<div className="flex w-full items-center">
|
||||
{/* left connector */}
|
||||
@@ -147,15 +158,19 @@ function ProgressTracker({
|
||||
style={{
|
||||
height: 3,
|
||||
background:
|
||||
idx === 0 ? "transparent" : reachedLeft ? "#0EA371" : "#E1E7EE",
|
||||
idx === 0
|
||||
? "transparent"
|
||||
: reachedLeft
|
||||
? "#0EA371"
|
||||
: "#E1E7EE",
|
||||
}}
|
||||
/>
|
||||
{/* stage circle */}
|
||||
<div
|
||||
className="flex items-center justify-center rounded-full shrink-0"
|
||||
className="flex items-center justify-center mb-2 rounded-full shrink-0"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
width: 32,
|
||||
height: 32,
|
||||
backgroundColor: circleBg,
|
||||
border: circleBorder,
|
||||
boxShadow: circleShadow,
|
||||
@@ -173,32 +188,22 @@ function ProgressTracker({
|
||||
style={{
|
||||
height: 3,
|
||||
background:
|
||||
idx === last ? "transparent" : reachedRight ? "#0EA371" : "#E1E7EE",
|
||||
idx === last
|
||||
? "transparent"
|
||||
: reachedRight
|
||||
? "#0EA371"
|
||||
: "#E1E7EE",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Text
|
||||
fz="13.5px"
|
||||
fw={state === "active" ? 800 : 700}
|
||||
fz="14px"
|
||||
fw={700}
|
||||
ta="center"
|
||||
c={state === "idle" ? "#9AA8B5" : "#10202F"}
|
||||
>
|
||||
{stage.label}
|
||||
</Text>
|
||||
<Text
|
||||
fz="11.5px"
|
||||
fw={state === "active" ? 700 : 500}
|
||||
ta="center"
|
||||
c={state === "active" ? activeSub : "#9AA8B5"}
|
||||
>
|
||||
{state === "done"
|
||||
? "Completed"
|
||||
: state === "active"
|
||||
? negative
|
||||
? "Stopped"
|
||||
: "In progress"
|
||||
: "Pending"}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { CheckCircle2, Clock, FileText } from "lucide-react";
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { CheckCircle2, Clock } from "lucide-react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
@@ -173,16 +173,16 @@ export function PaymentCard({
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
fullWidth
|
||||
mt={16}
|
||||
variant="default"
|
||||
radius={10}
|
||||
leftSection={<FileText size={17} color="#475569" />}
|
||||
styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" } }}
|
||||
>
|
||||
Download invoice
|
||||
</Button>
|
||||
{/* <Button */}
|
||||
{/* fullWidth */}
|
||||
{/* mt={16} */}
|
||||
{/* variant="default" */}
|
||||
{/* radius={10} */}
|
||||
{/* leftSection={<FileText size={17} color="#475569" />} */}
|
||||
{/* styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" } }} */}
|
||||
{/* > */}
|
||||
{/* Download invoice */}
|
||||
{/* </Button> */}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
FileText,
|
||||
PackageCheck,
|
||||
ShieldCheck,
|
||||
Ship,
|
||||
Train,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -15,32 +16,41 @@ export const PROGRESS_STAGES = [
|
||||
{
|
||||
label: "Submitted",
|
||||
icon: ClipboardCheck,
|
||||
statuses: ["SUBMITTED", "PENDING_APPROVAL"],
|
||||
statuses: ["SUBMITTED"],
|
||||
},
|
||||
{
|
||||
label: "Approved",
|
||||
label: "Approval",
|
||||
icon: ShieldCheck,
|
||||
statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE", "APPROVED"],
|
||||
},
|
||||
{
|
||||
label: "Contract",
|
||||
icon: ShieldCheck,
|
||||
statuses: ["CONTRACT_READY", "SIGNED_CUSTOMER"],
|
||||
},
|
||||
{
|
||||
label: "Payment",
|
||||
icon: ShieldCheck,
|
||||
statuses: [
|
||||
"APPROVED_PENDING_SIGNATURE",
|
||||
"APPROVED",
|
||||
"CONTRACT_READY",
|
||||
"SIGNED_CUSTOMER",
|
||||
"FULLY_EXECUTED",
|
||||
"SELECTED_FOR_BATCH",
|
||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Loading",
|
||||
icon: Ship,
|
||||
statuses: [
|
||||
"PAID",
|
||||
"PNR_GENERATED",
|
||||
"PENDING_CONSOLIDATION",
|
||||
"CONSOLIDATED",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "In Transit",
|
||||
icon: Train,
|
||||
statuses: [
|
||||
"SELECTED_FOR_BATCH",
|
||||
"EXPIRED",
|
||||
"PNR_GENERATED",
|
||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
"PAID",
|
||||
"IN_TRANSIT",
|
||||
"PENDING_CONSOLIDATION",
|
||||
"CONSOLIDATED",
|
||||
],
|
||||
statuses: ["EXPIRED", "IN_TRANSIT"],
|
||||
},
|
||||
{
|
||||
label: "Complete",
|
||||
@@ -72,7 +82,7 @@ export const STATUS_MAP: Record<
|
||||
PENDING_APPROVAL: {
|
||||
title: "Pending approval",
|
||||
description: "Your booking is moving through the approval process.",
|
||||
stage: 1,
|
||||
stage: 2,
|
||||
},
|
||||
APPROVED_PENDING_SIGNATURE: {
|
||||
title: "Approved — awaiting signature",
|
||||
@@ -88,71 +98,71 @@ export const STATUS_MAP: Record<
|
||||
title: "Contract ready to sign",
|
||||
description:
|
||||
"Your contract is ready. Review and apply your signature to proceed.",
|
||||
stage: 2,
|
||||
stage: 3,
|
||||
},
|
||||
SIGNED_CUSTOMER: {
|
||||
title: "Signed — awaiting staff",
|
||||
description:
|
||||
"Your signature has been submitted. Awaiting the final staff signature.",
|
||||
stage: 2,
|
||||
stage: 3,
|
||||
},
|
||||
FULLY_EXECUTED: {
|
||||
title: "Contract fully executed",
|
||||
description: "Signed by all parties. You can now proceed to payment.",
|
||||
stage: 2,
|
||||
stage: 4,
|
||||
},
|
||||
SELECTED_FOR_BATCH: {
|
||||
title: "Selected for a train — payment due",
|
||||
description:
|
||||
"Your booking was selected for a scheduled train. Complete payment within the pay window to secure your slot.",
|
||||
stage: 3,
|
||||
stage: 4,
|
||||
},
|
||||
EXPIRED: {
|
||||
title: "Pay window expired",
|
||||
description:
|
||||
"The payment window was missed. You can move this booking to another schedule or cancel it.",
|
||||
stage: 3,
|
||||
stage: 6,
|
||||
},
|
||||
PNR_GENERATED: {
|
||||
title: "Payment reference generated",
|
||||
description:
|
||||
"A payment reference number has been generated for this booking.",
|
||||
stage: 3,
|
||||
stage: 5,
|
||||
},
|
||||
PAYMENT_VERIFICATION_IN_PROGRESS: {
|
||||
title: "Verifying payment",
|
||||
description: "Your payment is being verified.",
|
||||
stage: 3,
|
||||
stage: 4,
|
||||
},
|
||||
PAID: {
|
||||
title: "Payment confirmed",
|
||||
description: "Payment has been confirmed for this booking.",
|
||||
stage: 3,
|
||||
stage: 5,
|
||||
},
|
||||
IN_TRANSIT: {
|
||||
title: "Cargo moving",
|
||||
description: "Your shipment is currently moving through the rail network.",
|
||||
stage: 3,
|
||||
stage: 6,
|
||||
},
|
||||
PENDING_CONSOLIDATION: {
|
||||
title: "Pending consolidation",
|
||||
description: "Awaiting a consolidation partner shipment.",
|
||||
stage: 3,
|
||||
stage: 5,
|
||||
},
|
||||
CONSOLIDATED: {
|
||||
title: "Consolidated",
|
||||
description: "Cargo has been consolidated with a partner shipment.",
|
||||
stage: 3,
|
||||
stage: 5,
|
||||
},
|
||||
COMPLETED: {
|
||||
title: "Service complete",
|
||||
description: "Cargo delivered and service successfully terminated.",
|
||||
stage: 4,
|
||||
stage: 7,
|
||||
},
|
||||
DELIVERED: {
|
||||
title: "Service complete",
|
||||
description: "Cargo delivered and service successfully terminated.",
|
||||
stage: 4,
|
||||
stage: 7,
|
||||
},
|
||||
REJECTED: {
|
||||
title: "Booking rejected",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useMemo, useRef, type ReactNode } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import type { CreateBookingPayload } from "@/services/bookings.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
@@ -34,12 +34,17 @@ import {
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { api } from "@/services/api";
|
||||
import type { CreateBookingPayload } from "@/services/bookings.service";
|
||||
import { useMemo, useRef, type ReactNode } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
CountChip,
|
||||
DocRow,
|
||||
IconSquare,
|
||||
} from "./BookingDetailPage/components/Documents";
|
||||
import {
|
||||
BookingFormInputValues,
|
||||
BOOKING_DOCS_SETTING,
|
||||
BookingFormInputValues,
|
||||
bookingFormSchema,
|
||||
getRouteDirection,
|
||||
initialBookingFormValues,
|
||||
@@ -48,11 +53,6 @@ import {
|
||||
} from "./new-booking-form/schema";
|
||||
import { SelectField } from "./new-booking-form/shared";
|
||||
import { Step5CargoDetails } from "./new-booking-form/steps";
|
||||
import {
|
||||
CountChip,
|
||||
DocRow,
|
||||
IconSquare,
|
||||
} from "./BookingDetailPage/components/Documents";
|
||||
|
||||
function yardNameFromBooking(
|
||||
yard: { label?: string; code?: string; name?: string } | undefined | null,
|
||||
@@ -117,8 +117,6 @@ function mapBookingToFormValues(
|
||||
shippingLine: (booking as any).shippingLine?.name ?? "",
|
||||
consolidationEnabled: booking.allowConsolidation ?? false,
|
||||
notes: "",
|
||||
// Terms were accepted at creation; editing shouldn't re-gate on them.
|
||||
termsAccepted: true,
|
||||
containers: [],
|
||||
} as BookingFormInputValues;
|
||||
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
import { api } from "@/services/api";
|
||||
import type { CreateBookingPayload } from "@/services/bookings.service";
|
||||
import type {
|
||||
CreateBookingPayload,
|
||||
GeneratePriceResponse,
|
||||
} from "@/services/bookings.service";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Alert, Box, Button, Group, Text, Title } from "@mantine/core";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, Check, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { AlertCircle, Check, ChevronLeft, ChevronRight, Send, XCircle } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import type { Freight } from "@/types";
|
||||
import {
|
||||
BookingFormInputValues,
|
||||
STEPS,
|
||||
@@ -97,6 +111,58 @@ export default function NewBookingPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const createAndPriceMutation = useMutation({
|
||||
mutationFn: async (payload: CreateBookingPayload) => {
|
||||
const booking = await api.bookings.create.call(payload);
|
||||
|
||||
const documents = form.getValues("documents") ?? {};
|
||||
const hasDocs = Object.values(documents).some((value) =>
|
||||
Array.isArray(value) ? value.length > 0 : Boolean(value),
|
||||
);
|
||||
if (hasDocs) {
|
||||
await api.bookings.uploadDocuments.call({
|
||||
id: booking.id,
|
||||
files: documents,
|
||||
});
|
||||
}
|
||||
|
||||
const pricing = await api.bookings.generatePrice.call({ id: booking.id });
|
||||
|
||||
return { bookingId: booking.id, pricing };
|
||||
},
|
||||
onSuccess: ({ bookingId, pricing }) => {
|
||||
setPriceBookingId(bookingId);
|
||||
setPricingData(pricing);
|
||||
setPricingPhase("ready");
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||
},
|
||||
onError: () => {
|
||||
setPricingPhase("idle");
|
||||
},
|
||||
});
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!priceBookingId) throw new Error("No booking to confirm");
|
||||
await api.bookings.submit.call({ id: priceBookingId });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||
navigate(`/bookings/${priceBookingId}`);
|
||||
},
|
||||
});
|
||||
|
||||
const abortMutation = useMutation({
|
||||
mutationFn: async (reason: string) => {
|
||||
if (!priceBookingId) throw new Error("No booking to abort");
|
||||
await api.bookings.cancel.call({ id: priceBookingId, reason });
|
||||
},
|
||||
onSuccess: () => {
|
||||
setCancelDialogOpen(false);
|
||||
navigate("/bookings");
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<BookingFormInputValues, any, BookingFormValues>({
|
||||
defaultValues: initialBookingFormValues,
|
||||
resolver: zodResolver(bookingFormSchema),
|
||||
@@ -116,6 +182,21 @@ export default function NewBookingPage() {
|
||||
return route;
|
||||
}, [originYard, destinationYard]);
|
||||
|
||||
const docValues = form.watch("documents") ?? {};
|
||||
const hasDocuments = useMemo(
|
||||
() =>
|
||||
Object.values(docValues).some((value) =>
|
||||
Array.isArray(value) ? value.length > 0 : Boolean(value),
|
||||
),
|
||||
[docValues],
|
||||
);
|
||||
|
||||
const [pricingPhase, setPricingPhase] = useState<"idle" | "generating" | "ready">("idle");
|
||||
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(null);
|
||||
const [priceBookingId, setPriceBookingId] = useState<string | null>(null);
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
|
||||
async function handleContinue() {
|
||||
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
|
||||
if (!valid) return;
|
||||
@@ -123,14 +204,14 @@ export default function NewBookingPage() {
|
||||
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
|
||||
}
|
||||
|
||||
const handleSubmit = form.handleSubmit((data) => {
|
||||
function buildApiPayload(data: BookingFormValues): CreateBookingPayload {
|
||||
if (data.contractType === "renewal" && !data.previousContractRef) {
|
||||
form.setError("previousContractRef", {
|
||||
type: "manual",
|
||||
message: "Select a previous contract reference.",
|
||||
});
|
||||
setStep(1);
|
||||
return;
|
||||
throw new Error("Validation failed");
|
||||
}
|
||||
|
||||
const totalWeight =
|
||||
@@ -141,7 +222,6 @@ export default function NewBookingPage() {
|
||||
)
|
||||
: Number(data.cargoWeight || 0);
|
||||
|
||||
// ── Reference data lookups ──────────────────────────────────────────
|
||||
const shippingLines = referenceData?.shipping_line ?? [];
|
||||
const cargoTree = referenceData?.cargo_type ?? [];
|
||||
const containerGroups = referenceData?.containers ?? [];
|
||||
@@ -174,8 +254,7 @@ export default function NewBookingPage() {
|
||||
(s) => s.id === data.serviceTypeId,
|
||||
)!;
|
||||
|
||||
// ── Build API payload ───────────────────────────────────────────────
|
||||
const apiPayload: CreateBookingPayload = {
|
||||
return {
|
||||
scheduledDate: new Date().toISOString(),
|
||||
contractType:
|
||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||
@@ -222,8 +301,25 @@ export default function NewBookingPage() {
|
||||
: {}),
|
||||
...(cargoFreeText ? { cargoFreeText } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
createMutation.mutate(apiPayload);
|
||||
const handleDraftSubmit = form.handleSubmit((data) => {
|
||||
try {
|
||||
const apiPayload = buildApiPayload(data);
|
||||
createMutation.mutate(apiPayload);
|
||||
} catch {
|
||||
// validation error already handled
|
||||
}
|
||||
});
|
||||
|
||||
const handleGeneratePrice = form.handleSubmit((data) => {
|
||||
try {
|
||||
const apiPayload = buildApiPayload(data);
|
||||
setPricingPhase("generating");
|
||||
createAndPriceMutation.mutate(apiPayload);
|
||||
} catch {
|
||||
// validation error already handled
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -270,7 +366,7 @@ export default function NewBookingPage() {
|
||||
id="new-booking-form"
|
||||
className="flex flex-col"
|
||||
style={{ flex: 1 }}
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={handleDraftSubmit}
|
||||
>
|
||||
<Box flex={1} p="24px">
|
||||
<Box mb="lg">
|
||||
@@ -295,6 +391,24 @@ export default function NewBookingPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{createAndPriceMutation.isError && (
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<AlertCircle size={16} />}
|
||||
radius="md"
|
||||
mb="lg"
|
||||
>
|
||||
<Text size="sm" fw={600}>
|
||||
Failed to generate price estimate
|
||||
</Text>
|
||||
<Text size="sm" mt={4} c="red.7">
|
||||
{createAndPriceMutation.error instanceof Error
|
||||
? createAndPriceMutation.error.message
|
||||
: "An unexpected error occurred. Please try again."}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<Step1ContractType form={form} referenceData={referenceData} />
|
||||
)}
|
||||
@@ -326,6 +440,15 @@ export default function NewBookingPage() {
|
||||
setStep={setStep}
|
||||
direction={direction!}
|
||||
referenceData={referenceData}
|
||||
pricingPhase={pricingPhase}
|
||||
pricingData={pricingData}
|
||||
onConfirm={() => confirmMutation.mutate()}
|
||||
onContinueLater={
|
||||
priceBookingId ? () => navigate(`/bookings/${priceBookingId}`) : undefined
|
||||
}
|
||||
onAbort={() => setCancelDialogOpen(true)}
|
||||
confirmPending={confirmMutation.isPending}
|
||||
abortPending={abortMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
@@ -366,24 +489,97 @@ export default function NewBookingPage() {
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
) : (
|
||||
) : pricingPhase === "idle" ? (
|
||||
<Group>
|
||||
<Button
|
||||
type="submit"
|
||||
form="new-booking-form"
|
||||
variant={hasDocuments ? "outline" : "filled"}
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={createMutation.isPending}
|
||||
leftSection={
|
||||
createMutation.isPending ? undefined : <Check size={16} />
|
||||
}
|
||||
>
|
||||
{createMutation.isPending ? "Saving Draft..." : "Save as Draft"}
|
||||
</Button>
|
||||
{hasDocuments && (
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={createAndPriceMutation.isPending}
|
||||
leftSection={
|
||||
createAndPriceMutation.isPending ? undefined : <Send size={16} />
|
||||
}
|
||||
onClick={() => handleGeneratePrice()}
|
||||
>
|
||||
{createAndPriceMutation.isPending ? "Generating price…" : "Submit"}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
) : pricingPhase === "generating" ? (
|
||||
<Button
|
||||
type="submit"
|
||||
form="new-booking-form"
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={createMutation.isPending}
|
||||
leftSection={
|
||||
createMutation.isPending ? undefined : <Check size={16} />
|
||||
}
|
||||
loading
|
||||
>
|
||||
{createMutation.isPending ? "Saving Draft..." : "Save as Draft"}
|
||||
Generating price estimate…
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</Group>
|
||||
</Box>
|
||||
</form>
|
||||
{/* <DevTool control={form.control} /> */}
|
||||
|
||||
<Modal
|
||||
opened={cancelDialogOpen}
|
||||
onClose={() => setCancelDialogOpen(false)}
|
||||
title={<Text fw={700}>Abort booking</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Are you sure you want to abort this booking? This action cannot be
|
||||
undone.
|
||||
</Text>
|
||||
<TextInput
|
||||
label="Reason for cancellation (optional)"
|
||||
placeholder="e.g. Change of plans…"
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.currentTarget.value)}
|
||||
radius="md"
|
||||
data-autofocus
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => setCancelDialogOpen(false)}
|
||||
>
|
||||
Keep editing
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
radius="md"
|
||||
onClick={() =>
|
||||
abortMutation.mutate(
|
||||
cancelReason.trim() || "Aborted by customer",
|
||||
)
|
||||
}
|
||||
disabled={abortMutation.isPending}
|
||||
loading={abortMutation.isPending}
|
||||
leftSection={
|
||||
!abortMutation.isPending ? <XCircle size={15} /> : undefined
|
||||
}
|
||||
>
|
||||
Yes, abort
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,7 +121,6 @@ export const bookingFormSchema = z
|
||||
consolidationEnabled: z.boolean(),
|
||||
documents: z.record(z.string(), z.any()).default({}),
|
||||
notes: z.string(),
|
||||
termsAccepted: z.boolean(),
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
@@ -165,23 +164,13 @@ export const bookingFormSchema = z
|
||||
(data) => !(data.cargoType === "container" && data.containers.length === 0),
|
||||
{ message: "Add at least one container.", path: ["containers"] },
|
||||
)
|
||||
.refine((data) => data.termsAccepted, {
|
||||
message: "Accept the freight contract terms to submit.",
|
||||
path: ["termsAccepted"],
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.cargoType === "bulk") {
|
||||
if (!data.cargoTypePath[0]) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["cargoTypePath"],
|
||||
message: "Select a freight type.",
|
||||
});
|
||||
} else if (!data.cargoTypePath[1]) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["cargoTypePath"],
|
||||
message: "Select a commodity.",
|
||||
message: "Select a Cargo type.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -237,7 +226,6 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
||||
consolidationEnabled: false,
|
||||
documents: {},
|
||||
notes: "",
|
||||
termsAccepted: false,
|
||||
};
|
||||
|
||||
export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
||||
@@ -265,7 +253,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
||||
],
|
||||
5: ["scheduledDate", "trainScheduleId"],
|
||||
6: ["documents"],
|
||||
7: ["notes", "termsAccepted"],
|
||||
7: ["notes"],
|
||||
};
|
||||
|
||||
export interface ContainerConfig {
|
||||
@@ -288,10 +276,10 @@ export function getRouteDirection(
|
||||
return "DOMESTIC";
|
||||
}
|
||||
if (origin.country === "Ethiopia" && dest.country === "Djibouti") {
|
||||
return "IMPORT";
|
||||
return "EXPORT";
|
||||
}
|
||||
if (origin.country === "Djibouti" && dest.country === "Ethiopia") {
|
||||
return "EXPORT";
|
||||
return "IMPORT";
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import { Divider, Skeleton, Stack, Switch } from "@mantine/core";
|
||||
import { Flame, MapPin, Snowflake } from "lucide-react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { Flame, MapPin, Snowflake } from "lucide-react";
|
||||
import { Divider, Skeleton, Stack, Switch } from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
BookingFormInputValues,
|
||||
type BookingFormValues,
|
||||
@@ -56,25 +56,24 @@ export function Step4Route({
|
||||
return true;
|
||||
});
|
||||
}, [yardOptions, destinationYard]);
|
||||
console.log({ yardOptions, originYard, destinationYard });
|
||||
const destData = useMemo(() => {
|
||||
return yardOptions.filter((o) => o.value !== originYard);
|
||||
}, [yardOptions, originYard]);
|
||||
|
||||
const direction = getRouteDirection(
|
||||
referenceData?.yard.find((y) => y.id === originYard),
|
||||
referenceData?.yard.find((y) => y.name === destinationYard),
|
||||
);
|
||||
const origin = referenceData?.yard.find((y) => y.id === originYard);
|
||||
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
|
||||
const direction = getRouteDirection(origin, dest);
|
||||
console.log({ yardOptions, originYard, destinationYard, direction, origin, dest });
|
||||
|
||||
const directionStyle: Record<string, string> = {
|
||||
export: "bg-sky-50 text-sky-800 border-sky-200",
|
||||
import: "bg-amber-50 text-amber-800 border-amber-200",
|
||||
domestic: "bg-gray-100 text-gray-600 border-gray-200",
|
||||
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
|
||||
IMPORT: "bg-amber-50 text-amber-800 border-amber-200",
|
||||
DOMESTIC: "bg-gray-100 text-gray-600 border-gray-200",
|
||||
};
|
||||
const directionLabel: Record<string, string> = {
|
||||
export: "Export workflow (inside country to outside country)",
|
||||
import: "Import workflow (outside country to inside country)",
|
||||
domestic: "Domestic corridor",
|
||||
EXPORT: "Export workflow (inside country to outside country)",
|
||||
IMPORT: "Import workflow (outside country to inside country)",
|
||||
DOMESTIC: "Domestic corridor",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { Box, Card, Checkbox, SimpleGrid, Text, Textarea } from "@mantine/core";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { Check, Send, XCircle, FileText, Route, Package, Truck } from "lucide-react";
|
||||
import {
|
||||
BookingFormInputValues,
|
||||
BOOKING_DOCS_SETTING,
|
||||
@@ -8,6 +20,7 @@ import {
|
||||
} from "./schema";
|
||||
import { StepHeader } from "./shared";
|
||||
import type { Freight } from "@/types";
|
||||
import type { GeneratePriceResponse } from "@/services/bookings.service";
|
||||
|
||||
type BookingForm = UseFormReturn<
|
||||
BookingFormInputValues,
|
||||
@@ -20,19 +33,32 @@ export function Step8Review({
|
||||
setStep,
|
||||
direction,
|
||||
referenceData,
|
||||
pricingPhase = "idle",
|
||||
pricingData,
|
||||
onConfirm,
|
||||
onContinueLater,
|
||||
onAbort,
|
||||
confirmPending = false,
|
||||
abortPending = false,
|
||||
}: {
|
||||
form: BookingForm;
|
||||
setStep: (step: number) => void;
|
||||
direction: Freight.ScheduleTradeDirection;
|
||||
referenceData?: Freight.BookingReferenceData;
|
||||
pricingPhase?: "idle" | "generating" | "ready";
|
||||
pricingData?: GeneratePriceResponse | null;
|
||||
onConfirm?: () => void;
|
||||
onContinueLater?: () => void;
|
||||
onAbort?: () => void;
|
||||
confirmPending?: boolean;
|
||||
abortPending?: boolean;
|
||||
}) {
|
||||
const values = form.watch();
|
||||
const errors = form.formState.errors;
|
||||
const serviceType = referenceData?.service.find(
|
||||
(s) => s.id === values.serviceTypeId,
|
||||
);
|
||||
|
||||
function Row({
|
||||
function CompactRow({
|
||||
label,
|
||||
value,
|
||||
target,
|
||||
@@ -42,19 +68,19 @@ export function Step8Review({
|
||||
target: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 py-2">
|
||||
<div className="min-w-0">
|
||||
<Text size="xs" c="dimmed">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Text size="10px" c="dimmed" fw={600} tt="uppercase" className="mb-1 tracking-wider">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={500} mt={2} className="truncate">
|
||||
<Text size="sm" fw={500} className="truncate">
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(target)}
|
||||
className="shrink-0 text-xs font-medium text-emerald-600 hover:underline"
|
||||
className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
@@ -62,6 +88,28 @@ export function Step8Review({
|
||||
);
|
||||
}
|
||||
|
||||
function CompactCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card radius="md" p="sm" withBorder className="border-gray-200 bg-white hover:shadow-sm transition-shadow">
|
||||
<Group gap="xs" mb="xs" wrap="nowrap">
|
||||
<Box c="edr-green">{Icon}</Box>
|
||||
<Text size="xs" fw={700} tt="uppercase" c="dimmed" className="tracking-wider">
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap="xs">{children}</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const containerSummary =
|
||||
values.cargoType === "container" && values.containers.length > 0
|
||||
? values.containers
|
||||
@@ -95,146 +143,211 @@ export function Step8Review({
|
||||
return child ? `${group.name} — ${child.name}` : group.name;
|
||||
})();
|
||||
|
||||
function ReviewCard({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card radius="lg" withBorder p={0} className="overflow-hidden">
|
||||
<Box
|
||||
px="md"
|
||||
py="sm"
|
||||
className="border-b border-[var(--mantine-color-gray-2)] bg-gray-50/60"
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
tt="uppercase"
|
||||
c="dimmed"
|
||||
className="tracking-wider"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box px="md" py="xs" className="divide-y divide-gray-100">
|
||||
{children}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
const originYardName = referenceData?.yard.find(
|
||||
(y) => y.id === values.originYard,
|
||||
)?.name ?? values.originYard;
|
||||
|
||||
const destinationYardName = referenceData?.yard.find(
|
||||
(y) => y.id === values.destinationYard,
|
||||
)?.name ?? values.destinationYard;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Stack gap="md">
|
||||
<StepHeader
|
||||
title="Review & Submit"
|
||||
description="Confirm your contract request before sending it for EDR staff review."
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
|
||||
<ReviewCard title="Contract & Service">
|
||||
<Row
|
||||
{/* Pricing Card - Prominent at top */}
|
||||
{pricingPhase === "generating" && (
|
||||
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2">
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Generating price estimate…
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{pricingPhase === "ready" && pricingData && (
|
||||
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2 bg-gradient-to-br from-white to-emerald-50/30">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={700} tt="uppercase" c="edr-green" className="tracking-wider">
|
||||
💳 Price Breakdown
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
{pricingData.lineItems.map((item) => (
|
||||
<Group key={item.code} justify="space-between" py={2}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{item.description}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{item.amount.toLocaleString()} {item.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
<Divider my="xs" />
|
||||
<Group justify="space-between" py={2}>
|
||||
<Text fw={700} size="md">
|
||||
Total
|
||||
</Text>
|
||||
<Text fw={800} size="lg" c="edr-green">
|
||||
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
{pricingData.warnings.length > 0 && (
|
||||
<Text size="xs" c="orange.7" mt="xs" p="xs" className="bg-orange-50 rounded">
|
||||
⚠️ {pricingData.warnings.join(", ")}
|
||||
</Text>
|
||||
)}
|
||||
<Group mt="md">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Check size={16} />}
|
||||
onClick={onConfirm}
|
||||
loading={confirmPending}
|
||||
className="flex-1"
|
||||
>
|
||||
{confirmPending ? "Confirming…" : "Confirm"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Send size={16} />}
|
||||
onClick={onContinueLater}
|
||||
className="flex-1"
|
||||
>
|
||||
Continue later
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={!abortPending ? <XCircle size={16} /> : undefined}
|
||||
onClick={onAbort}
|
||||
loading={abortPending}
|
||||
>
|
||||
Abort
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Review Details - Compact Cards Grid */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="sm" mt="md">
|
||||
<CompactCard icon={<Package size={16} />} title="Contract & Service">
|
||||
<CompactRow
|
||||
label="Type"
|
||||
value={values.contractType === "new" ? "New Contract" : "Renewal"}
|
||||
target={1}
|
||||
/>
|
||||
<Row label="Service" value={serviceType?.name ?? ""} target={2} />
|
||||
</ReviewCard>
|
||||
<CompactRow label="Service" value={serviceType?.name ?? ""} target={2} />
|
||||
</CompactCard>
|
||||
|
||||
<ReviewCard title="First & Last Mile">
|
||||
<Row
|
||||
<CompactCard icon={<Route size={16} />} title="Route">
|
||||
<CompactRow
|
||||
label="Origin → Destination"
|
||||
value={`${originYardName} → ${destinationYardName}`}
|
||||
target={3}
|
||||
/>
|
||||
<CompactRow
|
||||
label="Workflow"
|
||||
value={
|
||||
direction ? direction.charAt(0).toUpperCase() + direction.slice(1) : ""
|
||||
}
|
||||
target={3}
|
||||
/>
|
||||
</CompactCard>
|
||||
|
||||
<CompactCard icon={<Truck size={16} />} title="Logistics">
|
||||
<CompactRow
|
||||
label="First Mile"
|
||||
value={
|
||||
values.firstMile.enabled
|
||||
? values.firstMile.pickUpAddress
|
||||
: "Not requested"
|
||||
values.firstMile.enabled ? values.firstMile.pickUpAddress : "Not requested"
|
||||
}
|
||||
target={2}
|
||||
/>
|
||||
<Row
|
||||
<CompactRow
|
||||
label="Last Mile"
|
||||
value={
|
||||
values.lastMile.enabled
|
||||
? values.lastMile.deliveryAddress
|
||||
: "Not requested"
|
||||
values.lastMile.enabled ? values.lastMile.deliveryAddress : "Not requested"
|
||||
}
|
||||
target={2}
|
||||
/>
|
||||
<Row
|
||||
<CompactRow
|
||||
label="Equipment Return"
|
||||
value={
|
||||
values.equipmentReturn === "with_return"
|
||||
? "With Return"
|
||||
: "Without Return"
|
||||
values.equipmentReturn === "with_return" ? "With Return" : "Without Return"
|
||||
}
|
||||
target={2}
|
||||
/>
|
||||
<Row
|
||||
<CompactRow
|
||||
label="Customs Clearing"
|
||||
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
|
||||
target={2}
|
||||
/>
|
||||
</ReviewCard>
|
||||
</CompactCard>
|
||||
|
||||
<ReviewCard title="Route & Cargo">
|
||||
<Row
|
||||
label="Route"
|
||||
value={`${values.originYard} → ${values.destinationYard}`}
|
||||
target={3}
|
||||
/>
|
||||
<Row
|
||||
label="Workflow"
|
||||
value={
|
||||
direction
|
||||
? direction.charAt(0).toUpperCase() + direction.slice(1)
|
||||
: ""
|
||||
}
|
||||
target={3}
|
||||
/>
|
||||
<Row
|
||||
<CompactCard icon={<Package size={16} />} title="Cargo Details">
|
||||
<CompactRow
|
||||
label="Weight (VGM)"
|
||||
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
|
||||
target={4}
|
||||
/>
|
||||
<Row label="Cargo" value={cargoValue} target={4} />
|
||||
<Row
|
||||
<CompactRow label="Cargo Type" value={cargoValue} target={4} />
|
||||
<CompactRow
|
||||
label="Modifiers"
|
||||
value={
|
||||
[
|
||||
values.isHazardous && "Hazardous",
|
||||
values.isRefrigerated && "Refrigerated",
|
||||
]
|
||||
[values.isHazardous && "Hazardous", values.isRefrigerated && "Refrigerated"]
|
||||
.filter(Boolean)
|
||||
.join(", ") || "None"
|
||||
}
|
||||
target={3}
|
||||
/>
|
||||
</ReviewCard>
|
||||
</CompactCard>
|
||||
|
||||
<ReviewCard title="Container & Wagons">
|
||||
<Row label="Containers" value={containerSummary || "—"} target={4} />
|
||||
<Row
|
||||
label="Total VGM"
|
||||
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : ""}
|
||||
<CompactCard icon={<Package size={16} />} title="Containers">
|
||||
<CompactRow
|
||||
label="Count & Type"
|
||||
value={containerSummary || "—"}
|
||||
target={4}
|
||||
/>
|
||||
</ReviewCard>
|
||||
|
||||
<ReviewCard title="Documents">
|
||||
<Row
|
||||
label="Attached"
|
||||
value={
|
||||
docsAttached > 0
|
||||
? `${docsAttached} of ${docsTotal} attached`
|
||||
: "None — upload later from the booking page"
|
||||
}
|
||||
target={5}
|
||||
<CompactRow
|
||||
label="Total VGM"
|
||||
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
|
||||
target={4}
|
||||
/>
|
||||
</ReviewCard>
|
||||
</CompactCard>
|
||||
|
||||
<CompactCard icon={<FileText size={16} />} title="Documents">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Text size="10px" c="dimmed" fw={600} tt="uppercase" className="mb-1 tracking-wider">
|
||||
Attached
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{docsAttached > 0
|
||||
? `${docsAttached} of ${docsTotal}`
|
||||
: "None"}
|
||||
</Text>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(5)}
|
||||
className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</CompactCard>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Notes */}
|
||||
<Controller
|
||||
name="notes"
|
||||
control={form.control}
|
||||
@@ -243,35 +356,13 @@ export function Step8Review({
|
||||
{...field}
|
||||
id="notes"
|
||||
label="Additional Notes"
|
||||
placeholder="Any special instructions or notes for EDR operations..."
|
||||
rows={3}
|
||||
placeholder="Any special instructions or notes for EDR operations…"
|
||||
rows={2}
|
||||
radius="md"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="termsAccepted"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Checkbox
|
||||
label={
|
||||
<Text size="sm" c="dimmed">
|
||||
I confirm the information is accurate and agree to EDR's{" "}
|
||||
<Text component="span" c="edr-green" fw={500}>
|
||||
freight contract terms and conditions
|
||||
</Text>
|
||||
.
|
||||
</Text>
|
||||
}
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.currentTarget.checked)}
|
||||
error={fieldState.error?.message ?? errors.termsAccepted?.message}
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,5 +2,15 @@ export * from "./common/index";
|
||||
export * from "./freight/index";
|
||||
export * as Freight from "./freight/index";
|
||||
export * as Passenger from "./passenger/index";
|
||||
export type { PaymentEvent, PaymentEventType, PaymentFailedEvent, PaymentSucceededEvent } from "./common/payments";
|
||||
export { PaymentIntentSnapshot, InitiatePaymentRequest, PaymentReferenceType, PaymentService } from "./common/payments";
|
||||
export type {
|
||||
PaymentEvent,
|
||||
PaymentEventType,
|
||||
PaymentFailedEvent,
|
||||
PaymentSucceededEvent,
|
||||
} from "./common/payments";
|
||||
export {
|
||||
type PaymentIntentSnapshot,
|
||||
type InitiatePaymentRequest,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
} from "./common/payments";
|
||||
|
||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@@ -436,7 +436,7 @@ importers:
|
||||
version: 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||
'@nestjs/microservices':
|
||||
specifier: ^11.1.24
|
||||
version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/passport':
|
||||
specifier: ^10.0.3
|
||||
version: 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
||||
|
||||
Reference in New Issue
Block a user