add customer portal

This commit is contained in:
yaschalew
2026-05-18 11:25:33 +03:00
parent e552ac67d5
commit db1f0cc5d7
9 changed files with 563 additions and 26 deletions

View File

@@ -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: <UserCircle /> },
{ label: "Dashboard", href: "/", icon: <LayoutDashboard /> },
{ label: "Customers", href: "/customers", icon: <Users /> },
{ label: "Bookings", href: "/bookings", icon: <CalendarCheck /> },
@@ -69,6 +72,7 @@ const App = () => {
>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/portal" element={<MyPortalPage />} />
<Route path="/bookings" element={<BookingsPage />} />
<Route path="/customers" element={<CustomersPage />} />
<Route path="/customers/:id" element={<CustomerDetailPage />} />

View File

@@ -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);
}

View File

@@ -474,7 +474,7 @@ export default function DashboardPage() {
</ResponsiveContainer>
</ChartCard>
<ChartCard title="Fleet Status" subtitle={`${trains.length} units`}>
<ChartCard title="Train Status" subtitle={`${trains.length} units`}>
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie

View File

@@ -0,0 +1,480 @@
import { useMemo } from "react";
import { Link } from "react-router-dom";
import {
ArrowRight,
Building2,
CheckCircle2,
Clock,
DollarSign,
Eye,
Mail,
MapPin,
Package,
Phone,
Plus,
Receipt,
Truck,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewBookingPage from "@/pages/bookings/NewBookingPage";
import {
getCurrentCustomer,
getMyBookings,
getMyInvoices,
getMyShipments,
} from "@/lib/currentCustomer";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import type { ShipmentStatus } from "@/pages/tracking/shipments.mock";
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
import type { BookingStatus } from "@/pages/bookings/bookings.mock";
export default function MyPortalPage() {
const me = useMemo(() => 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 (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
<Breadcrumbs items={[{ label: "My Portal" }]} />
{/* Welcome banner */}
<div className="overflow-hidden rounded-3xl bg-gradient-to-r from-[#33578D] to-[#4a72ad] p-6 text-white shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-white/15 text-2xl font-bold backdrop-blur">
{me.company.charAt(0)}
</div>
<div>
<p className="text-sm text-white/80">Welcome back</p>
<h1 className="text-3xl font-bold tracking-tight">
{me.name}
</h1>
<p className="mt-1 flex items-center gap-2 text-sm text-white/80">
<Building2 className="h-4 w-4" />
{me.company}
<span className="text-white/40">·</span>
<span className="rounded-full bg-white/15 px-2 py-0.5 text-xs">
{me.customerType}
</span>
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<NewBookingPage>
<button
type="button"
className="inline-flex items-center gap-2 rounded-2xl bg-white px-4 py-2 text-sm font-semibold text-[#33578D] transition hover:bg-slate-100"
>
<Plus className="h-4 w-4" />
New Booking
</button>
</NewBookingPage>
<Link
to="/tracking"
className="inline-flex items-center gap-2 rounded-2xl border border-white/40 px-4 py-2 text-sm font-medium text-white transition hover:bg-white/10"
>
<Truck className="h-4 w-4" />
Track Shipment
</Link>
</div>
</div>
</div>
{/* My KPIs */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<KpiCard
label="My Active Bookings"
value={String(activeBookings.length)}
sub={`${myBookings.length} total`}
icon={<Package className="h-5 w-5" />}
href="/bookings"
/>
<KpiCard
label="In Transit"
value={String(activeShipments.length)}
sub={`${myShipments.length} shipments`}
icon={<Truck className="h-5 w-5" />}
href="/tracking"
/>
<KpiCard
label="Outstanding"
value={formatCurrency(totalOutstanding, "USD")}
sub={`${outstandingInvoices.length} invoices`}
icon={<DollarSign className="h-5 w-5" />}
href="/billing"
tone={outstandingInvoices.some((i) => i.status === "Overdue") ? "danger" : "brand"}
/>
<KpiCard
label="Total Spent"
value={formatCurrency(totalSpent, "USD")}
sub="All-time, paid invoices"
icon={<CheckCircle2 className="h-5 w-5" />}
/>
</div>
{/* Active Shipments */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">
Active Shipments
</h2>
<p className="text-sm text-slate-500">
Live tracking for your in-flight cargo
</p>
</div>
<Link
to="/tracking"
className="inline-flex items-center gap-1 text-sm font-medium text-[#33578D] transition hover:underline"
>
View all
<ArrowRight className="h-4 w-4" />
</Link>
</div>
{activeShipments.length === 0 ? (
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
No shipments currently in transit.
</p>
) : (
<div className="grid gap-3 md:grid-cols-2">
{activeShipments.slice(0, 4).map((shipment) => (
<div
key={shipment.id}
className="rounded-2xl border border-slate-100 p-4 transition hover:border-[#33578D]/20 hover:bg-[#33578D]/5"
>
<div className="flex items-center justify-between">
<span className="font-semibold text-slate-900">
{shipment.reference}
</span>
<ShipmentBadge status={shipment.status} />
</div>
<p className="mt-1 text-sm text-slate-700">
{shipment.originStation}
<ArrowRight className="mx-2 inline h-3 w-3 text-slate-400" />
{shipment.destinationStation}
</p>
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3 text-[#33578D]" />
{shipment.currentLocation}
</span>
<span>ETA {shipment.eta}</span>
</div>
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-[#33578D] transition-all"
style={{ width: `${shipment.progress}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
{/* Recent Bookings + Invoices + Profile */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Recent bookings */}
<div className="rounded-3xl bg-white p-6 shadow-sm lg:col-span-2">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">
Recent Bookings
</h2>
<p className="text-sm text-slate-500">
Your latest freight requests
</p>
</div>
<Link
to="/bookings"
className="inline-flex items-center gap-1 text-sm font-medium text-[#33578D] transition hover:underline"
>
View all
<ArrowRight className="h-4 w-4" />
</Link>
</div>
{recentBookings.length === 0 ? (
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
You haven't booked any freight yet.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[600px] whitespace-nowrap text-left text-sm">
<thead className="text-xs text-slate-500">
<tr>
<th className="py-2 font-medium">Reference</th>
<th className="py-2 font-medium">Route</th>
<th className="py-2 font-medium">Cargo</th>
<th className="py-2 font-medium">Status</th>
<th className="py-2 text-right font-medium">Action</th>
</tr>
</thead>
<tbody>
{recentBookings.map((booking) => (
<tr
key={booking.id}
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
>
<td className="py-3 font-medium text-slate-900">
{booking.reference}
</td>
<td className="py-3 text-slate-700">
{booking.originStation} {booking.destinationStation}
</td>
<td className="py-3 text-slate-700">
{booking.cargoType}
</td>
<td className="py-3">
<BookingBadge status={booking.status} />
</td>
<td className="py-3 text-right">
<Link
to={`/bookings/${booking.id}`}
aria-label="View booking"
className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 transition hover:bg-[#33578D]/10 hover:text-[#33578D]"
>
<Eye className="h-4 w-4" />
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Profile card */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">
My Profile
</h2>
<p className="text-sm text-slate-500">Account information</p>
<div className="mt-4 space-y-3 text-sm">
<ProfileRow
icon={<Building2 className="h-4 w-4" />}
label="Company"
value={me.company}
/>
<ProfileRow
icon={<Mail className="h-4 w-4" />}
label="Email"
value={me.email}
/>
<ProfileRow
icon={<Phone className="h-4 w-4" />}
label="Phone"
value={me.phone}
/>
<ProfileRow
icon={<MapPin className="h-4 w-4" />}
label="Location"
value={`${me.city}, ${me.country}`}
/>
</div>
<Link
to={`/customers/${me.id}`}
className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
>
View full profile
<ArrowRight className="h-4 w-4" />
</Link>
</div>
</div>
{/* Invoices */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">
Recent Invoices
</h2>
<p className="text-sm text-slate-500">
{outstandingInvoices.length} outstanding ·{" "}
{myInvoices.length} total
</p>
</div>
<Link
to="/billing"
className="inline-flex items-center gap-1 text-sm font-medium text-[#33578D] transition hover:underline"
>
View all
<ArrowRight className="h-4 w-4" />
</Link>
</div>
{recentInvoices.length === 0 ? (
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
No invoices yet.
</p>
) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
{recentInvoices.map((invoice) => (
<div
key={invoice.id}
className="rounded-2xl border border-slate-100 p-4 transition hover:border-[#33578D]/20 hover:bg-[#33578D]/5"
>
<div className="flex items-center justify-between">
<Receipt className="h-4 w-4 text-[#33578D]" />
<InvoiceBadge status={invoice.status} />
</div>
<p className="mt-2 text-xs text-slate-500">
{invoice.number}
</p>
<p className="mt-0.5 text-lg font-bold text-slate-900">
{formatCurrency(invoice.amount, invoice.currency)}
</p>
<p className="mt-1 flex items-center gap-1 text-xs text-slate-500">
<Clock className="h-3 w-3" />
Due {invoice.dueDate}
</p>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
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 = (
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-slate-500">{label}</p>
<h3 className="mt-2 text-2xl font-bold text-slate-900">{value}</h3>
<p className="mt-1 text-xs text-slate-500">{sub}</p>
</div>
<div
className={`flex h-10 w-10 items-center justify-center rounded-2xl ${iconWrap}`}
>
{icon}
</div>
</div>
);
const className =
"rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20";
return href ? (
<Link to={href} className={`block ${className}`}>
{inner}
</Link>
) : (
<div className={className}>{inner}</div>
);
}
function ProfileRow({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-start gap-3">
<div className="mt-0.5 text-[#33578D]">{icon}</div>
<div className="flex-1">
<p className="text-xs font-medium text-slate-500">{label}</p>
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
</div>
</div>
);
}
function ShipmentBadge({ status }: { status: ShipmentStatus }) {
const styles: Record<ShipmentStatus, string> = {
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Delayed: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}
function BookingBadge({ status }: { status: BookingStatus }) {
const styles: Record<BookingStatus, string> = {
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 (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}
function InvoiceBadge({ status }: { status: InvoiceStatus }) {
const styles: Record<InvoiceStatus, string> = {
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 (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -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({
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
{/* Freight Booking */}
{/* Consignment */}
<div className="space-y-2 md:col-span-2">
<Label>Freight Booking *</Label>
<Label>Consignment *</Label>
<select
defaultValue={shipment?.bookingId ?? ""}
defaultValue={shipment?.consignmentId ?? ""}
className={selectClass}
>
<option value="" disabled>
Select freight booking
Select consignment
</option>
{bookings.map((b) => (
<option key={b.id} value={b.id}>
{b.reference} {b.customer} ({b.originStation} {" "}
{b.destinationStation})
{consignments.map((c) => (
<option key={c.id} value={c.id}>
{c.trackingNumber} {c.customer} ({c.originStation} {" "}
{c.destinationStation})
</option>
))}
</select>
<p className="text-xs text-slate-500">
Origin and destination auto-fill from the linked consignment.
</p>
</div>
{/* Status */}
@@ -113,7 +116,7 @@ export default function NewShipmentPage({
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={shipment?.originStation ?? ""}
placeholder="Auto-filled from booking"
placeholder="Auto-filled from consignment"
className="pl-10"
/>
</div>
@@ -126,7 +129,7 @@ export default function NewShipmentPage({
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={shipment?.destinationStation ?? ""}
placeholder="Auto-filled from booking"
placeholder="Auto-filled from consignment"
className="pl-10"
/>
</div>

View File

@@ -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}
</span>
<span className="text-xs text-slate-500">
Booking {shipment.bookingReference}
Consignment {shipment.consignmentReference}
</span>
</div>
<StatusBadge status={shipment.status} />

View File

@@ -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,

View File

@@ -107,7 +107,7 @@ const DashboardLayout = ({
) : null;
return (
<div className="flex min-h-screen bg-slate-50 dark:bg-slate-950">
<div className="flex h-screen overflow-hidden bg-slate-50 dark:bg-slate-950">
<Sidebar
title={title}
items={sidebarItems}
@@ -115,7 +115,7 @@ const DashboardLayout = ({
onNavigate={onNavigate}
headerExtra={themeToggleButton}
/>
<div className="flex flex-1 flex-col">
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-16 items-center justify-between border-b border-slate-200 bg-white px-6 dark:border-slate-800 dark:bg-slate-900">
<div className="text-base font-medium text-slate-700 dark:text-slate-200">
{title}

View File

@@ -22,7 +22,7 @@ const Sidebar = ({
onNavigate,
headerExtra,
}: SidebarProps) => (
<aside className="flex w-64 flex-col gap-1 border-r border-slate-200 bg-[#33578D]/10 px-3 py-5 dark:border-slate-800 dark:bg-slate-900">
<aside className="flex h-full w-64 shrink-0 flex-col gap-1 overflow-y-auto border-r border-slate-200 bg-[#33578D]/10 px-3 py-5 dark:border-slate-800 dark:bg-slate-900">
{title ? (
<div className="flex items-center justify-between gap-2 px-3 pb-4">
<div className="flex items-center gap-2">