mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
add customer portal
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
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>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Fleet Status" subtitle={`${trains.length} units`}>
|
||||
<ChartCard title="Train Status" subtitle={`${trains.length} units`}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<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 { 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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user