diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx deleted file mode 100644 index a6e34b7ab..000000000 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx +++ /dev/null @@ -1,1046 +0,0 @@ -import { - Box, - Grid, - Group, - SimpleGrid, - Skeleton, - Stack, - Text, -} from "@mantine/core"; -import { useQuery } from "@tanstack/react-query"; -import { format } from "date-fns"; -import { - ArrowRight, - CheckCircle2, - ChevronRight, - Clock3, - FileCheck2, - FilePen, - MapPin, - Truck, - Wallet, - Zap, - type LucideIcon, -} from "lucide-react"; -import { useMemo } from "react"; -import { Link, useNavigate } from "react-router-dom"; - -import useAuth from "@/hooks/useAuth"; -import { getMyInvoices } from "@/lib/currentCustomer"; -import type { Currency, InvoiceStatus } from "@/pages/billing/invoices.mock"; -import { formatCurrency } from "@/pages/billing/invoices.mock"; -import { api } from "@/services/api"; - -/** Resolve a Mantine color token ("edr-slate" or "edr-green.7") to its CSS var, - * for the few places that need a raw color string (lucide icons). */ -const cv = (token: string) => { - const [name, shade] = token.split("."); - return `var(--mantine-color-${name}-${shade ?? "6"})`; -}; - -/** Format a signed percentage for KPI deltas, e.g. 16 → "+16%", -4 → "-4%". */ -const formatPct = (n: number) => `${n >= 0 ? "+" : ""}${n}%`; - -const ACTIVE_STATUSES = [ - "DRAFT", - "SUBMITTED", - "PENDING_APPROVAL", - "IN_TRANSIT", -]; - -interface StageConfig { - stage: number; - icon: LucideIcon; - iconColor: string; // Mantine color token - tile: string; // Mantine bg token - hint: string; - step: string; // stepper color token - badgeLabel: string; - badgeBg: string; - badgeText: string; - badgeDot: string; - action: { - label: string; - kind: "dark" | "amber" | "outline"; - icon?: LucideIcon; - }; -} - -const STATUS_CONFIG: Record = { - 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" }, - }, -}; - -const ACTION_PROPS: Record = { - dark: { bg: "edr-ink", c: "white" }, - amber: { bg: "edr-accent", c: "white" }, - outline: { bg: "edr-card", c: "edr-text", bd: "1px solid edr-border" }, -}; - -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" }, -}; - -export default function MyPortalPage() { - const { user, customer } = useAuth(); - const myInvoices = useMemo(() => getMyInvoices(), []); - const navigate = useNavigate(); - - const bookingsQuery = useQuery( - api.bookings.list.queryOptions({ - input: { sortBy: "createdAt", sortOrder: "DESC" }, - }), - ); - - const dashboardQuery = useQuery(api.companies.getDashboard.queryOptions()); - const dashboard = dashboardQuery.data; - - 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 visibleBookings = allBookings; - 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 volumePoints = dashboard?.freightVolume.monthly ?? []; - const maxVolume = Math.max(1, ...volumePoints.map((p) => p.tonnes)); - - return ( - - {/* ── Hello Row ─────────────────────────────────────────────────────── */} - - - - {greeting} - - - {companyName} 👋 - - - - {/* Book a shipment CTA */} - - - - - - - Book a shipment - - - - - - - - - - {!customer && ( - - - - - Setup your Company Profile - - - Complete your company information to unlock all features and - start booking shipments. - - - - - Complete Setup - - - - - - - - - - - )} - - {/* ── Stats Strip ───────────────────────────────────────────────────── */} - - - - - - - - - - {/* ── Mid Row: Shipments + Invoices ─────────────────────────────────── */} - - - - - - - My Shipments - - - From draft to delivery — every booking in one place - - - - - {bookingsQuery.isPending ? ( - - {[1, 2, 3, 4].map((i) => ( - - ))} - - ) : visibleBookings.length === 0 ? ( - - ) : ( - - {visibleBookings.map((booking, i) => ( - navigate(`/bookings/${booking.id}`)} - /> - ))} - - )} - - - - {/* Invoices */} - - - - - Invoices - - - - - View all - - - - - - - {/* Outstanding card */} - - - Outstanding balance - - - {formatCurrency(totalOutstanding || 377500, "ETB")} - - - - {outstandingInvoices.length || 2} invoices unpaid - - - - - Pay all - - - - - - {/* Invoice list */} - {recentInvoices.length === 0 ? ( - - ) : ( - - {recentInvoices.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 ( - - {i > 0 && } - - - - - {invoice.number} - - - {invoice.bookingReference} - - - - {formatCurrency(invoice.amount, invoice.currency)} - - - - - - - {dueText} - - - - - {badge.label} - - - - - - ); - })} - - )} - - - - - {/* ── Bottom Row: Freight Volume + Recent Activity ──────────────────── */} - - - - - Freight Volume - - - {dashboardQuery.isPending ? ( - - ) : ( - <> - - {(dashboard?.freightVolume.totalTonnes ?? 0).toLocaleString()}{" "} - t - - - {formatCurrency( - dashboard?.freightVolume.totalValue ?? 0, - (dashboard?.freightVolume.currency ?? "ETB") as Currency, - )} - - - {formatPct(dashboard?.freightVolume.ytdChangePct ?? 0)} YTD - - - )} - - {dashboardQuery.isPending ? ( - - ) : volumePoints.length === 0 ? ( - - - No freight volume yet. - - - ) : ( - - {volumePoints.map((point, i) => { - const isLast = i === volumePoints.length - 1; - return ( - - - - {point.month} - - - ); - })} - - )} - - - - - - - - Recent Activity - - - - - View all - - - - - - - {bookingsQuery.isPending ? ( - - {[1, 2, 3, 4, 5].map((i) => ( - - ))} - - ) : allBookings.length === 0 ? ( - - ) : ( - - {allBookings.slice(0, 6).map((booking) => ( - navigate(`/bookings/${booking.id}`)} - /> - ))} - - )} - - - - - ); -} - -// ── Sub-components ───────────────────────────────────────────────────────────── - -function Card({ - children, - className = "", - padding = 24, -}: { - children: React.ReactNode; - className?: string; - padding?: number; -}) { - return ( - - {children} - - ); -} - -function StatKpi({ - icon: Icon, - label, - value, - delta, - deltaColor, - divider, -}: { - icon: LucideIcon; - label: string; - value: string; - delta: string; - deltaColor: string; - divider?: boolean; -}) { - return ( - - - - - {label} - - - - - {value} - - - {delta} - - - - ); -} - -function Stepper({ stage, color }: { stage: number; color: string }) { - return ( - - {[0, 1, 2, 3, 4].map((i) => { - const done = i < stage; - const active = i === stage; - const size = active ? 12 : done ? 9 : 8; - return ( - - - {i < 4 && ( - - )} - - ); - })} - - ); -} - -function BookingRow({ - booking, - last, - onClick, -}: { - booking: any; - last: boolean; - onClick: () => void; -}) { - 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 ( - - - - - - - - - {booking.reference} - - - {commodity} · {origin} → {dest} - - - - - - {cfg.hint} - - - - - - - - - {cfg.badgeLabel} - - - - - {cfg.action.label} - - {AIcon && ( - - )} - - - - - ); -} - -function ActivityRow({ - booking, - onClick, -}: { - booking: any; - onClick: () => void; -}) { - 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 ( - - - - - - - Booking {booking.reference} {verb} - - - {booking.originYard?.label ?? booking.originYard?.code ?? "—"} →{" "} - {booking.destinationYard?.label ?? - booking.destinationYard?.code ?? - "—"} - - - - {format(new Date(booking.createdAt), "MMM d")} - - - ); -} - -function EmptyState({ message }: { message: string }) { - return ( - - - {message} - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx new file mode 100644 index 000000000..b9551c321 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx @@ -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 ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx new file mode 100644 index 000000000..a65aa009e --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx @@ -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 ( + + + + + + + Booking {booking.reference} {verb} + + + {booking.originYard?.label ?? booking.originYard?.code ?? "—"} →{" "} + {booking.destinationYard?.label ?? + booking.destinationYard?.code ?? + "—"} + + + + {format(new Date(booking.createdAt), "MMM d")} + + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx new file mode 100644 index 000000000..27b923467 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx @@ -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 ( + + + + + + + + + {booking.reference} + + + {commodity} · {origin} → {dest} + + + + + + {cfg.hint} + + + + + + + + + {cfg.badgeLabel} + + + + + {cfg.action.label} + + {AIcon && ( + + )} + + + + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/Card.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/Card.tsx new file mode 100644 index 000000000..2f24d0a59 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/Card.tsx @@ -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 ( + + {children} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/EmptyState.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/EmptyState.tsx new file mode 100644 index 000000000..66f3f16a3 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/EmptyState.tsx @@ -0,0 +1,18 @@ +import { Box, Text } from "@mantine/core"; + +interface EmptyStateProps { + message: string; +} + +export function EmptyState({ message }: EmptyStateProps) { + return ( + + + {message} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/FreightVolumeSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/FreightVolumeSection.tsx new file mode 100644 index 000000000..27b74cb6f --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/FreightVolumeSection.tsx @@ -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 ( + + + Freight Volume + + + {isLoading ? ( + + ) : ( + <> + + {totalTonnes.toLocaleString()} t + + + {formatCurrency(totalValue, currency)} + + + {formatPct(ytdChangePct)} YTD + + + )} + + {isLoading ? ( + + ) : volumePoints.length === 0 ? ( + + + No freight volume yet. + + + ) : ( + + {volumePoints.map((point, i) => { + const isLast = i === volumePoints.length - 1; + return ( + + + + {point.month} + + + ); + })} + + )} + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/HelloSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/HelloSection.tsx new file mode 100644 index 000000000..9f1d726b0 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/HelloSection.tsx @@ -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 ( + + + + {greeting} + + + {companyName} 👋 + + + + + + + + + + Book a shipment + + + + + + + + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx new file mode 100644 index 000000000..fcdc14095 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx @@ -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 ( + + + + Invoices + + + + + View all + + + + + + + + + Outstanding balance + + + {formatCurrency(totalOutstanding || 377500, "ETB")} + + + + {outstandingInvoices.length || 2} invoices unpaid + + + + + Pay all + + + + + + {invoices.length === 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 ( + + {i > 0 && } + + + + + {invoice.number} + + + {invoice.bookingReference} + + + + {formatCurrency(invoice.amount, invoice.currency)} + + + + + + + {dueText} + + + + + {badge.label} + + + + + + ); + })} + + )} + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/RecentActivitySection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/RecentActivitySection.tsx new file mode 100644 index 000000000..44a8e7fa6 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/RecentActivitySection.tsx @@ -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 ( + + + + Recent Activity + + + + + View all + + + + + + + {isLoading ? ( + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + ) : bookings.length === 0 ? ( + + ) : ( + + {bookings.slice(0, 6).map((booking) => ( + onBookingClick(booking.id)} + /> + ))} + + )} + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/SetupPrompt.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/SetupPrompt.tsx new file mode 100644 index 000000000..cb29865e2 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/SetupPrompt.tsx @@ -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 ( + + + + + Setup your Company Profile + + + Complete your company information to unlock all features and start + booking shipments. + + + + + Complete Setup + + + + + + + + + + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ShipmentsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ShipmentsSection.tsx new file mode 100644 index 000000000..d4237914b --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ShipmentsSection.tsx @@ -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 ( + + + + + My Shipments + + + From draft to delivery — every booking in one place + + + + + {isLoading ? ( + + {[1, 2, 3, 4].map((i) => ( + + ))} + + ) : bookings.length === 0 ? ( + + ) : ( + + {bookings.map((booking, i) => ( + onBookingClick(booking.id)} + /> + ))} + + )} + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx new file mode 100644 index 000000000..413caafc4 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx @@ -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 ( + + + + + {label} + + + + + {value} + + + {delta} + + + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx new file mode 100644 index 000000000..006907eeb --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx @@ -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 ( + + + + + + + + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/Stepper.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/Stepper.tsx new file mode 100644 index 000000000..820ba6034 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/Stepper.tsx @@ -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 ( + + {[0, 1, 2, 3, 4].map((i) => { + const done = i < stage; + const active = i === stage; + const size = active ? 12 : done ? 9 : 8; + return ( + + + {i < 4 && ( + + )} + + ); + })} + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts new file mode 100644 index 000000000..de3ddb448 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts @@ -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"; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts new file mode 100644 index 000000000..c08b5e73a --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts @@ -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 = { + 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 = + { + 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" }, +}; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts new file mode 100644 index 000000000..fe2701840 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts @@ -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, + }; +} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/index.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/index.ts new file mode 100644 index 000000000..9b18dc3af --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/index.ts @@ -0,0 +1 @@ +export { default } from "./MyPortalPage"; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx index 95e4cdf78..2ba282fa2 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx @@ -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({
@@ -77,7 +82,7 @@ export function StatusHero({ > {chipLabel} - + {chipValue} @@ -114,8 +119,13 @@ function ProgressTracker({ return ( /* Scrollable on mobile so 5 stages never overflow */
{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 (
{/* 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 */}
{stage.label} - - {state === "done" - ? "Completed" - : state === "active" - ? negative - ? "Stopped" - : "In progress" - : "Pending"} -
); })} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx index ec6c10f02..1fec98a35 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx @@ -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({ )} - + {/* */} ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts index c6e19fb9b..6ff19a633 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts @@ -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", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx index edd83df15..b81cc455a 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx @@ -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; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index e992bdd20..6ccf34362 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -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({ 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(null); + const [priceBookingId, setPriceBookingId] = useState(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} > @@ -295,6 +391,24 @@ export default function NewBookingPage() { )} + {createAndPriceMutation.isError && ( + } + radius="md" + mb="lg" + > + + Failed to generate price estimate + + + {createAndPriceMutation.error instanceof Error + ? createAndPriceMutation.error.message + : "An unexpected error occurred. Please try again."} + + + )} + {step === 1 && ( )} @@ -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} /> )} @@ -366,24 +489,97 @@ export default function NewBookingPage() { > Continue - ) : ( + ) : pricingPhase === "idle" ? ( + + + {hasDocuments && ( + + )} + + ) : pricingPhase === "generating" ? ( - )} + ) : null} - {/* */} + + setCancelDialogOpen(false)} + title={Abort booking} + radius="lg" + centered + > + + + Are you sure you want to abort this booking? This action cannot be + undone. + + setCancelReason(e.currentTarget.value)} + radius="md" + data-autofocus + /> + + + + + + ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index 3d1a7a0bf..dbf943068 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -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 = { consolidationEnabled: false, documents: {}, notes: "", - termsAccepted: false, }; export const stepFields: Record>> = { @@ -265,7 +253,7 @@ export const stepFields: Record>> = { ], 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; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index e8d0310c4..e9d4a387e 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -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 = { - 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 = { - 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(() => { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx index 7d14dd28e..278698f83 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx @@ -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 ( -
-
- +
+
+ {label} - + {value || "—"}
@@ -62,6 +88,28 @@ export function Step8Review({ ); } + function CompactCard({ + icon: Icon, + title, + children, + }: { + icon: React.ReactNode; + title: string; + children: React.ReactNode; + }) { + return ( + + + {Icon} + + {title} + + + {children} + + ); + } + 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 ( - - - - {title} - - - - {children} - - - ); - } + 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 ( -
+ - - - + + + + Generating price estimate… + + + + )} + + {pricingPhase === "ready" && pricingData && ( + + + + 💳 Price Breakdown + + + {pricingData.lineItems.map((item) => ( + + + {item.description} + + + {item.amount.toLocaleString()} {item.currency} + + + ))} + + + + + Total + + + {pricingData.totalAmount.toLocaleString()} {pricingData.currency} + + + {pricingData.warnings.length > 0 && ( + + ⚠️ {pricingData.warnings.join(", ")} + + )} + + + + + + + + )} + + {/* Review Details - Compact Cards Grid */} + + } title="Contract & Service"> + - - + + - - } title="Route"> + + + + + } title="Logistics"> + - - - - + - - - - } title="Cargo Details"> + - - + - + - - - 0 ? `${totalVgm.toFixed(1)} tons` : ""} + } title="Containers"> + - - - - 0 - ? `${docsAttached} of ${docsTotal} attached` - : "None — upload later from the booking page" - } - target={5} + 0 ? `${totalVgm.toFixed(1)} tons` : "—"} + target={4} /> - + + + } title="Documents"> +
+
+ + Attached + + + {docsAttached > 0 + ? `${docsAttached} of ${docsTotal}` + : "None"} + +
+ +
+
+ {/* Notes */} )} /> - - ( - - I confirm the information is accurate and agree to EDR's{" "} - - freight contract terms and conditions - - . - - } - checked={field.value} - onChange={(e) => field.onChange(e.currentTarget.checked)} - error={fieldState.error?.message ?? errors.termsAccepted?.message} - color="edr-green" - radius="sm" - /> - )} - /> -
+ ); } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 397d240bf..c3b9319b3 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 750c9ec01..c1b29edac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)