mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 17:45:42 +00:00
add customer portal
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
|||||||
Receipt,
|
Receipt,
|
||||||
FileText,
|
FileText,
|
||||||
Settings,
|
Settings,
|
||||||
|
UserCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import BookingsPage from "./pages/bookings/BookingsPage";
|
import BookingsPage from "./pages/bookings/BookingsPage";
|
||||||
@@ -33,8 +34,10 @@ import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
|||||||
import NewCustomerPage from "./pages/customers/NewCustomerPage";
|
import NewCustomerPage from "./pages/customers/NewCustomerPage";
|
||||||
import DocumentsPage from "./pages/documents/DocumentsPage";
|
import DocumentsPage from "./pages/documents/DocumentsPage";
|
||||||
import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage";
|
import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage";
|
||||||
|
import MyPortalPage from "./pages/portal/MyPortalPage";
|
||||||
|
|
||||||
const sidebarItems: SidebarItem[] = [
|
const sidebarItems: SidebarItem[] = [
|
||||||
|
{ label: "My Portal", href: "/portal", icon: <UserCircle /> },
|
||||||
{ label: "Dashboard", href: "/", icon: <LayoutDashboard /> },
|
{ label: "Dashboard", href: "/", icon: <LayoutDashboard /> },
|
||||||
{ label: "Customers", href: "/customers", icon: <Users /> },
|
{ label: "Customers", href: "/customers", icon: <Users /> },
|
||||||
{ label: "Bookings", href: "/bookings", icon: <CalendarCheck /> },
|
{ label: "Bookings", href: "/bookings", icon: <CalendarCheck /> },
|
||||||
@@ -69,6 +72,7 @@ const App = () => {
|
|||||||
>
|
>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
|
<Route path="/portal" element={<MyPortalPage />} />
|
||||||
<Route path="/bookings" element={<BookingsPage />} />
|
<Route path="/bookings" element={<BookingsPage />} />
|
||||||
<Route path="/customers" element={<CustomersPage />} />
|
<Route path="/customers" element={<CustomersPage />} />
|
||||||
<Route path="/customers/:id" element={<CustomerDetailPage />} />
|
<Route path="/customers/:id" element={<CustomerDetailPage />} />
|
||||||
|
|||||||
45
apps/edr-freight-web/portal/src/lib/currentCustomer.ts
Normal file
45
apps/edr-freight-web/portal/src/lib/currentCustomer.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -474,7 +474,7 @@ export default function DashboardPage() {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
<ChartCard title="Fleet Status" subtitle={`${trains.length} units`}>
|
<ChartCard title="Train Status" subtitle={`${trains.length} units`}>
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
|
|||||||
480
apps/edr-freight-web/portal/src/pages/portal/MyPortalPage.tsx
Normal file
480
apps/edr-freight-web/portal/src/pages/portal/MyPortalPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,12 +13,12 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Button } from "@/components/ui/button";
|
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";
|
import type { ShipmentMode, ShipmentStatus } from "./shipments.mock";
|
||||||
|
|
||||||
export interface ShipmentFormData {
|
export interface ShipmentFormData {
|
||||||
bookingId?: number;
|
consignmentId?: number;
|
||||||
bookingReference?: string;
|
consignmentReference?: string;
|
||||||
originStation?: string;
|
originStation?: string;
|
||||||
destinationStation?: string;
|
destinationStation?: string;
|
||||||
mode?: ShipmentMode;
|
mode?: ShipmentMode;
|
||||||
@@ -61,23 +61,26 @@ export default function NewShipmentPage({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
<div className="grid gap-5 py-4 md:grid-cols-2">
|
||||||
{/* Freight Booking */}
|
{/* Consignment */}
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<Label>Freight Booking *</Label>
|
<Label>Consignment *</Label>
|
||||||
<select
|
<select
|
||||||
defaultValue={shipment?.bookingId ?? ""}
|
defaultValue={shipment?.consignmentId ?? ""}
|
||||||
className={selectClass}
|
className={selectClass}
|
||||||
>
|
>
|
||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
Select freight booking
|
Select consignment
|
||||||
</option>
|
</option>
|
||||||
{bookings.map((b) => (
|
{consignments.map((c) => (
|
||||||
<option key={b.id} value={b.id}>
|
<option key={c.id} value={c.id}>
|
||||||
{b.reference} — {b.customer} ({b.originStation} →{" "}
|
{c.trackingNumber} — {c.customer} ({c.originStation} →{" "}
|
||||||
{b.destinationStation})
|
{c.destinationStation})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Origin and destination auto-fill from the linked consignment.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
@@ -113,7 +116,7 @@ export default function NewShipmentPage({
|
|||||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||||
<Input
|
<Input
|
||||||
defaultValue={shipment?.originStation ?? ""}
|
defaultValue={shipment?.originStation ?? ""}
|
||||||
placeholder="Auto-filled from booking"
|
placeholder="Auto-filled from consignment"
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,7 +129,7 @@ export default function NewShipmentPage({
|
|||||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||||
<Input
|
<Input
|
||||||
defaultValue={shipment?.destinationStation ?? ""}
|
defaultValue={shipment?.destinationStation ?? ""}
|
||||||
placeholder="Auto-filled from booking"
|
placeholder="Auto-filled from consignment"
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export default function TrackingPage() {
|
|||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
return (
|
return (
|
||||||
s.reference.toLowerCase().includes(q) ||
|
s.reference.toLowerCase().includes(q) ||
|
||||||
|
s.consignmentReference.toLowerCase().includes(q) ||
|
||||||
s.bookingReference.toLowerCase().includes(q) ||
|
s.bookingReference.toLowerCase().includes(q) ||
|
||||||
s.customer.toLowerCase().includes(q) ||
|
s.customer.toLowerCase().includes(q) ||
|
||||||
s.originStation.toLowerCase().includes(q) ||
|
s.originStation.toLowerCase().includes(q) ||
|
||||||
@@ -203,7 +204,7 @@ function ShipmentCard({ shipment }: { shipment: Shipment }) {
|
|||||||
{shipment.reference}
|
{shipment.reference}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-slate-500">
|
<span className="text-xs text-slate-500">
|
||||||
Booking {shipment.bookingReference}
|
Consignment {shipment.consignmentReference}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={shipment.status} />
|
<StatusBadge status={shipment.status} />
|
||||||
|
|||||||
@@ -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 ShipmentStatus = "In Transit" | "Delivered" | "Delayed";
|
||||||
export type ShipmentMode = "rail" | "truck" | "multimodal";
|
export type ShipmentMode = "rail" | "truck" | "multimodal";
|
||||||
@@ -6,7 +6,8 @@ export type ShipmentMode = "rail" | "truck" | "multimodal";
|
|||||||
export interface Shipment {
|
export interface Shipment {
|
||||||
id: number;
|
id: number;
|
||||||
reference: string;
|
reference: string;
|
||||||
bookingId: number;
|
consignmentId: number;
|
||||||
|
consignmentReference: string;
|
||||||
bookingReference: string;
|
bookingReference: string;
|
||||||
customer: string;
|
customer: string;
|
||||||
originStation: string;
|
originStation: string;
|
||||||
@@ -126,16 +127,19 @@ const seedShipments: Array<{
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const shipments: Shipment[] = seedShipments.map((entry, i) => {
|
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;
|
const id = i + 1;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
reference: `SH-${String(id).padStart(3, "0")}`,
|
reference: `SH-${String(id).padStart(3, "0")}`,
|
||||||
bookingId: booking.id,
|
consignmentId: consignment.id,
|
||||||
bookingReference: booking.reference,
|
consignmentReference: consignment.trackingNumber,
|
||||||
customer: booking.customer,
|
bookingReference: consignment.bookingReference,
|
||||||
originStation: booking.originStation,
|
customer: consignment.customer,
|
||||||
destinationStation: booking.destinationStation,
|
originStation: consignment.originStation,
|
||||||
|
destinationStation: consignment.destinationStation,
|
||||||
mode: entry.mode,
|
mode: entry.mode,
|
||||||
status: entry.status,
|
status: entry.status,
|
||||||
currentLocation: entry.currentLocation,
|
currentLocation: entry.currentLocation,
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ const DashboardLayout = ({
|
|||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
return (
|
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
|
<Sidebar
|
||||||
title={title}
|
title={title}
|
||||||
items={sidebarItems}
|
items={sidebarItems}
|
||||||
@@ -115,7 +115,7 @@ const DashboardLayout = ({
|
|||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
headerExtra={themeToggleButton}
|
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">
|
<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">
|
<div className="text-base font-medium text-slate-700 dark:text-slate-200">
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const Sidebar = ({
|
|||||||
onNavigate,
|
onNavigate,
|
||||||
headerExtra,
|
headerExtra,
|
||||||
}: SidebarProps) => (
|
}: 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 ? (
|
{title ? (
|
||||||
<div className="flex items-center justify-between gap-2 px-3 pb-4">
|
<div className="flex items-center justify-between gap-2 px-3 pb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user