From db1f0cc5d7d6d5edeed31e1c198c5211dfce689f Mon Sep 17 00:00:00 2001 From: yaschalew Date: Mon, 18 May 2026 11:25:33 +0300 Subject: [PATCH 1/3] add customer portal --- apps/edr-freight-web/portal/src/App.tsx | 4 + .../portal/src/lib/currentCustomer.ts | 45 ++ .../src/pages/dashboard/DashboardPage.tsx | 2 +- .../portal/src/pages/portal/MyPortalPage.tsx | 480 ++++++++++++++++++ .../src/pages/tracking/NewShipmentPage.tsx | 29 +- .../src/pages/tracking/TrackingPage.tsx | 3 +- .../src/pages/tracking/shipments.mock.ts | 20 +- .../src/components/Layout/DashboardLayout.tsx | 4 +- .../src/components/Layout/Sidebar.tsx | 2 +- 9 files changed, 563 insertions(+), 26 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/lib/currentCustomer.ts create mode 100644 apps/edr-freight-web/portal/src/pages/portal/MyPortalPage.tsx diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index f774eacb9..4e39c95b1 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -16,6 +16,7 @@ import { Receipt, FileText, Settings, + UserCircle, } from "lucide-react"; import BookingsPage from "./pages/bookings/BookingsPage"; @@ -33,8 +34,10 @@ import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import NewCustomerPage from "./pages/customers/NewCustomerPage"; import DocumentsPage from "./pages/documents/DocumentsPage"; import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage"; +import MyPortalPage from "./pages/portal/MyPortalPage"; const sidebarItems: SidebarItem[] = [ + { label: "My Portal", href: "/portal", icon: }, { label: "Dashboard", href: "/", icon: }, { label: "Customers", href: "/customers", icon: }, { label: "Bookings", href: "/bookings", icon: }, @@ -69,6 +72,7 @@ const App = () => { > } /> + } /> } /> } /> } /> diff --git a/apps/edr-freight-web/portal/src/lib/currentCustomer.ts b/apps/edr-freight-web/portal/src/lib/currentCustomer.ts new file mode 100644 index 000000000..0a68cd4f5 --- /dev/null +++ b/apps/edr-freight-web/portal/src/lib/currentCustomer.ts @@ -0,0 +1,45 @@ +import { customers, type Customer } from "@/pages/customers/customers.mock"; +import { bookings, type Booking } from "@/pages/bookings/bookings.mock"; +import { + consignments, + type Consignment, +} from "@/pages/consignments/consignments.mock"; +import { + shipments, + type Shipment, +} from "@/pages/tracking/shipments.mock"; +import { invoices, type Invoice } from "@/pages/billing/invoices.mock"; + +/** + * Mock "logged-in customer". When auth integrates, replace this with the value + * pulled from `@edr/iamui-common` / the JWT context. + */ +const CURRENT_CUSTOMER_ID = 1; + +export function getCurrentCustomer(): Customer { + return ( + customers.find((c) => c.id === CURRENT_CUSTOMER_ID) ?? + (customers[0] as Customer) + ); +} + +export function getMyBookings(): Booking[] { + const me = getCurrentCustomer(); + return bookings.filter((b) => b.customerId === me.id); +} + +export function getMyConsignments(): Consignment[] { + const me = getCurrentCustomer(); + const myBookingIds = new Set(getMyBookings().map((b) => b.id)); + return consignments.filter((c) => myBookingIds.has(c.bookingId)); +} + +export function getMyShipments(): Shipment[] { + const myBookingIds = new Set(getMyBookings().map((b) => b.id)); + return shipments.filter((s) => myBookingIds.has(s.bookingId)); +} + +export function getMyInvoices(): Invoice[] { + const me = getCurrentCustomer(); + return invoices.filter((inv) => inv.customerId === me.id); +} diff --git a/apps/edr-freight-web/portal/src/pages/dashboard/DashboardPage.tsx b/apps/edr-freight-web/portal/src/pages/dashboard/DashboardPage.tsx index 645b990bc..8d7f029b2 100644 --- a/apps/edr-freight-web/portal/src/pages/dashboard/DashboardPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/dashboard/DashboardPage.tsx @@ -474,7 +474,7 @@ export default function DashboardPage() { - + getCurrentCustomer(), []); + const myBookings = useMemo(() => getMyBookings(), []); + const myShipments = useMemo(() => getMyShipments(), []); + const myInvoices = useMemo(() => getMyInvoices(), []); + + const activeBookings = myBookings.filter( + (b) => b.status === "Confirmed" || b.status === "In Transit", + ); + const activeShipments = myShipments.filter((s) => s.status === "In Transit"); + const outstandingInvoices = myInvoices.filter( + (inv) => inv.status === "Sent" || inv.status === "Overdue", + ); + const totalOutstanding = outstandingInvoices + .filter((inv) => inv.currency === "USD") + .reduce((sum, inv) => sum + inv.amount, 0); + const totalSpent = myInvoices + .filter((inv) => inv.status === "Paid" && inv.currency === "USD") + .reduce((sum, inv) => sum + inv.amount, 0); + + const recentBookings = [...myBookings].slice(0, 5); + const recentInvoices = [...myInvoices].slice(0, 4); + + return ( +
+
+ + + {/* Welcome banner */} +
+
+
+
+ {me.company.charAt(0)} +
+
+

Welcome back

+

+ {me.name} +

+

+ + {me.company} + · + + {me.customerType} + +

+
+
+ +
+ + + + + + Track Shipment + +
+
+
+ + {/* My KPIs */} +
+ } + href="/bookings" + /> + } + href="/tracking" + /> + } + href="/billing" + tone={outstandingInvoices.some((i) => i.status === "Overdue") ? "danger" : "brand"} + /> + } + /> +
+ + {/* Active Shipments */} +
+
+
+

+ Active Shipments +

+

+ Live tracking for your in-flight cargo +

+
+ + View all + + +
+ + {activeShipments.length === 0 ? ( +

+ No shipments currently in transit. +

+ ) : ( +
+ {activeShipments.slice(0, 4).map((shipment) => ( +
+
+ + {shipment.reference} + + +
+

+ {shipment.originStation} + + {shipment.destinationStation} +

+
+ + + {shipment.currentLocation} + + ETA {shipment.eta} +
+
+
+
+
+ ))} +
+ )} +
+ + {/* Recent Bookings + Invoices + Profile */} +
+ {/* Recent bookings */} +
+
+
+

+ Recent Bookings +

+

+ Your latest freight requests +

+
+ + View all + + +
+ + {recentBookings.length === 0 ? ( +

+ You haven't booked any freight yet. +

+ ) : ( +
+ + + + + + + + + + + + {recentBookings.map((booking) => ( + + + + + + + + ))} + +
ReferenceRouteCargoStatusAction
+ {booking.reference} + + {booking.originStation} → {booking.destinationStation} + + {booking.cargoType} + + + + + + +
+
+ )} +
+ + {/* Profile card */} +
+

+ My Profile +

+

Account information

+ +
+ } + label="Company" + value={me.company} + /> + } + label="Email" + value={me.email} + /> + } + label="Phone" + value={me.phone} + /> + } + label="Location" + value={`${me.city}, ${me.country}`} + /> +
+ + + View full profile + + +
+
+ + {/* Invoices */} +
+
+
+

+ Recent Invoices +

+

+ {outstandingInvoices.length} outstanding ·{" "} + {myInvoices.length} total +

+
+ + View all + + +
+ + {recentInvoices.length === 0 ? ( +

+ No invoices yet. +

+ ) : ( +
+ {recentInvoices.map((invoice) => ( +
+
+ + +
+

+ {invoice.number} +

+

+ {formatCurrency(invoice.amount, invoice.currency)} +

+

+ + Due {invoice.dueDate} +

+
+ ))} +
+ )} +
+
+
+ ); +} + +function KpiCard({ + label, + value, + sub, + icon, + href, + tone = "brand", +}: { + label: string; + value: string; + sub: string; + icon: React.ReactNode; + href?: string; + tone?: "brand" | "danger"; +}) { + const iconWrap = + tone === "danger" + ? "bg-red-100 text-red-600" + : "bg-[#33578D]/10 text-[#33578D]"; + + const inner = ( +
+
+

{label}

+

{value}

+

{sub}

+
+
+ {icon} +
+
+ ); + + const className = + "rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20"; + + return href ? ( + + {inner} + + ) : ( +
{inner}
+ ); +} + +function ProfileRow({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: string; +}) { + return ( +
+
{icon}
+
+

{label}

+

{value}

+
+
+ ); +} + +function ShipmentBadge({ status }: { status: ShipmentStatus }) { + const styles: Record = { + "In Transit": "bg-indigo-100 text-indigo-700", + Delivered: "bg-emerald-100 text-emerald-700", + Delayed: "bg-red-100 text-red-700", + }; + return ( + + {status} + + ); +} + +function BookingBadge({ status }: { status: BookingStatus }) { + const styles: Record = { + Pending: "bg-amber-100 text-amber-700", + Confirmed: "bg-sky-100 text-sky-700", + "In Transit": "bg-indigo-100 text-indigo-700", + Delivered: "bg-emerald-100 text-emerald-700", + Cancelled: "bg-red-100 text-red-700", + }; + return ( + + {status} + + ); +} + +function InvoiceBadge({ status }: { status: InvoiceStatus }) { + const styles: Record = { + Draft: "bg-slate-100 text-slate-600", + Sent: "bg-sky-100 text-sky-700", + Paid: "bg-emerald-100 text-emerald-700", + Overdue: "bg-red-100 text-red-700", + Cancelled: "bg-amber-100 text-amber-700", + }; + return ( + + {status} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/tracking/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/tracking/NewShipmentPage.tsx index b5742c5b5..1958b73c5 100644 --- a/apps/edr-freight-web/portal/src/pages/tracking/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/tracking/NewShipmentPage.tsx @@ -13,12 +13,12 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; -import { bookings } from "../bookings/bookings.mock"; +import { consignments } from "../consignments/consignments.mock"; import type { ShipmentMode, ShipmentStatus } from "./shipments.mock"; export interface ShipmentFormData { - bookingId?: number; - bookingReference?: string; + consignmentId?: number; + consignmentReference?: string; originStation?: string; destinationStation?: string; mode?: ShipmentMode; @@ -61,23 +61,26 @@ export default function NewShipmentPage({
- {/* Freight Booking */} + {/* Consignment */}
- + +

+ Origin and destination auto-fill from the linked consignment. +

{/* Status */} @@ -113,7 +116,7 @@ export default function NewShipmentPage({
@@ -126,7 +129,7 @@ export default function NewShipmentPage({
diff --git a/apps/edr-freight-web/portal/src/pages/tracking/TrackingPage.tsx b/apps/edr-freight-web/portal/src/pages/tracking/TrackingPage.tsx index b2c190e4d..6d0b885a5 100644 --- a/apps/edr-freight-web/portal/src/pages/tracking/TrackingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/tracking/TrackingPage.tsx @@ -41,6 +41,7 @@ export default function TrackingPage() { if (!q) return true; return ( s.reference.toLowerCase().includes(q) || + s.consignmentReference.toLowerCase().includes(q) || s.bookingReference.toLowerCase().includes(q) || s.customer.toLowerCase().includes(q) || s.originStation.toLowerCase().includes(q) || @@ -203,7 +204,7 @@ function ShipmentCard({ shipment }: { shipment: Shipment }) { {shipment.reference} - Booking {shipment.bookingReference} + Consignment {shipment.consignmentReference} diff --git a/apps/edr-freight-web/portal/src/pages/tracking/shipments.mock.ts b/apps/edr-freight-web/portal/src/pages/tracking/shipments.mock.ts index 07aad40df..9ef03d171 100644 --- a/apps/edr-freight-web/portal/src/pages/tracking/shipments.mock.ts +++ b/apps/edr-freight-web/portal/src/pages/tracking/shipments.mock.ts @@ -1,4 +1,4 @@ -import { bookings } from "../bookings/bookings.mock"; +import { consignments } from "../consignments/consignments.mock"; export type ShipmentStatus = "In Transit" | "Delivered" | "Delayed"; export type ShipmentMode = "rail" | "truck" | "multimodal"; @@ -6,7 +6,8 @@ export type ShipmentMode = "rail" | "truck" | "multimodal"; export interface Shipment { id: number; reference: string; - bookingId: number; + consignmentId: number; + consignmentReference: string; bookingReference: string; customer: string; originStation: string; @@ -126,16 +127,19 @@ const seedShipments: Array<{ ]; export const shipments: Shipment[] = seedShipments.map((entry, i) => { - const booking = bookings[i % bookings.length] as (typeof bookings)[number]; + const consignment = consignments[ + i % consignments.length + ] as (typeof consignments)[number]; const id = i + 1; return { id, reference: `SH-${String(id).padStart(3, "0")}`, - bookingId: booking.id, - bookingReference: booking.reference, - customer: booking.customer, - originStation: booking.originStation, - destinationStation: booking.destinationStation, + consignmentId: consignment.id, + consignmentReference: consignment.trackingNumber, + bookingReference: consignment.bookingReference, + customer: consignment.customer, + originStation: consignment.originStation, + destinationStation: consignment.destinationStation, mode: entry.mode, status: entry.status, currentLocation: entry.currentLocation, diff --git a/packages/ui-common/src/components/Layout/DashboardLayout.tsx b/packages/ui-common/src/components/Layout/DashboardLayout.tsx index 71f5d14f3..184448b41 100644 --- a/packages/ui-common/src/components/Layout/DashboardLayout.tsx +++ b/packages/ui-common/src/components/Layout/DashboardLayout.tsx @@ -107,7 +107,7 @@ const DashboardLayout = ({ ) : null; return ( -
+
-
+
{title} diff --git a/packages/ui-common/src/components/Layout/Sidebar.tsx b/packages/ui-common/src/components/Layout/Sidebar.tsx index 7653b8c10..989c2f2de 100644 --- a/packages/ui-common/src/components/Layout/Sidebar.tsx +++ b/packages/ui-common/src/components/Layout/Sidebar.tsx @@ -22,7 +22,7 @@ const Sidebar = ({ onNavigate, headerExtra, }: SidebarProps) => ( -