mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 13:28:11 +00:00
Merge pull request #50 from Tria-plc/freight/feature/ui-sync
freight/feature/ui sync
This commit is contained in:
@@ -7,65 +7,37 @@ import {
|
|||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
|
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
|
||||||
Users,
|
|
||||||
CalendarCheck,
|
CalendarCheck,
|
||||||
Package,
|
|
||||||
MapPin,
|
MapPin,
|
||||||
Train,
|
|
||||||
Receipt,
|
Receipt,
|
||||||
FileText,
|
FileText,
|
||||||
Settings,
|
Home,
|
||||||
UserCircle,
|
|
||||||
FileUp,
|
|
||||||
MapPinned,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import BookingsPage from "./pages/bookings/BookingsPage";
|
|
||||||
import MyBookings from "./pages/bookings/MyBookings";
|
|
||||||
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
|
||||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
|
||||||
import ConsignmentsPage from "./pages/consignments/ConsignmentsPage";
|
|
||||||
import ConsignmentDetailPage from "./pages/consignments/ConsignmentDetailPage";
|
|
||||||
import TrackingPage from "./pages/tracking/TrackingPage";
|
|
||||||
import BillingPage from "./pages/billing/BillingPage";
|
|
||||||
import TrainsPage from "./pages/trains/TrainsPage";
|
|
||||||
import DashboardPage from "./pages/dashboard/DashboardPage";
|
|
||||||
import useAuth from "./hooks/useAuth";
|
import useAuth from "./hooks/useAuth";
|
||||||
import CustomersPage from "./pages/customers/CustomersPage";
|
|
||||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
import MyPortalPage from "./pages/MyPortalPage";
|
||||||
import NewCustomerPage from "./pages/customers/NewCustomerPage";
|
|
||||||
import DocumentsPage from "./pages/documents/DocumentsPage";
|
|
||||||
import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage";
|
|
||||||
import FileUploadSettingsPage from "./pages/admin/FileUploadSettingsPage";
|
|
||||||
import MyPortalPage from "./pages/portal/MyPortalPage";
|
|
||||||
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
|
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
|
||||||
import SignupPage from "./pages/accounts/SignupPage";
|
import SignupPage from "./pages/accounts/SignupPage";
|
||||||
import OnboardingPage from "./pages/accounts/OnboardingPage";
|
import OnboardingPage from "./pages/accounts/OnboardingPage";
|
||||||
import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
|
import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
|
||||||
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
|
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
|
||||||
import LoginPage from "./pages/accounts/LoginPage";
|
import LoginPage from "./pages/accounts/LoginPage";
|
||||||
import Station from "./components/stations/Station";
|
import MyBookings from "./pages/bookings/MyBookings";
|
||||||
|
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
||||||
|
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||||
|
import TrackingPage from "./pages/tracking/TrackingPage";
|
||||||
|
import BillingPage from "./pages/billing/BillingPage";
|
||||||
|
import DocumentsPage from "./pages/documents/DocumentsPage";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
const sidebarItems: SidebarItem[] = [
|
const sidebarItems: SidebarItem[] = [
|
||||||
{ label: "My Portal", href: "/", icon: <UserCircle /> },
|
{ label: "Home", href: "/", icon: <Home /> },
|
||||||
{ label: "Dashboard", href: "/dashboard", icon: <LayoutDashboard /> },
|
|
||||||
{ label: "Customers", href: "/customers", icon: <Users /> },
|
|
||||||
{ label: "My Bookings", href: "/bookings", icon: <CalendarCheck /> },
|
{ label: "My Bookings", href: "/bookings", icon: <CalendarCheck /> },
|
||||||
{ label: "Consignments", href: "/consignments", icon: <Package /> },
|
|
||||||
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
|
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
|
||||||
{ label: "Stations", href: "/stations", icon: <MapPinned /> },
|
|
||||||
{ label: "Trains", href: "/trains", icon: <Train /> },
|
|
||||||
{ label: "Billing", href: "/billing", icon: <Receipt /> },
|
{ label: "Billing", href: "/billing", icon: <Receipt /> },
|
||||||
{ label: "Documents", href: "/documents", icon: <FileText /> },
|
{ label: "Documents", href: "/documents", icon: <FileText /> },
|
||||||
{ label: "Dropdown Settings", href: "/admin/dropdowns", icon: <Settings /> },
|
|
||||||
{
|
|
||||||
label: "File Upload Settings",
|
|
||||||
href: "/admin/file-uploads",
|
|
||||||
icon: <FileUp />,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
@@ -95,7 +67,7 @@ const App = () => {
|
|||||||
<Route path="/signup" element={<SignupPage />} />
|
<Route path="/signup" element={<SignupPage />} />
|
||||||
<Route path="/otp" element={<VerificationOtpPage />} />
|
<Route path="/otp" element={<VerificationOtpPage />} />
|
||||||
<Route path="/set-password" element={<SetPasswordPage />} />
|
<Route path="/set-password" element={<SetPasswordPage />} />
|
||||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -119,28 +91,13 @@ const App = () => {
|
|||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
>
|
>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/dashboard" element={<DashboardPage />} />
|
|
||||||
<Route path="/" element={<MyPortalPage />} />
|
<Route path="/" element={<MyPortalPage />} />
|
||||||
<Route path="/bookings" element={<MyBookings />} />
|
<Route path="/bookings" element={<MyBookings />} />
|
||||||
<Route path="/admin/bookings" element={<BookingsPage />} />
|
|
||||||
<Route path="/customers" element={<CustomersPage />} />
|
|
||||||
<Route path="/customers/:id" element={<CustomerDetailPage />} />
|
|
||||||
<Route path="/new-customer" element={<NewCustomerPage />} />
|
|
||||||
<Route path="/bookings/new" element={<NewBookingPage />} />
|
<Route path="/bookings/new" element={<NewBookingPage />} />
|
||||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||||
<Route path="/consignments" element={<ConsignmentsPage />} />
|
|
||||||
<Route path="/consignments/:id" element={<ConsignmentDetailPage />} />
|
|
||||||
<Route path="/tracking" element={<TrackingPage />} />
|
<Route path="/tracking" element={<TrackingPage />} />
|
||||||
<Route path="/stations" element={<Station />} />
|
|
||||||
<Route path="/trains" element={<TrainsPage />} />
|
|
||||||
<Route path="/billing" element={<BillingPage />} />
|
<Route path="/billing" element={<BillingPage />} />
|
||||||
<Route path="/documents" element={<DocumentsPage />} />
|
<Route path="/documents" element={<DocumentsPage />} />
|
||||||
<Route path="/admin/dropdowns" element={<DropdownSettingsPage />} />
|
|
||||||
<Route
|
|
||||||
path="/admin/file-uploads"
|
|
||||||
element={<FileUploadSettingsPage />}
|
|
||||||
/>
|
|
||||||
<Route path="/user-management" element={<Navigate to="/" replace />} />
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
CircleOff,
|
|
||||||
Loader2,
|
|
||||||
MapPin,
|
|
||||||
Search,
|
|
||||||
TrainFront,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
|
||||||
import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings";
|
|
||||||
import type { DropdownOption } from "@/types/dropdownSettings";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
DataTable,
|
|
||||||
DataTableFooter,
|
|
||||||
Input,
|
|
||||||
type ColumnDef,
|
|
||||||
usePagination,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
const STATION_DROPDOWN_CODE = "stations_ter";
|
|
||||||
|
|
||||||
export default function Station() {
|
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
|
|
||||||
const { data, isLoading, isError, error } = useDropdownSettingByCode(
|
|
||||||
STATION_DROPDOWN_CODE,
|
|
||||||
);
|
|
||||||
|
|
||||||
const stations = useMemo<DropdownOption[]>(
|
|
||||||
() => [...(data?.children ?? [])].sort((a, b) => a.order - b.order),
|
|
||||||
[data?.children],
|
|
||||||
);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
if (!q) return stations;
|
|
||||||
|
|
||||||
return stations.filter(
|
|
||||||
(station) =>
|
|
||||||
station.label.toLowerCase().includes(q) ||
|
|
||||||
station.value.toLowerCase().includes(q) ||
|
|
||||||
(station.note ?? "").toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
}, [query, stations]);
|
|
||||||
|
|
||||||
const total = filtered.length;
|
|
||||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
|
||||||
const start = pagination.pageIndex * pagination.pageSize;
|
|
||||||
const end = Math.min(start + pagination.pageSize, total);
|
|
||||||
const paginatedData = useMemo(
|
|
||||||
() => filtered.slice(start, end),
|
|
||||||
[end, filtered, start],
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeCount = stations.filter((station) => !station.disabled).length;
|
|
||||||
const disabledCount = stations.length - activeCount;
|
|
||||||
|
|
||||||
const status: "loading" | "error" | "success" = isLoading
|
|
||||||
? "loading"
|
|
||||||
: isError
|
|
||||||
? "error"
|
|
||||||
: "success";
|
|
||||||
|
|
||||||
const columns: ColumnDef<DropdownOption>[] = [
|
|
||||||
{
|
|
||||||
id: "station",
|
|
||||||
header: "Station",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const station = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
|
||||||
<MapPin />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-slate-900">{station.label}</p>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
{station.note ?? "No station note"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "value",
|
|
||||||
header: "Code",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
|
|
||||||
{row.original.value}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "order",
|
|
||||||
header: "Order",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "status",
|
|
||||||
header: "Status",
|
|
||||||
cell: ({ row }) =>
|
|
||||||
row.original.disabled ? (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
|
|
||||||
<CircleOff className="h-3 w-3" />
|
|
||||||
Disabled
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
|
||||||
<TrainFront className="h-3 w-3" />
|
|
||||||
Active
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen p-6">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Breadcrumbs items={[{ label: "Stations" }]} />
|
|
||||||
|
|
||||||
<Card className="p-6 flex-row justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
|
||||||
Stations
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-secondary-foreground">
|
|
||||||
Station options loaded from dropdown code{" "}
|
|
||||||
<span className="font-mono">stations_ter</span>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative w-full sm:w-80">
|
|
||||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="search"
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => {
|
|
||||||
setQuery(event.target.value);
|
|
||||||
setPagination({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
placeholder="Search stations..."
|
|
||||||
className="pl-8!"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
<StationStat label="Stations" value={stations.length} />
|
|
||||||
<StationStat label="Active" value={activeCount} />
|
|
||||||
<StationStat label="Disabled" value={disabledCount} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isError ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center gap-3 py-6 text-sm text-red-600">
|
|
||||||
<AlertCircle className="h-5 w-5" />
|
|
||||||
Failed to load stations.{" "}
|
|
||||||
{error instanceof Error ? error.message : "Unknown error."}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Card className="gap-0">
|
|
||||||
<CardHeader className="border-b">
|
|
||||||
<CardTitle>Station List</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
All configured freight stations from the dropdown service.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="px-0">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin text-primary" />
|
|
||||||
Loading stations...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={paginatedData}
|
|
||||||
status={status}
|
|
||||||
pagination={{
|
|
||||||
pageIndex: pagination.pageIndex,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
pageCount,
|
|
||||||
totalCount: total,
|
|
||||||
}}
|
|
||||||
tableOptions={{
|
|
||||||
state: { pagination },
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
}}
|
|
||||||
containerClassName="border-b shadow-none"
|
|
||||||
footer={DataTableFooter}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StationStat({ label, value }: { label: string; value: number }) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">{label}</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
||||||
<MapPin />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
@@ -85,9 +86,7 @@ export default function EDRFreightLandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold tracking-tight">
|
<h1 className="text-xl font-bold tracking-tight">EDR Freight</h1>
|
||||||
EDR Freight
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Rail Logistics Platform
|
Rail Logistics Platform
|
||||||
@@ -119,20 +118,20 @@ export default function EDRFreightLandingPage() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<a
|
<Link
|
||||||
href="http://localhost:5173/auth"
|
to="/login"
|
||||||
className="hidden rounded-2xl border border-border bg-card px-5 py-2.5 text-sm font-medium transition hover:bg-accent md:block"
|
className="hidden rounded-2xl border border-border bg-card px-5 py-2.5 text-sm font-medium transition hover:bg-accent md:block"
|
||||||
>
|
>
|
||||||
Login
|
Login
|
||||||
</a>
|
</Link>
|
||||||
|
|
||||||
<a
|
<Link
|
||||||
href="http://localhost:5173/signup"
|
to="/signup"
|
||||||
className="hidden items-center gap-2 rounded-2xl bg-primary px-5 py-2.5 text-sm font-semibold text-primary-foreground shadow-lg transition hover:opacity-90 md:flex"
|
className="hidden items-center gap-2 rounded-2xl bg-primary px-5 py-2.5 text-sm font-semibold text-primary-foreground shadow-lg transition hover:opacity-90 md:flex"
|
||||||
>
|
>
|
||||||
Get Started
|
Get Started
|
||||||
<ArrowRight className="size-4" />
|
<ArrowRight className="size-4" />
|
||||||
</a>
|
</Link>
|
||||||
|
|
||||||
<button className="rounded-2xl border border-border p-2 md:hidden">
|
<button className="rounded-2xl border border-border p-2 md:hidden">
|
||||||
<Menu className="size-5" />
|
<Menu className="size-5" />
|
||||||
@@ -145,7 +144,7 @@ export default function EDRFreightLandingPage() {
|
|||||||
<section className="relative overflow-hidden">
|
<section className="relative overflow-hidden">
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(16,185,129,0.16),transparent_35%)]" />
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(16,185,129,0.16),transparent_35%)]" />
|
||||||
|
|
||||||
<div className="mx-auto grid max-w-7xl gap-16 px-6 py-20 lg:grid-cols-2 lg:items-center">
|
<div className="mx-auto grid relative z-10 max-w-7xl gap-16 px-6 py-20 lg:grid-cols-2 lg:items-center">
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-border bg-accent px-4 py-2 text-sm font-medium text-primary shadow-sm">
|
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-border bg-accent px-4 py-2 text-sm font-medium text-primary shadow-sm">
|
||||||
<span className="size-2 rounded-full bg-primary" />
|
<span className="size-2 rounded-full bg-primary" />
|
||||||
@@ -157,26 +156,26 @@ export default function EDRFreightLandingPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="mt-6 max-w-2xl text-lg leading-8 text-muted-foreground">
|
<p className="mt-6 max-w-2xl text-lg leading-8 text-muted-foreground">
|
||||||
EDR Freight enables logistics companies and railway operators
|
EDR Freight enables logistics companies and railway operators to
|
||||||
to manage shipments, monitor freight corridors, optimize train
|
manage shipments, monitor freight corridors, optimize train
|
||||||
operations, and streamline enterprise logistics workflows.
|
operations, and streamline enterprise logistics workflows.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-10 flex flex-wrap gap-4">
|
<div className="mt-10 flex flex-wrap gap-4">
|
||||||
<a
|
<Link
|
||||||
href="http://localhost:5173/signup"
|
to="/signup"
|
||||||
className="flex items-center gap-2 rounded-2xl bg-primary px-6 py-3 font-semibold text-primary-foreground shadow-lg transition hover:-translate-y-0.5 hover:opacity-90"
|
className="flex items-center gap-2 rounded-2xl bg-primary px-6 py-3 font-semibold text-primary-foreground shadow-lg transition hover:-translate-y-0.5 hover:opacity-90"
|
||||||
>
|
>
|
||||||
Get Started
|
Get Started
|
||||||
<ArrowRight className="size-5" />
|
<ArrowRight className="size-5" />
|
||||||
</a>
|
</Link>
|
||||||
|
|
||||||
<a
|
<Link
|
||||||
href="http://localhost:5173/auth"
|
to="/login"
|
||||||
className="rounded-2xl border border-border bg-card px-6 py-3 font-semibold transition hover:bg-accent"
|
className="rounded-2xl border border-border bg-card px-6 py-3 font-semibold transition hover:bg-accent"
|
||||||
>
|
>
|
||||||
Login
|
Login
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 flex flex-wrap gap-6">
|
<div className="mt-12 flex flex-wrap gap-6">
|
||||||
@@ -206,9 +205,7 @@ export default function EDRFreightLandingPage() {
|
|||||||
Freight Operations
|
Freight Operations
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 className="mt-2 text-3xl font-bold">
|
<h3 className="mt-2 text-3xl font-bold">Live Statistics</h3>
|
||||||
Live Statistics
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl bg-primary/10 p-4 text-primary">
|
<div className="rounded-2xl bg-primary/10 p-4 text-primary">
|
||||||
@@ -229,9 +226,7 @@ export default function EDRFreightLandingPage() {
|
|||||||
<Icon className="size-6" />
|
<Icon className="size-6" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4 className="text-3xl font-black">
|
<h4 className="text-3xl font-black">{item.value}</h4>
|
||||||
{item.value}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -271,9 +266,7 @@ export default function EDRFreightLandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-lg font-bold">
|
<h4 className="text-lg font-bold">16 Trains Active</h4>
|
||||||
16 Trains Active
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Across all freight corridors
|
Across all freight corridors
|
||||||
@@ -302,8 +295,8 @@ export default function EDRFreightLandingPage() {
|
|||||||
|
|
||||||
<p className="mt-4 text-lg leading-8 text-muted-foreground">
|
<p className="mt-4 text-lg leading-8 text-muted-foreground">
|
||||||
Centralized railway freight operations with live shipment
|
Centralized railway freight operations with live shipment
|
||||||
visibility, operational monitoring, customer management,
|
visibility, operational monitoring, customer management, and
|
||||||
and intelligent logistics insights.
|
intelligent logistics insights.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -320,9 +313,7 @@ export default function EDRFreightLandingPage() {
|
|||||||
<Icon className="size-7" />
|
<Icon className="size-7" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="mt-6 text-xl font-bold">
|
<h3 className="mt-6 text-xl font-bold">{feature.title}</h3>
|
||||||
{feature.title}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p className="mt-3 leading-7 text-muted-foreground">
|
<p className="mt-3 leading-7 text-muted-foreground">
|
||||||
{feature.description}
|
{feature.description}
|
||||||
@@ -444,10 +435,7 @@ export default function EDRFreightLandingPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Contact */}
|
{/* Contact */}
|
||||||
<section
|
<section id="contact" className="border-t border-border bg-card/40 py-24">
|
||||||
id="contact"
|
|
||||||
className="border-t border-border bg-card/40 py-24"
|
|
||||||
>
|
|
||||||
<div className="mx-auto max-w-7xl px-6">
|
<div className="mx-auto max-w-7xl px-6">
|
||||||
<div className="grid gap-12 lg:grid-cols-2">
|
<div className="grid gap-12 lg:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
@@ -460,8 +448,8 @@ export default function EDRFreightLandingPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="mt-5 text-lg leading-8 text-muted-foreground">
|
<p className="mt-5 text-lg leading-8 text-muted-foreground">
|
||||||
Contact EDR Freight for partnership opportunities,
|
Contact EDR Freight for partnership opportunities, enterprise
|
||||||
enterprise onboarding, or logistics support.
|
onboarding, or logistics support.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-10 space-y-5">
|
<div className="mt-10 space-y-5">
|
||||||
@@ -485,9 +473,7 @@ export default function EDRFreightLandingPage() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Phone</p>
|
<p className="font-semibold">Phone</p>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">+251 11 000 0000</p>
|
||||||
+251 11 000 0000
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -508,9 +494,7 @@ export default function EDRFreightLandingPage() {
|
|||||||
|
|
||||||
{/* Contact Form */}
|
{/* Contact Form */}
|
||||||
<div className="rounded-[32px] border border-border bg-card p-8 shadow-xl">
|
<div className="rounded-[32px] border border-border bg-card p-8 shadow-xl">
|
||||||
<h3 className="text-2xl font-bold">
|
<h3 className="text-2xl font-bold">Send us a message</h3>
|
||||||
Send us a message
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p className="mt-2 text-muted-foreground">
|
<p className="mt-2 text-muted-foreground">
|
||||||
We’ll get back to you as soon as possible.
|
We’ll get back to you as soon as possible.
|
||||||
@@ -559,26 +543,26 @@ export default function EDRFreightLandingPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="mt-6 text-lg leading-8 opacity-85">
|
<p className="mt-6 text-lg leading-8 opacity-85">
|
||||||
Centralize logistics workflows, optimize freight movement,
|
Centralize logistics workflows, optimize freight movement, and
|
||||||
and gain real-time operational visibility across all
|
gain real-time operational visibility across all railway
|
||||||
railway corridors.
|
corridors.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-10 flex flex-wrap items-center justify-center gap-4">
|
<div className="mt-10 flex flex-wrap items-center justify-center gap-4">
|
||||||
<a
|
<Link
|
||||||
href="http://localhost:5173/signup"
|
to="/signup"
|
||||||
className="flex items-center gap-2 rounded-2xl bg-white px-7 py-3 font-semibold text-primary shadow-lg transition hover:opacity-90"
|
className="flex items-center gap-2 rounded-2xl bg-white px-7 py-3 font-semibold text-primary shadow-lg transition hover:opacity-90"
|
||||||
>
|
>
|
||||||
Create Account
|
Create Account
|
||||||
<ArrowRight className="size-5" />
|
<ArrowRight className="size-5" />
|
||||||
</a>
|
</Link>
|
||||||
|
|
||||||
<a
|
<Link
|
||||||
href="http://localhost:5173/auth"
|
to="/login"
|
||||||
className="rounded-2xl border border-white/20 bg-white/10 px-7 py-3 font-semibold backdrop-blur transition hover:bg-white/20"
|
className="rounded-2xl border border-white/20 bg-white/10 px-7 py-3 font-semibold backdrop-blur transition hover:bg-white/20"
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -594,17 +578,13 @@ export default function EDRFreightLandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-foreground">
|
<p className="font-semibold text-foreground">EDR Freight</p>
|
||||||
EDR Freight
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>Modern railway logistics management platform</p>
|
<p>Modern railway logistics management platform</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>© 2026 EDR Freight. All rights reserved.</div>
|
||||||
© 2026 EDR Freight. All rights reserved.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
360
apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx
Normal file
360
apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
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 {
|
||||||
|
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";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
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-background p-6">
|
||||||
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
|
{/* Welcome banner */}
|
||||||
|
<div className="overflow-hidden rounded-3xl bg-gradient-to-r from-[#059669] to-[#10B981] 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">
|
||||||
|
<Link to="/bookings/new">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-white text-[#10B981] hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
New Booking
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link to="/tracking">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-white/40 text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Truck className="h-4 w-4" />
|
||||||
|
Track Shipment
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Shipments */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Active Shipments</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Live tracking for your in-flight cargo
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/tracking"
|
||||||
|
className="inline-flex items-center gap-1 text-sm font-medium text-primary transition hover:underline"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
{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-primary/20 hover:bg-primary/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-primary" />
|
||||||
|
{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-primary transition-all"
|
||||||
|
style={{ width: `${shipment.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recent bookings */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Recent Bookings</CardTitle>
|
||||||
|
<CardDescription>Your latest freight requests</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/bookings"
|
||||||
|
className="inline-flex items-center gap-1 text-sm font-medium text-primary transition hover:underline"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="px-0">
|
||||||
|
{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="px-6 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="px-6 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-primary/5"
|
||||||
|
>
|
||||||
|
<td className="px-6 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="px-6 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-primary/10 hover:text-primary"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Invoices */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Recent Invoices</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{outstandingInvoices.length} outstanding · {myInvoices.length}{" "}
|
||||||
|
total
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/billing"
|
||||||
|
className="inline-flex items-center gap-1 text-sm font-medium text-primary transition hover:underline"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
{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-primary/20 hover:bg-primary/5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Receipt className="h-4 w-4 text-primary" />
|
||||||
|
<InvoiceBadge status={invoice.status} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 pt-2 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>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</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-primary">{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
export interface DeleteDropdownSettingDialogProps {
|
|
||||||
settingLabel: string;
|
|
||||||
settingCode: string;
|
|
||||||
onConfirm?: () => void;
|
|
||||||
children?: ReactNode;
|
|
||||||
open?: boolean;
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DeleteDropdownSettingDialog({
|
|
||||||
settingLabel,
|
|
||||||
settingCode,
|
|
||||||
onConfirm,
|
|
||||||
children,
|
|
||||||
open: openProp,
|
|
||||||
onOpenChange,
|
|
||||||
}: DeleteDropdownSettingDialogProps) {
|
|
||||||
const isControlled = openProp !== undefined;
|
|
||||||
const [internalOpen, setInternalOpen] = useState(false);
|
|
||||||
const open = isControlled ? openProp : internalOpen;
|
|
||||||
const setOpen = (next: boolean) => {
|
|
||||||
if (!isControlled) setInternalOpen(next);
|
|
||||||
onOpenChange?.(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
|
|
||||||
|
|
||||||
<DialogContent className="sm:max-w-md rounded-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl font-bold">
|
|
||||||
Delete dropdown setting?
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
This will remove{" "}
|
|
||||||
<span className="font-semibold text-slate-900">{settingLabel}</span>{" "}
|
|
||||||
(<span className="font-mono text-xs">{settingCode}</span>) and all
|
|
||||||
of its options. Forms referencing this code will fall back to
|
|
||||||
empty options.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-2">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
</DialogClose>
|
|
||||||
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button
|
|
||||||
onClick={onConfirm}
|
|
||||||
className="bg-red-600 text-white hover:bg-red-700"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
export interface DeleteFileUploadSettingDialogProps {
|
|
||||||
settingLabel: string;
|
|
||||||
settingCode: string;
|
|
||||||
onConfirm?: () => void;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DeleteFileUploadSettingDialog({
|
|
||||||
settingLabel,
|
|
||||||
settingCode,
|
|
||||||
onConfirm,
|
|
||||||
children,
|
|
||||||
}: DeleteFileUploadSettingDialogProps) {
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="sm:max-w-md rounded-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl font-bold">
|
|
||||||
Delete file upload setting?
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
This will remove{" "}
|
|
||||||
<span className="font-semibold text-slate-900">{settingLabel}</span>{" "}
|
|
||||||
(<span className="font-mono text-xs">{settingCode}</span>) and all
|
|
||||||
of its fields. Forms referencing this code will fall back to no
|
|
||||||
uploads.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-2">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
</DialogClose>
|
|
||||||
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button
|
|
||||||
onClick={onConfirm}
|
|
||||||
className="bg-red-600 text-white hover:bg-red-700"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,468 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
Boxes,
|
|
||||||
CheckCircle2,
|
|
||||||
Eye,
|
|
||||||
Filter,
|
|
||||||
ListOrdered,
|
|
||||||
Loader2,
|
|
||||||
MoreHorizontal,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
Settings,
|
|
||||||
Shield,
|
|
||||||
Sparkles,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
|
||||||
import EditDropdownSettingDialog from "./EditDropdownSettingDialog";
|
|
||||||
import ManageDropdownOptionsDialog from "./ManageDropdownOptionsDialog";
|
|
||||||
import DeleteDropdownSettingDialog from "./DeleteDropdownSettingDialog";
|
|
||||||
import {
|
|
||||||
useDeleteDropdownSetting,
|
|
||||||
useDropdownSettings,
|
|
||||||
} from "@/hooks/useDropdownSettings";
|
|
||||||
import type { DropdownSetting } from "@/types/dropdownSettings";
|
|
||||||
import {
|
|
||||||
DataTable,
|
|
||||||
DataTableFooter,
|
|
||||||
type ColumnDef,
|
|
||||||
usePagination,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
Input,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
type ActiveDialog = "edit" | "options" | "delete";
|
|
||||||
|
|
||||||
export default function DropdownSettingsPage() {
|
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
|
|
||||||
const [activeDialog, setActiveDialog] = useState<ActiveDialog | null>(null);
|
|
||||||
const [activeSetting, setActiveSetting] = useState<DropdownSetting | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const openDialogFor = (dialog: ActiveDialog, setting: DropdownSetting) => {
|
|
||||||
// Defer past the DropdownMenu's close cycle. Radix's modal lock can leave
|
|
||||||
// `pointer-events: none` on <body> when a menu closes and a dialog opens
|
|
||||||
// in the same frame — wait two RAFs and then explicitly reset the body
|
|
||||||
// style so the dialog interior is interactive.
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
document.body.style.pointerEvents = "";
|
|
||||||
setActiveSetting(setting);
|
|
||||||
setActiveDialog(dialog);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const closeDialog = () => {
|
|
||||||
setActiveDialog(null);
|
|
||||||
// Keep activeSetting briefly so dialog content doesn't flash empty during
|
|
||||||
// the close animation; cleared on next open.
|
|
||||||
};
|
|
||||||
|
|
||||||
// Belt-and-suspenders for the Radix pointer-events leak: any time the active
|
|
||||||
// dialog changes, schedule a body-style cleanup after the next paint.
|
|
||||||
useEffect(() => {
|
|
||||||
const id = requestAnimationFrame(() => {
|
|
||||||
if (document.body.style.pointerEvents === "none") {
|
|
||||||
document.body.style.pointerEvents = "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => cancelAnimationFrame(id);
|
|
||||||
}, [activeDialog]);
|
|
||||||
|
|
||||||
const { data, isLoading, isError, error } = useDropdownSettings();
|
|
||||||
const deleteMutation = useDeleteDropdownSetting();
|
|
||||||
|
|
||||||
const dropdownSettings = useMemo<DropdownSetting[]>(
|
|
||||||
() => (Array.isArray(data) ? data : []),
|
|
||||||
[data],
|
|
||||||
);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
if (!q) return dropdownSettings;
|
|
||||||
return dropdownSettings.filter(
|
|
||||||
(s) =>
|
|
||||||
s.code.toLowerCase().includes(q) ||
|
|
||||||
s.label.toLowerCase().includes(q) ||
|
|
||||||
(s.description ?? "").toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
}, [dropdownSettings, query]);
|
|
||||||
|
|
||||||
const total = filtered.length;
|
|
||||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
|
||||||
const start = pagination.pageIndex * pagination.pageSize;
|
|
||||||
const end = Math.min(start + pagination.pageSize, total);
|
|
||||||
|
|
||||||
const paginatedData = useMemo(
|
|
||||||
() => filtered.slice(start, end),
|
|
||||||
[start, end, filtered],
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalOptions = dropdownSettings.reduce(
|
|
||||||
(sum, s) => sum + (s.children?.length ?? 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const multipleCount = dropdownSettings.filter((s) => s.multiple).length;
|
|
||||||
const searchableCount = dropdownSettings.filter(
|
|
||||||
(s) => s.meta?.searchable,
|
|
||||||
).length;
|
|
||||||
|
|
||||||
const status: "loading" | "error" | "success" = isLoading
|
|
||||||
? "loading"
|
|
||||||
: isError
|
|
||||||
? "error"
|
|
||||||
: "success";
|
|
||||||
|
|
||||||
const columns: ColumnDef<DropdownSetting>[] = [
|
|
||||||
{
|
|
||||||
id: "setting",
|
|
||||||
header: "Setting",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const s = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
|
||||||
<Settings />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-slate-900">{s.label}</p>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
{s.description ?? "No description"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "code",
|
|
||||||
header: "Code",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
|
|
||||||
{row.original.code}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "options",
|
|
||||||
header: "Options",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const s = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-700">
|
|
||||||
<Boxes />
|
|
||||||
<span className="font-medium">{s.children?.length ?? 0}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "behavior",
|
|
||||||
header: "Behavior",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const s = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{s.multiple ? (
|
|
||||||
<BehaviorChip label="Multi" />
|
|
||||||
) : (
|
|
||||||
<BehaviorChip label="Single" muted />
|
|
||||||
)}
|
|
||||||
{s.meta?.searchable ? <BehaviorChip label="Searchable" /> : null}
|
|
||||||
{s.meta?.clearable ? <BehaviorChip label="Clearable" /> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "permissions",
|
|
||||||
header: "Permissions",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const s = row.original;
|
|
||||||
const perms = s.meta?.permissions ?? [];
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
|
||||||
{perms.length === 0 ? (
|
|
||||||
<span className="text-xs text-slate-400">—</span>
|
|
||||||
) : (
|
|
||||||
perms.map((p) => (
|
|
||||||
<span
|
|
||||||
key={p}
|
|
||||||
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
|
|
||||||
>
|
|
||||||
<Shield />
|
|
||||||
{p}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
size: 40,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const setting = row.original;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex justify-end"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<DropdownMenu modal={false}>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon">
|
|
||||||
<MoreHorizontal />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Eye />
|
|
||||||
View
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => openDialogFor("options", setting)}
|
|
||||||
>
|
|
||||||
<CheckCircle2 />
|
|
||||||
Options
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => openDialogFor("edit", setting)}
|
|
||||||
>
|
|
||||||
<Pencil />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => openDialogFor("delete", setting)}
|
|
||||||
variant="destructive"
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen p-6">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Breadcrumbs
|
|
||||||
items={[
|
|
||||||
{ label: "Admin", href: "/admin" },
|
|
||||||
{ label: "Dropdown Settings" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card className="p-6 flex-row justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
|
||||||
Dropdown Settings
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-secondary-foreground">
|
|
||||||
Manage every dynamic dropdown across the platform — labels,
|
|
||||||
options, ordering, and permissions.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
|
||||||
<div className="relative w-full sm:w-80">
|
|
||||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="search"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => {
|
|
||||||
setQuery(e.target.value);
|
|
||||||
setPagination({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
placeholder="Search by code, label, description..."
|
|
||||||
className="pl-8!"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EditDropdownSettingDialog mode="create">
|
|
||||||
<Button>
|
|
||||||
<Plus />
|
|
||||||
New Setting
|
|
||||||
</Button>
|
|
||||||
</EditDropdownSettingDialog>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
|
||||||
<StatCard
|
|
||||||
label="Settings"
|
|
||||||
value={dropdownSettings.length}
|
|
||||||
icon={<Settings />}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Total Options"
|
|
||||||
value={totalOptions}
|
|
||||||
icon={<Boxes />}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Multi-select"
|
|
||||||
value={multipleCount}
|
|
||||||
icon={<ListOrdered />}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Searchable"
|
|
||||||
value={searchableCount}
|
|
||||||
icon={<Sparkles />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isError ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center gap-3 py-6 text-sm text-red-600">
|
|
||||||
<AlertCircle className="h-5 w-5" />
|
|
||||||
Failed to load dropdown settings.{" "}
|
|
||||||
{error instanceof Error ? error.message : "Unknown error."}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Card className="gap-0">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between border-b">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Registered Dropdowns</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Every dynamic dropdown the platform reads from.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button variant="secondary" size="sm">
|
|
||||||
<Filter />
|
|
||||||
Filter
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="px-0">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin text-primary" />
|
|
||||||
Loading dropdown settings…
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={paginatedData}
|
|
||||||
status={status}
|
|
||||||
onRowClick={() => { }}
|
|
||||||
pagination={{
|
|
||||||
pageIndex: pagination.pageIndex,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
pageCount: pageCount,
|
|
||||||
totalCount: total,
|
|
||||||
}}
|
|
||||||
tableOptions={{
|
|
||||||
state: { pagination },
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
}}
|
|
||||||
containerClassName="border-b shadow-none"
|
|
||||||
footer={DataTableFooter}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controlled dialogs — hoisted out of the DropdownMenu so they can open
|
|
||||||
reliably after a menu item is selected. */}
|
|
||||||
{activeSetting ? (
|
|
||||||
<>
|
|
||||||
<EditDropdownSettingDialog
|
|
||||||
key={`edit-${activeSetting.id}`}
|
|
||||||
mode="edit"
|
|
||||||
setting={activeSetting}
|
|
||||||
open={activeDialog === "edit"}
|
|
||||||
onOpenChange={(next) => (next ? null : closeDialog())}
|
|
||||||
/>
|
|
||||||
<ManageDropdownOptionsDialog
|
|
||||||
key={`options-${activeSetting.id}`}
|
|
||||||
setting={activeSetting}
|
|
||||||
open={activeDialog === "options"}
|
|
||||||
onOpenChange={(next) => (next ? null : closeDialog())}
|
|
||||||
/>
|
|
||||||
<DeleteDropdownSettingDialog
|
|
||||||
key={`delete-${activeSetting.id}`}
|
|
||||||
settingLabel={activeSetting.label}
|
|
||||||
settingCode={activeSetting.code}
|
|
||||||
onConfirm={() => deleteMutation.mutate(activeSetting.id)}
|
|
||||||
open={activeDialog === "delete"}
|
|
||||||
onOpenChange={(next) => (next ? null : closeDialog())}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
icon,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">{label}</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BehaviorChip({
|
|
||||||
label,
|
|
||||||
muted = false,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
muted?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
muted
|
|
||||||
? "rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600"
|
|
||||||
: "rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
|
||||||
import { Hash, Loader2 } from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
CreateDropdownSettingDto,
|
|
||||||
DropdownSetting,
|
|
||||||
UpdateDropdownSettingDto,
|
|
||||||
} from "@/types/dropdownSettings";
|
|
||||||
import {
|
|
||||||
useCreateDropdownSetting,
|
|
||||||
useUpdateDropdownSetting,
|
|
||||||
} from "@/hooks/useDropdownSettings";
|
|
||||||
|
|
||||||
export interface EditDropdownSettingDialogProps {
|
|
||||||
mode?: "create" | "edit";
|
|
||||||
setting?: DropdownSetting;
|
|
||||||
/** Optional trigger element. When omitted, the dialog renders content only and is fully controlled. */
|
|
||||||
children?: ReactNode;
|
|
||||||
/** Controlled open state. When provided, internal state is ignored. */
|
|
||||||
open?: boolean;
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePermissions(raw: string): string[] {
|
|
||||||
return raw
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EditDropdownSettingDialog({
|
|
||||||
mode = "create",
|
|
||||||
setting,
|
|
||||||
children,
|
|
||||||
open: openProp,
|
|
||||||
onOpenChange,
|
|
||||||
}: EditDropdownSettingDialogProps) {
|
|
||||||
const isEdit = mode === "edit";
|
|
||||||
const isControlled = openProp !== undefined;
|
|
||||||
|
|
||||||
const [internalOpen, setInternalOpen] = useState(false);
|
|
||||||
const open = isControlled ? openProp : internalOpen;
|
|
||||||
const setOpen = (next: boolean) => {
|
|
||||||
if (!isControlled) setInternalOpen(next);
|
|
||||||
onOpenChange?.(next);
|
|
||||||
};
|
|
||||||
const [code, setCode] = useState(setting?.code ?? "");
|
|
||||||
const [label, setLabel] = useState(setting?.label ?? "");
|
|
||||||
const [description, setDescription] = useState(setting?.description ?? "");
|
|
||||||
const [icon, setIcon] = useState(setting?.meta?.icon ?? "");
|
|
||||||
const [color, setColor] = useState(setting?.meta?.color ?? "");
|
|
||||||
const [permissions, setPermissions] = useState(
|
|
||||||
setting?.meta?.permissions?.join(", ") ?? "",
|
|
||||||
);
|
|
||||||
const [version, setVersion] = useState(setting?.meta?.version ?? "1.0");
|
|
||||||
const [multiple, setMultiple] = useState<boolean>(setting?.multiple ?? false);
|
|
||||||
const [searchable, setSearchable] = useState<boolean>(
|
|
||||||
setting?.meta?.searchable ?? false,
|
|
||||||
);
|
|
||||||
const [clearable, setClearable] = useState<boolean>(
|
|
||||||
setting?.meta?.clearable ?? false,
|
|
||||||
);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const createMutation = useCreateDropdownSetting();
|
|
||||||
const updateMutation = useUpdateDropdownSetting();
|
|
||||||
const pending = createMutation.isPending || updateMutation.isPending;
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
setCode(setting?.code ?? "");
|
|
||||||
setLabel(setting?.label ?? "");
|
|
||||||
setDescription(setting?.description ?? "");
|
|
||||||
setIcon(setting?.meta?.icon ?? "");
|
|
||||||
setColor(setting?.meta?.color ?? "");
|
|
||||||
setPermissions(setting?.meta?.permissions?.join(", ") ?? "");
|
|
||||||
setVersion(setting?.meta?.version ?? "1.0");
|
|
||||||
setMultiple(setting?.multiple ?? false);
|
|
||||||
setSearchable(setting?.meta?.searchable ?? false);
|
|
||||||
setClearable(setting?.meta?.clearable ?? false);
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildPayload = (): CreateDropdownSettingDto => ({
|
|
||||||
code: code.trim(),
|
|
||||||
label: label.trim(),
|
|
||||||
description: description.trim() || undefined,
|
|
||||||
multiple,
|
|
||||||
meta: {
|
|
||||||
...(icon.trim() ? { icon: icon.trim() } : {}),
|
|
||||||
...(color.trim() ? { color: color.trim() } : {}),
|
|
||||||
searchable,
|
|
||||||
clearable,
|
|
||||||
...(version.trim() ? { version: version.trim() } : {}),
|
|
||||||
permissions: parsePermissions(permissions),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
setError(null);
|
|
||||||
if (!code.trim() || !label.trim()) {
|
|
||||||
setError("Code and label are required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!/^[a-z][a-z0-9_]*$/i.test(code.trim())) {
|
|
||||||
setError(
|
|
||||||
"Code must start with a letter and contain only letters, digits, or underscores.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = buildPayload();
|
|
||||||
|
|
||||||
const onDone = () => {
|
|
||||||
setOpen(false);
|
|
||||||
if (!isEdit) reset();
|
|
||||||
};
|
|
||||||
const onError = (err: unknown) => {
|
|
||||||
setError(
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "Something went wrong. Try again.",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEdit && setting) {
|
|
||||||
// Update DTO omits `code` (immutable); strip it before sending.
|
|
||||||
const { code: _unused, ...updateDto } = payload;
|
|
||||||
void _unused;
|
|
||||||
updateMutation.mutate(
|
|
||||||
{ id: setting.id, dto: updateDto as UpdateDropdownSettingDto },
|
|
||||||
{ onSuccess: onDone, onError },
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
createMutation.mutate(payload, { onSuccess: onDone, onError });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(next) => {
|
|
||||||
setOpen(next);
|
|
||||||
if (!next) reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
|
|
||||||
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl rounded-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-2xl font-bold">
|
|
||||||
{isEdit ? "Edit Dropdown Setting" : "New Dropdown Setting"}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{isEdit
|
|
||||||
? "Update the metadata for this dropdown setting."
|
|
||||||
: "Define a new dynamic dropdown that admins can manage."}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Code *</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
value={code}
|
|
||||||
onChange={(e) => setCode(e.target.value)}
|
|
||||||
placeholder="e.g. cargo_type"
|
|
||||||
className="pl-10 font-mono"
|
|
||||||
disabled={isEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
{isEdit
|
|
||||||
? "Code is immutable after creation."
|
|
||||||
: "Stable identifier used in code. Use snake_case."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Label *</Label>
|
|
||||||
<Input
|
|
||||||
value={label}
|
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
|
||||||
placeholder="e.g. Cargo Type"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label>Description</Label>
|
|
||||||
<Textarea
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="What this dropdown represents and where it's used..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Icon (meta.icon)</Label>
|
|
||||||
<Input
|
|
||||||
value={icon}
|
|
||||||
onChange={(e) => setIcon(e.target.value)}
|
|
||||||
placeholder="lucide icon name, e.g. package"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Color (meta.color)</Label>
|
|
||||||
<Input
|
|
||||||
value={color}
|
|
||||||
onChange={(e) => setColor(e.target.value)}
|
|
||||||
placeholder="#10B981"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Permissions (comma-separated)</Label>
|
|
||||||
<Input
|
|
||||||
value={permissions}
|
|
||||||
onChange={(e) => setPermissions(e.target.value)}
|
|
||||||
placeholder="admin, ops"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Version (meta.version)</Label>
|
|
||||||
<Input
|
|
||||||
value={version}
|
|
||||||
onChange={(e) => setVersion(e.target.value)}
|
|
||||||
placeholder="1.0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label>Behavior</Label>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<ToggleChip
|
|
||||||
checked={multiple}
|
|
||||||
onChange={setMultiple}
|
|
||||||
label="Multi-select"
|
|
||||||
description="Users can pick more than one option"
|
|
||||||
/>
|
|
||||||
<ToggleChip
|
|
||||||
checked={searchable}
|
|
||||||
onChange={setSearchable}
|
|
||||||
label="Searchable"
|
|
||||||
description="Show a search input in the dropdown"
|
|
||||||
/>
|
|
||||||
<ToggleChip
|
|
||||||
checked={clearable}
|
|
||||||
onChange={setClearable}
|
|
||||||
label="Clearable"
|
|
||||||
description="Allow users to clear the selection"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mt-2 flex justify-end gap-3">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline" disabled={pending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={pending}
|
|
||||||
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{pending ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : isEdit ? (
|
|
||||||
"Save Changes"
|
|
||||||
) : (
|
|
||||||
"Create Setting"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToggleChip({
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
}: {
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (next: boolean) => void;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
className={
|
|
||||||
checked
|
|
||||||
? "flex cursor-pointer items-start gap-2 rounded-2xl border border-[#10B981]/40 bg-[#10B981]/10 px-3 py-2 text-sm"
|
|
||||||
: "flex cursor-pointer items-start gap-2 rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm transition hover:border-[#10B981]/30 hover:bg-[#10B981]/5"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
|
||||||
className="mt-0.5 h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-slate-900">{label}</p>
|
|
||||||
<p className="text-xs text-slate-500">{description}</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
|
||||||
import { Hash, Loader2 } from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
FileUploadEntity,
|
|
||||||
FileUploadSetting,
|
|
||||||
} from "@/types/fileUploadSettings";
|
|
||||||
import {
|
|
||||||
useCreateFileUploadSetting,
|
|
||||||
useUpdateFileUploadSetting,
|
|
||||||
} from "@/hooks/useFileUploadSettings";
|
|
||||||
|
|
||||||
export interface EditFileUploadSettingDialogProps {
|
|
||||||
mode?: "create" | "edit";
|
|
||||||
setting?: FileUploadSetting;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectClass =
|
|
||||||
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20";
|
|
||||||
|
|
||||||
const ENTITIES: FileUploadEntity[] = [
|
|
||||||
"customer",
|
|
||||||
"booking",
|
|
||||||
"consignment",
|
|
||||||
"shipment",
|
|
||||||
"invoice",
|
|
||||||
"train",
|
|
||||||
"other",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function EditFileUploadSettingDialog({
|
|
||||||
mode = "create",
|
|
||||||
setting,
|
|
||||||
children,
|
|
||||||
}: EditFileUploadSettingDialogProps) {
|
|
||||||
const isEdit = mode === "edit";
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [code, setCode] = useState(setting?.code ?? "");
|
|
||||||
const [label, setLabel] = useState(setting?.label ?? "");
|
|
||||||
const [entity, setEntity] = useState<FileUploadEntity>(
|
|
||||||
setting?.entity ?? "other",
|
|
||||||
);
|
|
||||||
const [description, setDescription] = useState(setting?.description ?? "");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const createMutation = useCreateFileUploadSetting();
|
|
||||||
const updateMutation = useUpdateFileUploadSetting();
|
|
||||||
const pending = createMutation.isPending || updateMutation.isPending;
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
setCode(setting?.code ?? "");
|
|
||||||
setLabel(setting?.label ?? "");
|
|
||||||
setEntity(setting?.entity ?? "other");
|
|
||||||
setDescription(setting?.description ?? "");
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
setError(null);
|
|
||||||
if (!code.trim() || !label.trim()) {
|
|
||||||
setError("Code and label are required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
code: code.trim(),
|
|
||||||
label: label.trim(),
|
|
||||||
entity,
|
|
||||||
description: description.trim() || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDone = () => {
|
|
||||||
setOpen(false);
|
|
||||||
if (!isEdit) reset();
|
|
||||||
};
|
|
||||||
const onError = (err: unknown) => {
|
|
||||||
setError(
|
|
||||||
err instanceof Error ? err.message : "Something went wrong. Try again.",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEdit && setting) {
|
|
||||||
updateMutation.mutate(
|
|
||||||
{ id: setting.id, dto: payload },
|
|
||||||
{ onSuccess: onDone, onError },
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
createMutation.mutate(payload, { onSuccess: onDone, onError });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(next) => {
|
|
||||||
setOpen(next);
|
|
||||||
if (!next) reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl rounded-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-2xl font-bold">
|
|
||||||
{isEdit ? "Edit File Upload Setting" : "New File Upload Setting"}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{isEdit
|
|
||||||
? "Update the metadata for this file upload group."
|
|
||||||
: "Define a new file upload group that a form can reference by code."}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Code *</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
value={code}
|
|
||||||
onChange={(e) => setCode(e.target.value)}
|
|
||||||
placeholder="e.g. customer_registration"
|
|
||||||
className="pl-10 font-mono"
|
|
||||||
disabled={isEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
{isEdit
|
|
||||||
? "Code is immutable after creation."
|
|
||||||
: "Stable identifier used in code. Use snake_case."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Label *</Label>
|
|
||||||
<Input
|
|
||||||
value={label}
|
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
|
||||||
placeholder="e.g. Customer Registration"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Entity</Label>
|
|
||||||
<select
|
|
||||||
value={entity}
|
|
||||||
onChange={(e) => setEntity(e.target.value as FileUploadEntity)}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
{ENTITIES.map((e) => (
|
|
||||||
<option key={e} value={e} className="capitalize">
|
|
||||||
{e[0]!.toUpperCase() + e.slice(1)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
Domain the upload group applies to.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Field Count</Label>
|
|
||||||
<Input
|
|
||||||
disabled
|
|
||||||
value={String(setting?.fields.length ?? 0)}
|
|
||||||
className="bg-slate-50 text-slate-600"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
Manage fields from the "Fields" action on the list.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label>Description</Label>
|
|
||||||
<Textarea
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="What this upload group represents and where it's used..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mt-2 flex justify-end gap-3">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline" disabled={pending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={pending}
|
|
||||||
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{pending ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : isEdit ? (
|
|
||||||
"Save Changes"
|
|
||||||
) : (
|
|
||||||
"Create Setting"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,460 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
Filter,
|
|
||||||
FileUp,
|
|
||||||
HardDrive,
|
|
||||||
Layers,
|
|
||||||
Loader2,
|
|
||||||
Paperclip,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
Settings,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
|
||||||
import EditFileUploadSettingDialog from "./EditFileUploadSettingDialog";
|
|
||||||
import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog";
|
|
||||||
import DeleteFileUploadSettingDialog from "./DeleteFileUploadSettingDialog";
|
|
||||||
import {
|
|
||||||
useDeleteFileUploadSetting,
|
|
||||||
useFileUploadSettings,
|
|
||||||
} from "@/hooks/useFileUploadSettings";
|
|
||||||
import { getMinFiles } from "@/types/fileUploadSettings";
|
|
||||||
|
|
||||||
export default function FileUploadSettingsPage() {
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
|
|
||||||
const { data, isLoading, isError, error } = useFileUploadSettings();
|
|
||||||
const deleteMutation = useDeleteFileUploadSetting();
|
|
||||||
|
|
||||||
const fileUploadSettings = useMemo(
|
|
||||||
() => (Array.isArray(data) ? data : []),
|
|
||||||
[data],
|
|
||||||
);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
if (!q) return fileUploadSettings;
|
|
||||||
return fileUploadSettings.filter(
|
|
||||||
(s) =>
|
|
||||||
s.code.toLowerCase().includes(q) ||
|
|
||||||
s.label.toLowerCase().includes(q) ||
|
|
||||||
(s.description ?? "").toLowerCase().includes(q) ||
|
|
||||||
s.fields.some(
|
|
||||||
(f) =>
|
|
||||||
f.fileKey.toLowerCase().includes(q) ||
|
|
||||||
f.fileLabel.toLowerCase().includes(q),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, [fileUploadSettings, query]);
|
|
||||||
|
|
||||||
const totalFields = fileUploadSettings.reduce(
|
|
||||||
(sum, s) => sum + s.fields.length,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const requiredFields = fileUploadSettings.reduce(
|
|
||||||
(sum, s) => sum + s.fields.filter((f) => f.isRequired).length,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const multiFields = fileUploadSettings.reduce(
|
|
||||||
(sum, s) => sum + s.fields.filter((f) => f.isMultiple).length,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-slate-50 p-6">
|
|
||||||
<div className="mx-auto max-w-7xl space-y-6">
|
|
||||||
<Breadcrumbs
|
|
||||||
items={[
|
|
||||||
{ label: "Admin", href: "/admin" },
|
|
||||||
{ label: "File Upload Settings" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-sm md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
|
||||||
File Upload Settings
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
|
||||||
Define the file inputs every form in the platform should render —
|
|
||||||
required/optional, single/multiple, allowed types and size.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
|
||||||
<div className="relative w-full sm:w-80">
|
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
placeholder="Search by code, label, or file key..."
|
|
||||||
className="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EditFileUploadSettingDialog mode="create">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
New Setting
|
|
||||||
</button>
|
|
||||||
</EditFileUploadSettingDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
|
||||||
<StatCard
|
|
||||||
title="Settings"
|
|
||||||
value={String(fileUploadSettings.length)}
|
|
||||||
icon={<Settings className="h-5 w-5" />}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Total Fields"
|
|
||||||
value={String(totalFields)}
|
|
||||||
icon={<Paperclip className="h-5 w-5" />}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Required"
|
|
||||||
value={String(requiredFields)}
|
|
||||||
icon={<FileUp className="h-5 w-5" />}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Multi-file"
|
|
||||||
value={String(multiFields)}
|
|
||||||
icon={<Layers className="h-5 w-5" />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<div className="overflow-hidden rounded-3xl bg-white shadow-sm">
|
|
||||||
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900">
|
|
||||||
Registered File Upload Groups
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-slate-500">
|
|
||||||
Every group a form can reference by code.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]">
|
|
||||||
<Filter className="h-4 w-4" />
|
|
||||||
Filter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full min-w-[1100px] whitespace-nowrap text-left">
|
|
||||||
<thead className="bg-slate-50 text-sm text-slate-500">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-4 font-medium">Setting</th>
|
|
||||||
<th className="px-6 py-4 font-medium">Code</th>
|
|
||||||
<th className="px-6 py-4 font-medium">Entity</th>
|
|
||||||
<th className="px-6 py-4 font-medium">Fields</th>
|
|
||||||
<th className="px-6 py-4 font-medium">Required / Multi</th>
|
|
||||||
<th className="px-6 py-4 font-medium">Max Size</th>
|
|
||||||
<th className="px-6 py-4 font-medium text-right">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody>
|
|
||||||
{isLoading ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} className="px-6 py-12 text-center">
|
|
||||||
<Loader2 className="mx-auto h-6 w-6 animate-spin text-[#10B981]" />
|
|
||||||
<p className="mt-2 text-sm text-slate-500">
|
|
||||||
Loading file upload settings…
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : isError ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} className="px-6 py-12 text-center">
|
|
||||||
<AlertCircle className="mx-auto h-6 w-6 text-red-500" />
|
|
||||||
<p className="mt-2 text-sm text-red-600">
|
|
||||||
Failed to load settings.{" "}
|
|
||||||
{error instanceof Error ? error.message : "Unknown error."}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : filtered.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={7}
|
|
||||||
className="px-6 py-12 text-center text-sm text-slate-500"
|
|
||||||
>
|
|
||||||
{fileUploadSettings.length === 0
|
|
||||||
? "No file upload settings yet. Click \"New Setting\" to add one."
|
|
||||||
: "No file upload settings match your search."}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
filtered.map((setting) => {
|
|
||||||
const required = setting.fields.filter(
|
|
||||||
(f) => f.isRequired,
|
|
||||||
).length;
|
|
||||||
const multi = setting.fields.filter(
|
|
||||||
(f) => f.isMultiple,
|
|
||||||
).length;
|
|
||||||
const maxSize = Math.max(
|
|
||||||
0,
|
|
||||||
...setting.fields.map((f) => f.maxSizeMb),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={setting.id}
|
|
||||||
className="border-t border-slate-100 transition hover:bg-[#10B981]/5"
|
|
||||||
>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#10B981] text-white">
|
|
||||||
<FileUp className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-slate-900">
|
|
||||||
{setting.label}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
{setting.description ?? "No description"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
|
|
||||||
{setting.code}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span className="inline-flex rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium capitalize text-slate-600">
|
|
||||||
{setting.entity ?? "—"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-700">
|
|
||||||
<Paperclip className="h-4 w-4 text-[#10B981]" />
|
|
||||||
<span className="font-medium">
|
|
||||||
{setting.fields.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="px-6 py-4 text-sm text-slate-700">
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
|
||||||
<Chip>{required} required</Chip>
|
|
||||||
<Chip muted>{multi} multi</Chip>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="px-6 py-4 text-sm text-slate-700">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<HardDrive className="h-4 w-4 text-slate-400" />
|
|
||||||
{maxSize ? `${maxSize} MB` : "—"}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<ManageFileUploadFieldsDialog setting={setting}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-1 rounded-xl border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
|
||||||
>
|
|
||||||
<Paperclip className="h-3.5 w-3.5" />
|
|
||||||
Fields
|
|
||||||
</button>
|
|
||||||
</ManageFileUploadFieldsDialog>
|
|
||||||
|
|
||||||
<EditFileUploadSettingDialog
|
|
||||||
mode="edit"
|
|
||||||
setting={setting}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</EditFileUploadSettingDialog>
|
|
||||||
|
|
||||||
<DeleteFileUploadSettingDialog
|
|
||||||
settingLabel={setting.label}
|
|
||||||
settingCode={setting.code}
|
|
||||||
onConfirm={() =>
|
|
||||||
deleteMutation.mutate(setting.id)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</DeleteFileUploadSettingDialog>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Behavior reference card */}
|
|
||||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900">
|
|
||||||
Required × Multiple behavior
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
|
||||||
Min and max file counts are derived from these two toggles. The
|
|
||||||
"Max Files" you set on a field is only used when{" "}
|
|
||||||
<span className="font-medium">Multiple</span> is on.
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 overflow-x-auto">
|
|
||||||
<table className="w-full whitespace-nowrap text-left text-sm">
|
|
||||||
<thead className="text-xs text-slate-500">
|
|
||||||
<tr>
|
|
||||||
<th className="py-2 font-medium">Required</th>
|
|
||||||
<th className="py-2 font-medium">Multiple</th>
|
|
||||||
<th className="py-2 font-medium">min_files</th>
|
|
||||||
<th className="py-2 font-medium">max_files</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<BehaviorRow
|
|
||||||
required={false}
|
|
||||||
multiple={false}
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
/>
|
|
||||||
<BehaviorRow
|
|
||||||
required={true}
|
|
||||||
multiple={false}
|
|
||||||
min="1"
|
|
||||||
max="1"
|
|
||||||
/>
|
|
||||||
<BehaviorRow
|
|
||||||
required={false}
|
|
||||||
multiple={true}
|
|
||||||
min="0"
|
|
||||||
max="field.maxFiles"
|
|
||||||
/>
|
|
||||||
<BehaviorRow
|
|
||||||
required={true}
|
|
||||||
multiple={true}
|
|
||||||
min="1"
|
|
||||||
max="field.maxFiles"
|
|
||||||
/>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-xs text-slate-500">
|
|
||||||
Helpers <span className="font-mono">getMinFiles</span> and{" "}
|
|
||||||
<span className="font-mono">getEffectiveMaxFiles</span> live in{" "}
|
|
||||||
<span className="font-mono">@/types/fileUploadSettings</span> — use
|
|
||||||
them when wiring real uploaders. Example: a field with{" "}
|
|
||||||
<span className="font-mono">isRequired=false</span>,{" "}
|
|
||||||
<span className="font-mono">isMultiple=true</span>,{" "}
|
|
||||||
<span className="font-mono">maxFiles=5</span> gives{" "}
|
|
||||||
<span className="font-mono">{getMinFiles({
|
|
||||||
id: "demo",
|
|
||||||
fileKey: "demo",
|
|
||||||
fileLabel: "demo",
|
|
||||||
isRequired: false,
|
|
||||||
isMultiple: true,
|
|
||||||
maxFiles: 5,
|
|
||||||
allowedExtensions: [],
|
|
||||||
maxSizeMb: 1,
|
|
||||||
})}</span>
|
|
||||||
…5.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BehaviorRow({
|
|
||||||
required,
|
|
||||||
multiple,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
}: {
|
|
||||||
required: boolean;
|
|
||||||
multiple: boolean;
|
|
||||||
min: string;
|
|
||||||
max: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<tr className="border-t border-slate-100">
|
|
||||||
<td className="py-2.5">
|
|
||||||
<Chip muted={!required}>{required ? "Required" : "Optional"}</Chip>
|
|
||||||
</td>
|
|
||||||
<td className="py-2.5">
|
|
||||||
<Chip muted={!multiple}>{multiple ? "Multiple" : "Single"}</Chip>
|
|
||||||
</td>
|
|
||||||
<td className="py-2.5 font-mono text-slate-700">{min}</td>
|
|
||||||
<td className="py-2.5 font-mono text-slate-700">{max}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Chip({
|
|
||||||
children,
|
|
||||||
muted = false,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
muted?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
muted
|
|
||||||
? "rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600"
|
|
||||||
: "rounded-full bg-[#10B981]/10 px-2 py-0.5 text-xs font-medium text-[#10B981]"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
icon,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
value: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">{title}</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
|
||||||
import { GripVertical, Loader2, Plus, Trash2 } from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
CreateDropdownOptionDto,
|
|
||||||
DropdownSetting,
|
|
||||||
} from "@/types/dropdownSettings";
|
|
||||||
import { useReplaceDropdownOptions } from "@/hooks/useDropdownSettings";
|
|
||||||
|
|
||||||
export interface ManageDropdownOptionsDialogProps {
|
|
||||||
setting: DropdownSetting;
|
|
||||||
children?: ReactNode;
|
|
||||||
open?: boolean;
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Local draft used by the editor — uses a stable client-only `key` so React
|
|
||||||
* keys remain stable across reorders. On save we strip `key` and POST the
|
|
||||||
* remainder as CreateDropdownOptionDto[].
|
|
||||||
*/
|
|
||||||
interface DraftOption extends CreateDropdownOptionDto {
|
|
||||||
key: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let draftCounter = 0;
|
|
||||||
const nextKey = () => `draft-${Date.now()}-${++draftCounter}`;
|
|
||||||
|
|
||||||
function makeEmptyDraft(idx: number): DraftOption {
|
|
||||||
return {
|
|
||||||
key: nextKey(),
|
|
||||||
value: "",
|
|
||||||
label: "",
|
|
||||||
disabled: false,
|
|
||||||
order: idx + 1,
|
|
||||||
meta: {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ManageDropdownOptionsDialog({
|
|
||||||
setting,
|
|
||||||
children,
|
|
||||||
open: openProp,
|
|
||||||
onOpenChange,
|
|
||||||
}: ManageDropdownOptionsDialogProps) {
|
|
||||||
const isControlled = openProp !== undefined;
|
|
||||||
const [internalOpen, setInternalOpen] = useState(false);
|
|
||||||
const open = isControlled ? openProp : internalOpen;
|
|
||||||
const setOpen = (next: boolean) => {
|
|
||||||
if (!isControlled) setInternalOpen(next);
|
|
||||||
onOpenChange?.(next);
|
|
||||||
};
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const seed = (): DraftOption[] =>
|
|
||||||
[...(setting.children ?? [])]
|
|
||||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
||||||
.map((o, idx) => ({
|
|
||||||
key: o.id,
|
|
||||||
value: o.value,
|
|
||||||
label: o.label,
|
|
||||||
note: o.note ?? undefined,
|
|
||||||
disabled: o.disabled,
|
|
||||||
order: o.order ?? idx + 1,
|
|
||||||
meta: {
|
|
||||||
...(o.meta?.icon ? { icon: o.meta.icon } : {}),
|
|
||||||
...(o.meta?.color ? { color: o.meta.color } : {}),
|
|
||||||
...(o.meta?.badge ? { badge: o.meta.badge } : {}),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const [options, setOptions] = useState<DraftOption[]>(seed);
|
|
||||||
|
|
||||||
const replaceMutation = useReplaceDropdownOptions();
|
|
||||||
|
|
||||||
const update = (i: number, patch: Partial<DraftOption>) =>
|
|
||||||
setOptions((prev) =>
|
|
||||||
prev.map((o, idx) => (idx === i ? { ...o, ...patch } : o)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateMeta = (
|
|
||||||
i: number,
|
|
||||||
patch: Partial<NonNullable<DraftOption["meta"]>>,
|
|
||||||
) =>
|
|
||||||
setOptions((prev) =>
|
|
||||||
prev.map((o, idx) =>
|
|
||||||
idx === i ? { ...o, meta: { ...(o.meta ?? {}), ...patch } } : o,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const remove = (i: number) =>
|
|
||||||
setOptions((prev) => prev.filter((_, idx) => idx !== i));
|
|
||||||
|
|
||||||
const add = () =>
|
|
||||||
setOptions((prev) => [...prev, makeEmptyDraft(prev.length)]);
|
|
||||||
|
|
||||||
const move = (i: number, dir: -1 | 1) =>
|
|
||||||
setOptions((prev) => {
|
|
||||||
const next = [...prev];
|
|
||||||
const target = i + dir;
|
|
||||||
if (target < 0 || target >= next.length) return prev;
|
|
||||||
const a = next[i] as DraftOption;
|
|
||||||
const b = next[target] as DraftOption;
|
|
||||||
next[i] = { ...b, order: i + 1 };
|
|
||||||
next[target] = { ...a, order: target + 1 };
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const invalid = options.findIndex(
|
|
||||||
(o) => !o.label.trim() || !o.value.trim(),
|
|
||||||
);
|
|
||||||
if (invalid >= 0) {
|
|
||||||
setError(`Option ${invalid + 1} is missing a label or value.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: CreateDropdownOptionDto[] = options.map((o, idx) => {
|
|
||||||
const meta: NonNullable<CreateDropdownOptionDto["meta"]> = {};
|
|
||||||
if (o.meta?.icon?.trim()) meta.icon = o.meta.icon.trim();
|
|
||||||
if (o.meta?.color?.trim()) meta.color = o.meta.color.trim();
|
|
||||||
if (o.meta?.badge?.trim()) meta.badge = o.meta.badge.trim();
|
|
||||||
|
|
||||||
return {
|
|
||||||
value: o.value.trim(),
|
|
||||||
label: o.label.trim(),
|
|
||||||
note: o.note?.trim() || undefined,
|
|
||||||
disabled: o.disabled ?? false,
|
|
||||||
order: idx + 1,
|
|
||||||
...(Object.keys(meta).length > 0 ? { meta } : {}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
replaceMutation.mutate(
|
|
||||||
{ settingId: setting.id, options: payload },
|
|
||||||
{
|
|
||||||
onSuccess: () => setOpen(false),
|
|
||||||
onError: (err) =>
|
|
||||||
setError(
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "Failed to save options. Try again.",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(next) => {
|
|
||||||
setOpen(next);
|
|
||||||
if (next) setOptions(seed());
|
|
||||||
if (!next) setError(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
|
|
||||||
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl rounded-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-2xl font-bold">
|
|
||||||
Manage Options · {setting.label}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Add, edit, reorder, or remove options for{" "}
|
|
||||||
<span className="font-mono text-slate-700">{setting.code}</span>.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-3 py-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm text-slate-500">
|
|
||||||
{options.length} option{options.length === 1 ? "" : "s"}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={add}
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-xl bg-[#10B981] px-3 py-1.5 text-xs font-medium text-white transition hover:bg-[#10B981]/90"
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
Add Option
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{options.length === 0 ? (
|
|
||||||
<div className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
|
||||||
No options yet. Click{" "}
|
|
||||||
<span className="font-medium">Add Option</span> to start.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{options.map((opt, i) => (
|
|
||||||
<div
|
|
||||||
key={opt.key}
|
|
||||||
className="grid gap-2 rounded-2xl border border-slate-200 bg-white p-3 md:grid-cols-[auto_1fr_1fr_1fr_auto_auto_auto]"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1 text-slate-400">
|
|
||||||
<GripVertical className="h-4 w-4" />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => move(i, -1)}
|
|
||||||
aria-label="Move up"
|
|
||||||
disabled={i === 0}
|
|
||||||
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
|
|
||||||
>
|
|
||||||
▲
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => move(i, 1)}
|
|
||||||
aria-label="Move down"
|
|
||||||
disabled={i === options.length - 1}
|
|
||||||
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
|
|
||||||
>
|
|
||||||
▼
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Label *</Label>
|
|
||||||
<Input
|
|
||||||
value={opt.label}
|
|
||||||
onChange={(e) => update(i, { label: e.target.value })}
|
|
||||||
placeholder="Display label"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Value *</Label>
|
|
||||||
<Input
|
|
||||||
value={opt.value}
|
|
||||||
onChange={(e) => update(i, { value: e.target.value })}
|
|
||||||
placeholder="Stored value"
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Note</Label>
|
|
||||||
<Input
|
|
||||||
value={opt.note ?? ""}
|
|
||||||
onChange={(e) => update(i, { note: e.target.value })}
|
|
||||||
placeholder="Helper text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Badge</Label>
|
|
||||||
<Input
|
|
||||||
value={opt.meta?.badge ?? ""}
|
|
||||||
onChange={(e) => updateMeta(i, { badge: e.target.value })}
|
|
||||||
placeholder="—"
|
|
||||||
className="w-20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Color</Label>
|
|
||||||
<Input
|
|
||||||
value={opt.meta?.color ?? ""}
|
|
||||||
onChange={(e) => updateMeta(i, { color: e.target.value })}
|
|
||||||
placeholder="#…"
|
|
||||||
className="w-24 font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center justify-between gap-2">
|
|
||||||
<label className="flex items-center gap-1 text-xs text-slate-600">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={opt.disabled ?? false}
|
|
||||||
onChange={(e) =>
|
|
||||||
update(i, { disabled: e.target.checked })
|
|
||||||
}
|
|
||||||
className="h-3.5 w-3.5 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
|
|
||||||
/>
|
|
||||||
Off
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => remove(i)}
|
|
||||||
aria-label={`Remove ${opt.label || "option"}`}
|
|
||||||
className="rounded-lg p-1 text-red-500 transition hover:bg-red-50"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 border-t border-slate-100 pt-3">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline" disabled={replaceMutation.isPending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={replaceMutation.isPending}
|
|
||||||
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{replaceMutation.isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Save Options"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,416 +0,0 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
|
||||||
import { GripVertical, Loader2, Plus, Trash2 } from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
CreateFileUploadFieldDto,
|
|
||||||
FileUploadSetting,
|
|
||||||
} from "@/types/fileUploadSettings";
|
|
||||||
import { getMinFiles } from "@/types/fileUploadSettings";
|
|
||||||
import { useReplaceFileUploadFields } from "@/hooks/useFileUploadSettings";
|
|
||||||
|
|
||||||
export interface ManageFileUploadFieldsDialogProps {
|
|
||||||
setting: FileUploadSetting;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Local draft used by the editor — does NOT need to satisfy IFileUploadField
|
|
||||||
* (which carries server-only props like createdAt). On save, we strip the
|
|
||||||
* client-only `key` and post the rest as CreateFileUploadFieldDto[].
|
|
||||||
*/
|
|
||||||
interface DraftField extends CreateFileUploadFieldDto {
|
|
||||||
key: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let draftCounter = 0;
|
|
||||||
const nextKey = () => `draft-${Date.now()}-${++draftCounter}`;
|
|
||||||
|
|
||||||
function makeEmptyDraft(idx: number): DraftField {
|
|
||||||
return {
|
|
||||||
key: nextKey(),
|
|
||||||
fileKey: "",
|
|
||||||
fileLabel: "",
|
|
||||||
isRequired: false,
|
|
||||||
isMultiple: false,
|
|
||||||
maxFiles: 1,
|
|
||||||
allowedExtensions: ["pdf"],
|
|
||||||
maxSizeMb: 10,
|
|
||||||
order: idx + 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ManageFileUploadFieldsDialog({
|
|
||||||
setting,
|
|
||||||
children,
|
|
||||||
}: ManageFileUploadFieldsDialogProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const seed = (): DraftField[] =>
|
|
||||||
[...setting.fields]
|
|
||||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
||||||
.map((f, idx) => ({
|
|
||||||
key: f.id,
|
|
||||||
fileKey: f.fileKey,
|
|
||||||
fileLabel: f.fileLabel,
|
|
||||||
helpText: f.helpText ?? undefined,
|
|
||||||
isRequired: f.isRequired,
|
|
||||||
isMultiple: f.isMultiple,
|
|
||||||
maxFiles: f.maxFiles,
|
|
||||||
allowedExtensions: f.allowedExtensions,
|
|
||||||
maxSizeMb: f.maxSizeMb,
|
|
||||||
order: f.order ?? idx + 1,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const [fields, setFields] = useState<DraftField[]>(seed);
|
|
||||||
|
|
||||||
const replaceMutation = useReplaceFileUploadFields();
|
|
||||||
|
|
||||||
const update = (i: number, patch: Partial<DraftField>) =>
|
|
||||||
setFields((prev) =>
|
|
||||||
prev.map((f, idx) => {
|
|
||||||
if (idx !== i) return f;
|
|
||||||
const next = { ...f, ...patch };
|
|
||||||
if (patch.isMultiple === false) next.maxFiles = 1;
|
|
||||||
if (patch.isMultiple === true && next.maxFiles <= 1) next.maxFiles = 5;
|
|
||||||
return next;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateExtensions = (i: number, raw: string) => {
|
|
||||||
const list = raw
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim().toLowerCase().replace(/^\./, ""))
|
|
||||||
.filter(Boolean);
|
|
||||||
update(i, { allowedExtensions: list });
|
|
||||||
};
|
|
||||||
|
|
||||||
const remove = (i: number) =>
|
|
||||||
setFields((prev) => prev.filter((_, idx) => idx !== i));
|
|
||||||
|
|
||||||
const add = () =>
|
|
||||||
setFields((prev) => [...prev, makeEmptyDraft(prev.length)]);
|
|
||||||
|
|
||||||
const move = (i: number, dir: -1 | 1) =>
|
|
||||||
setFields((prev) => {
|
|
||||||
const next = [...prev];
|
|
||||||
const target = i + dir;
|
|
||||||
if (target < 0 || target >= next.length) return prev;
|
|
||||||
const a = next[i] as DraftField;
|
|
||||||
const b = next[target] as DraftField;
|
|
||||||
next[i] = { ...b, order: i + 1 };
|
|
||||||
next[target] = { ...a, order: target + 1 };
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const invalid = fields.findIndex(
|
|
||||||
(f) =>
|
|
||||||
!f.fileKey.trim() ||
|
|
||||||
!f.fileLabel.trim() ||
|
|
||||||
f.allowedExtensions.length === 0,
|
|
||||||
);
|
|
||||||
if (invalid >= 0) {
|
|
||||||
setError(
|
|
||||||
`Field ${invalid + 1} is missing file key, label, or extensions.`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: CreateFileUploadFieldDto[] = fields.map((f, idx) => ({
|
|
||||||
fileKey: f.fileKey.trim(),
|
|
||||||
fileLabel: f.fileLabel.trim(),
|
|
||||||
helpText: f.helpText?.trim() || undefined,
|
|
||||||
isRequired: f.isRequired,
|
|
||||||
isMultiple: f.isMultiple,
|
|
||||||
maxFiles: f.isMultiple ? Math.max(1, f.maxFiles) : 1,
|
|
||||||
allowedExtensions: f.allowedExtensions,
|
|
||||||
maxSizeMb: f.maxSizeMb,
|
|
||||||
order: idx + 1,
|
|
||||||
}));
|
|
||||||
|
|
||||||
replaceMutation.mutate(
|
|
||||||
{ settingId: setting.id, fields: payload },
|
|
||||||
{
|
|
||||||
onSuccess: () => setOpen(false),
|
|
||||||
onError: (err) =>
|
|
||||||
setError(
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "Failed to save fields. Try again.",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(next) => {
|
|
||||||
setOpen(next);
|
|
||||||
if (next) {
|
|
||||||
setFields(seed());
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-5xl rounded-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-2xl font-bold">
|
|
||||||
Manage Fields · {setting.label}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Add, edit, reorder, or remove upload fields for{" "}
|
|
||||||
<span className="font-mono text-slate-700">{setting.code}</span>.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-3 py-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm text-slate-500">
|
|
||||||
{fields.length} field{fields.length === 1 ? "" : "s"}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={add}
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-xl bg-[#10B981] px-3 py-1.5 text-xs font-medium text-white transition hover:bg-[#10B981]/90"
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
Add Field
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{fields.length === 0 ? (
|
|
||||||
<div className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
|
||||||
No fields yet. Click{" "}
|
|
||||||
<span className="font-medium">Add Field</span> to start.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{fields.map((f, i) => (
|
|
||||||
<FieldEditor
|
|
||||||
key={f.key}
|
|
||||||
field={f}
|
|
||||||
index={i}
|
|
||||||
total={fields.length}
|
|
||||||
onChange={(patch) => update(i, patch)}
|
|
||||||
onChangeExtensions={(raw) => updateExtensions(i, raw)}
|
|
||||||
onMove={(dir) => move(i, dir)}
|
|
||||||
onRemove={() => remove(i)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mt-2 flex justify-end gap-3 border-t border-slate-100 pt-3">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline" disabled={replaceMutation.isPending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={replaceMutation.isPending}
|
|
||||||
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{replaceMutation.isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Save Fields"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldEditor({
|
|
||||||
field,
|
|
||||||
index,
|
|
||||||
total,
|
|
||||||
onChange,
|
|
||||||
onChangeExtensions,
|
|
||||||
onMove,
|
|
||||||
onRemove,
|
|
||||||
}: {
|
|
||||||
field: DraftField;
|
|
||||||
index: number;
|
|
||||||
total: number;
|
|
||||||
onChange: (patch: Partial<DraftField>) => void;
|
|
||||||
onChangeExtensions: (raw: string) => void;
|
|
||||||
onMove: (dir: -1 | 1) => void;
|
|
||||||
onRemove: () => void;
|
|
||||||
}) {
|
|
||||||
const minFiles = getMinFiles(field);
|
|
||||||
const effectiveMax = field.isMultiple ? field.maxFiles : 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-slate-200 bg-white p-4">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2 text-slate-400">
|
|
||||||
<GripVertical className="h-4 w-4" />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onMove(-1)}
|
|
||||||
aria-label="Move up"
|
|
||||||
disabled={index === 0}
|
|
||||||
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
|
|
||||||
>
|
|
||||||
▲
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onMove(1)}
|
|
||||||
aria-label="Move down"
|
|
||||||
disabled={index === total - 1}
|
|
||||||
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
|
|
||||||
>
|
|
||||||
▼
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-[#10B981]">
|
|
||||||
Field {index + 1}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
|
||||||
min {minFiles} · max {effectiveMax}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onRemove}
|
|
||||||
aria-label="Remove field"
|
|
||||||
className="rounded-lg p-1 text-red-500 transition hover:bg-red-50"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-4">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">File Key *</Label>
|
|
||||||
<Input
|
|
||||||
value={field.fileKey}
|
|
||||||
onChange={(e) => onChange({ fileKey: e.target.value })}
|
|
||||||
placeholder="supporting_doc"
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5 md:col-span-2">
|
|
||||||
<Label className="text-xs">File Label *</Label>
|
|
||||||
<Input
|
|
||||||
value={field.fileLabel}
|
|
||||||
onChange={(e) => onChange({ fileLabel: e.target.value })}
|
|
||||||
placeholder="Supporting Document"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Max Size (MB)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={field.maxSizeMb}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange({ maxSizeMb: Number(e.target.value) })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5 md:col-span-2">
|
|
||||||
<Label className="text-xs">Allowed Extensions</Label>
|
|
||||||
<Input
|
|
||||||
value={field.allowedExtensions.join(", ")}
|
|
||||||
onChange={(e) => onChangeExtensions(e.target.value)}
|
|
||||||
placeholder="pdf, docx, jpg"
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
Comma-separated, no leading dot.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Max Files</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={50}
|
|
||||||
value={field.maxFiles}
|
|
||||||
disabled={!field.isMultiple}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange({ maxFiles: Number(e.target.value) })
|
|
||||||
}
|
|
||||||
className={!field.isMultiple ? "bg-slate-50 text-slate-400" : ""}
|
|
||||||
/>
|
|
||||||
{!field.isMultiple ? (
|
|
||||||
<p className="text-xs text-slate-400">
|
|
||||||
Locked to 1 when single-file.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-end md:gap-4">
|
|
||||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={field.isRequired}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange({ isRequired: e.target.checked })
|
|
||||||
}
|
|
||||||
className="h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
|
|
||||||
/>
|
|
||||||
Required
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={field.isMultiple}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange({ isMultiple: e.target.checked })
|
|
||||||
}
|
|
||||||
className="h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
|
|
||||||
/>
|
|
||||||
Multiple
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5 md:col-span-4">
|
|
||||||
<Label className="text-xs">Help Text (optional)</Label>
|
|
||||||
<Input
|
|
||||||
value={field.helpText ?? ""}
|
|
||||||
onChange={(e) => onChange({ helpText: e.target.value })}
|
|
||||||
placeholder="e.g. PDF or photo of the original document."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import type {
|
|
||||||
FileUploadField,
|
|
||||||
FileUploadSetting,
|
|
||||||
} from "@/types/fileUploadSettings";
|
|
||||||
|
|
||||||
const field = (
|
|
||||||
settingCode: string,
|
|
||||||
idx: number,
|
|
||||||
data: Omit<FileUploadField, "id" | "order">,
|
|
||||||
): FileUploadField => ({
|
|
||||||
id: `${settingCode}-${idx + 1}`,
|
|
||||||
order: idx + 1,
|
|
||||||
...data,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fileUploadSettings: FileUploadSetting[] = [
|
|
||||||
{
|
|
||||||
id: "fu-customer_registration",
|
|
||||||
code: "customer_registration",
|
|
||||||
label: "Customer Registration",
|
|
||||||
description: "Documents required when onboarding a new customer.",
|
|
||||||
entity: "customer",
|
|
||||||
fields: [
|
|
||||||
field("customer_registration", 0, {
|
|
||||||
fileKey: "tin_certificate",
|
|
||||||
fileLabel: "TIN Certificate",
|
|
||||||
helpText: "Tax Identification Number certificate.",
|
|
||||||
isRequired: true,
|
|
||||||
isMultiple: false,
|
|
||||||
maxFiles: 1,
|
|
||||||
allowedExtensions: ["pdf", "jpg", "png"],
|
|
||||||
maxSizeMb: 5,
|
|
||||||
}),
|
|
||||||
field("customer_registration", 1, {
|
|
||||||
fileKey: "trade_license",
|
|
||||||
fileLabel: "Trade License",
|
|
||||||
helpText: "Current, non-expired trade license.",
|
|
||||||
isRequired: true,
|
|
||||||
isMultiple: false,
|
|
||||||
maxFiles: 1,
|
|
||||||
allowedExtensions: ["pdf", "jpg", "png"],
|
|
||||||
maxSizeMb: 5,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "fu-booking",
|
|
||||||
code: "booking",
|
|
||||||
label: "Freight Booking",
|
|
||||||
description: "Documents attached to a freight booking submission.",
|
|
||||||
entity: "booking",
|
|
||||||
fields: [
|
|
||||||
field("booking", 0, {
|
|
||||||
fileKey: "packing_list",
|
|
||||||
fileLabel: "Packing List",
|
|
||||||
isRequired: true,
|
|
||||||
isMultiple: false,
|
|
||||||
maxFiles: 1,
|
|
||||||
allowedExtensions: ["pdf", "xlsx"],
|
|
||||||
maxSizeMb: 10,
|
|
||||||
}),
|
|
||||||
field("booking", 1, {
|
|
||||||
fileKey: "commercial_invoice",
|
|
||||||
fileLabel: "Commercial Invoice",
|
|
||||||
isRequired: true,
|
|
||||||
isMultiple: false,
|
|
||||||
maxFiles: 1,
|
|
||||||
allowedExtensions: ["pdf"],
|
|
||||||
maxSizeMb: 10,
|
|
||||||
}),
|
|
||||||
field("booking", 2, {
|
|
||||||
fileKey: "certificate_of_origin",
|
|
||||||
fileLabel: "Certificate of Origin",
|
|
||||||
helpText: "Optional. Required for international shipments.",
|
|
||||||
isRequired: false,
|
|
||||||
isMultiple: false,
|
|
||||||
maxFiles: 1,
|
|
||||||
allowedExtensions: ["pdf", "jpg", "png"],
|
|
||||||
maxSizeMb: 5,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "fu-consignment",
|
|
||||||
code: "consignment",
|
|
||||||
label: "Consignment",
|
|
||||||
description: "Cargo-level documents.",
|
|
||||||
entity: "consignment",
|
|
||||||
fields: [
|
|
||||||
field("consignment", 0, {
|
|
||||||
fileKey: "bill_of_lading",
|
|
||||||
fileLabel: "Bill of Lading",
|
|
||||||
isRequired: true,
|
|
||||||
isMultiple: false,
|
|
||||||
maxFiles: 1,
|
|
||||||
allowedExtensions: ["pdf"],
|
|
||||||
maxSizeMb: 10,
|
|
||||||
}),
|
|
||||||
field("consignment", 1, {
|
|
||||||
fileKey: "supporting_doc",
|
|
||||||
fileLabel: "Supporting Documents",
|
|
||||||
helpText: "Customs declarations, inspection reports, etc.",
|
|
||||||
isRequired: false,
|
|
||||||
isMultiple: true,
|
|
||||||
maxFiles: 5,
|
|
||||||
allowedExtensions: ["pdf", "docx", "jpg", "png"],
|
|
||||||
maxSizeMb: 10,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "fu-invoice",
|
|
||||||
code: "invoice",
|
|
||||||
label: "Invoice",
|
|
||||||
description: "Attachments for billing invoices.",
|
|
||||||
entity: "invoice",
|
|
||||||
fields: [
|
|
||||||
field("invoice", 0, {
|
|
||||||
fileKey: "proof_of_payment",
|
|
||||||
fileLabel: "Proof of Payment",
|
|
||||||
helpText: "Bank transfer receipt or wire confirmation.",
|
|
||||||
isRequired: false,
|
|
||||||
isMultiple: false,
|
|
||||||
maxFiles: 1,
|
|
||||||
allowedExtensions: ["pdf", "jpg", "png"],
|
|
||||||
maxSizeMb: 5,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "fu-train",
|
|
||||||
code: "train_maintenance",
|
|
||||||
label: "Train Maintenance",
|
|
||||||
description: "Maintenance and inspection records for rolling stock.",
|
|
||||||
entity: "train",
|
|
||||||
fields: [
|
|
||||||
field("train_maintenance", 0, {
|
|
||||||
fileKey: "inspection_report",
|
|
||||||
fileLabel: "Inspection Report",
|
|
||||||
isRequired: true,
|
|
||||||
isMultiple: false,
|
|
||||||
maxFiles: 1,
|
|
||||||
allowedExtensions: ["pdf"],
|
|
||||||
maxSizeMb: 10,
|
|
||||||
}),
|
|
||||||
field("train_maintenance", 1, {
|
|
||||||
fileKey: "photos",
|
|
||||||
fileLabel: "Inspection Photos",
|
|
||||||
helpText: "Photos of the condition.",
|
|
||||||
isRequired: false,
|
|
||||||
isMultiple: true,
|
|
||||||
maxFiles: 10,
|
|
||||||
allowedExtensions: ["jpg", "png", "heic"],
|
|
||||||
maxSizeMb: 8,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function getFileUploadSettingByCode(
|
|
||||||
code: string,
|
|
||||||
): FileUploadSetting | undefined {
|
|
||||||
return fileUploadSettings.find((s) => s.code === code);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFileUploadFieldsByCode(
|
|
||||||
code: string,
|
|
||||||
): FileUploadField[] {
|
|
||||||
const setting = getFileUploadSettingByCode(code);
|
|
||||||
if (!setting) return [];
|
|
||||||
return [...setting.fields].sort(
|
|
||||||
(a, b) => (a.order ?? 0) - (b.order ?? 0),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
ArrowRight,
|
|
||||||
Clock,
|
|
||||||
Eye,
|
|
||||||
Filter,
|
|
||||||
MoreHorizontal,
|
|
||||||
Package,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
Trash2,
|
|
||||||
Truck,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
|
||||||
import DeleteBookingDialog from "./DeleteBookingDialog";
|
|
||||||
import { bookings, type BookingStatus } from "./bookings.mock";
|
|
||||||
import {
|
|
||||||
DataTable,
|
|
||||||
DataTableFooter,
|
|
||||||
type ColumnDef,
|
|
||||||
usePagination,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
Input,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
export default function BookingsPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
||||||
|
|
||||||
const total = bookings.length;
|
|
||||||
const pageCount = Math.ceil(total / pagination.pageSize);
|
|
||||||
const start = pagination.pageIndex * pagination.pageSize;
|
|
||||||
const end = Math.min(start + pagination.pageSize, total);
|
|
||||||
|
|
||||||
const paginatedData = useMemo(() => bookings.slice(start, end), [start, end]);
|
|
||||||
|
|
||||||
const columns: ColumnDef<(typeof bookings)[number]>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: "reference",
|
|
||||||
header: "Reference",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const booking = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
|
||||||
<Package />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-slate-900">{booking.reference}</p>
|
|
||||||
<p className="text-sm text-slate-500">{booking.requestedDate}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "customer",
|
|
||||||
header: "Customer",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "route",
|
|
||||||
header: "Route",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-700">
|
|
||||||
<span>{row.original.originStation}</span>
|
|
||||||
<ArrowRight className="text-slate-400" />
|
|
||||||
<span>{row.original.destinationStation}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "cargo",
|
|
||||||
header: "Cargo",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const b = row.original;
|
|
||||||
return (
|
|
||||||
<div className="text-sm text-slate-700">
|
|
||||||
<p>{b.cargoType}</p>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
{b.containerCount} × {b.containerType} · {b.weightTons}t
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "status",
|
|
||||||
header: "Status",
|
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
size: 40,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const booking = row.original;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex justify-end"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon">
|
|
||||||
<MoreHorizontal />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => navigate(`/bookings/${booking.id}`)}
|
|
||||||
>
|
|
||||||
<Eye />
|
|
||||||
View
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DeleteBookingDialog bookingReference={booking.reference}>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={(e: Event) => e.preventDefault()}
|
|
||||||
variant="destructive"
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DeleteBookingDialog>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen p-6">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Breadcrumbs items={[{ label: "Bookings" }]} />
|
|
||||||
|
|
||||||
<Card className="p-6 flex-row justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
|
||||||
Bookings
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-secondary-foreground">
|
|
||||||
Manage and monitor your freight bookings.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
|
||||||
<div className="relative w-full sm:w-80">
|
|
||||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="search"
|
|
||||||
placeholder="Search bookings..."
|
|
||||||
className="pl-8!"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link to="/bookings/new">
|
|
||||||
<Button>
|
|
||||||
<Plus />
|
|
||||||
New Booking
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">Total Bookings</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
||||||
{total}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
||||||
<Package />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">In Transit</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
||||||
{bookings.filter((b) => b.status === "In Transit").length}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
||||||
<Truck />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">Pending</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
||||||
{bookings.filter((b) => b.status === "Pending").length}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
||||||
<Clock />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="gap-0">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between border-b">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Booking List</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Recent freight bookings and their status.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button variant="secondary" size="sm">
|
|
||||||
<Filter />
|
|
||||||
Filter
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="px-0">
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={paginatedData}
|
|
||||||
status="success"
|
|
||||||
onRowClick={(row) =>
|
|
||||||
navigate(`/bookings/${(row as (typeof bookings)[number]).id}`)
|
|
||||||
}
|
|
||||||
pagination={{
|
|
||||||
pageIndex: pagination.pageIndex,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
pageCount: pageCount,
|
|
||||||
totalCount: total,
|
|
||||||
}}
|
|
||||||
tableOptions={{
|
|
||||||
state: { pagination },
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
}}
|
|
||||||
containerClassName="border-b shadow-none"
|
|
||||||
footer={DataTableFooter}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ 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-3 py-1 text-xs font-medium ${styles[status]}`}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
Truck,
|
Truck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
|
||||||
import DeleteBookingDialog from "./DeleteBookingDialog";
|
import DeleteBookingDialog from "./DeleteBookingDialog";
|
||||||
import { getMyBookings } from "@/lib/currentCustomer";
|
import { getMyBookings } from "@/lib/currentCustomer";
|
||||||
import { deleteBooking, type Booking, type BookingStatus } from "./bookings.mock";
|
import { deleteBooking, type Booking, type BookingStatus } from "./bookings.mock";
|
||||||
@@ -183,8 +182,6 @@ export default function MyBookings() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen p-6">
|
<div className="min-h-screen p-6">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Breadcrumbs items={[{ label: "My Bookings" }]} />
|
|
||||||
|
|
||||||
{/* Header Section Card */}
|
{/* Header Section Card */}
|
||||||
<Card className="p-6 flex-row justify-between">
|
<Card className="p-6 flex-row justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react";
|
import { CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { Button } from "@edr/ui-common";
|
import { Button } from "@edr/ui-common";
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
import { getCurrentCustomer } from "@/lib/currentCustomer";
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { CreateBookingPayload } from "@/services/bookings.service";
|
import type { CreateBookingPayload } from "@/services/bookings.service";
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +29,7 @@ import {
|
|||||||
Step7Documents,
|
Step7Documents,
|
||||||
Step8Review,
|
Step8Review,
|
||||||
} from "./new-booking-form/steps";
|
} from "./new-booking-form/steps";
|
||||||
|
import useAuth from "@/hooks/useAuth";
|
||||||
|
|
||||||
export default function NewBookingPage() {
|
export default function NewBookingPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -37,6 +37,7 @@ export default function NewBookingPage() {
|
|||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [renewalValidating, setRenewalValidating] = useState(false);
|
const [renewalValidating, setRenewalValidating] = useState(false);
|
||||||
const [renewalValid, setRenewalValid] = useState<boolean | null>(null);
|
const [renewalValid, setRenewalValid] = useState<boolean | null>(null);
|
||||||
|
const { customer } = useAuth();
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (payload: CreateBookingPayload) =>
|
mutationFn: (payload: CreateBookingPayload) =>
|
||||||
api.bookings.create.call(payload),
|
api.bookings.create.call(payload),
|
||||||
@@ -129,7 +130,6 @@ export default function NewBookingPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const me = getCurrentCustomer();
|
|
||||||
const reference = data.previousContractRef;
|
const reference = data.previousContractRef;
|
||||||
|
|
||||||
const totalWeight =
|
const totalWeight =
|
||||||
@@ -142,7 +142,7 @@ export default function NewBookingPage() {
|
|||||||
|
|
||||||
const apiPayload = {
|
const apiPayload = {
|
||||||
reference,
|
reference,
|
||||||
customerId: String(me.id),
|
customerId: customer!.id,
|
||||||
scheduledDate: new Date().toISOString().slice(0, 10),
|
scheduledDate: new Date().toISOString().slice(0, 10),
|
||||||
totalAmount: 0,
|
totalAmount: 0,
|
||||||
contractType:
|
contractType:
|
||||||
@@ -194,7 +194,7 @@ export default function NewBookingPage() {
|
|||||||
createMutation.mutate(apiPayload);
|
createMutation.mutate(apiPayload);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (createMutation.isSuccess && createMutation.data) {
|
if (createMutation.isSuccess) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center p-6">
|
<div className="flex min-h-screen items-center justify-center p-6">
|
||||||
<div className="w-full max-w-sm rounded-2xl border border-border bg-card p-10 text-center shadow-sm">
|
<div className="w-full max-w-sm rounded-2xl border border-border bg-card p-10 text-center shadow-sm">
|
||||||
@@ -207,7 +207,7 @@ export default function NewBookingPage() {
|
|||||||
notified once approved.
|
notified once approved.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-4 font-mono text-sm font-semibold text-primary">
|
<p className="mt-4 font-mono text-sm font-semibold text-primary">
|
||||||
{createMutation.data.reference}
|
{createMutation.data?.reference}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export const bookingFormSchema = z
|
|||||||
destinationYard: z.string(),
|
destinationYard: z.string(),
|
||||||
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
|
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
|
||||||
cargoWeight: z.string(),
|
cargoWeight: z.string(),
|
||||||
freightType: z.enum(["bulk", "break_bulk"]),
|
freightType: z.enum(["bulk", "break_bulk"]).optional(),
|
||||||
bulkCommodity: z.string(),
|
bulkCommodity: z.string(),
|
||||||
bulkCommodityOther: z.string(),
|
bulkCommodityOther: z.string(),
|
||||||
breakBulkType: z.string(),
|
breakBulkType: z.string(),
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import { Controller, type UseFormReturn } from "react-hook-form";
|
|||||||
import { Flame, MapPin, Snowflake } from "lucide-react";
|
import { Flame, MapPin, Snowflake } from "lucide-react";
|
||||||
import { Field, SelectItem, Separator, Switch } from "@edr/ui-common";
|
import { Field, SelectItem, Separator, Switch } from "@edr/ui-common";
|
||||||
import { type BookingFormValues, getRouteDirection, STATIONS } from "./schema";
|
import { type BookingFormValues, getRouteDirection, STATIONS } from "./schema";
|
||||||
import { SelectField, SelectOptions, StepHeader, StepLabel } from "./shared";
|
import {
|
||||||
|
AlertBox,
|
||||||
|
SelectField,
|
||||||
|
SelectOptions,
|
||||||
|
StepHeader,
|
||||||
|
StepLabel,
|
||||||
|
} from "./shared";
|
||||||
import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings";
|
import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings";
|
||||||
import { DropdownOption } from "@/types/dropdownSettings";
|
import { DropdownOption } from "@/types/dropdownSettings";
|
||||||
|
|
||||||
@@ -145,12 +151,10 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function getStationOptions(options?: DropdownOption[]): DropdownOption[] {
|
function getStationOptions(options?: DropdownOption[]): DropdownOption[] {
|
||||||
return [...(options ?? [])].sort((a, b) => a.order - b.order);
|
return [...(options ?? [])].sort((a, b) => a.order - b.order);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function StationSelectOptions({
|
function StationSelectOptions({
|
||||||
options,
|
options,
|
||||||
excludeValue,
|
excludeValue,
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ export function Step5CargoDetails({
|
|||||||
selected={cargoType === "container"}
|
selected={cargoType === "container"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
field.onChange("container");
|
field.onChange("container");
|
||||||
form.setValue("freightType", "", { shouldDirty: true });
|
form.setValue("freightType", undefined, {
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
form.setValue("cargoWeight", "", { shouldDirty: true });
|
form.setValue("cargoWeight", "", { shouldDirty: true });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,287 +0,0 @@
|
|||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
AlertTriangle,
|
|
||||||
ArrowLeft,
|
|
||||||
ArrowRight,
|
|
||||||
Building2,
|
|
||||||
Calendar,
|
|
||||||
Hash,
|
|
||||||
MapPin,
|
|
||||||
Package,
|
|
||||||
Ruler,
|
|
||||||
StickyNote,
|
|
||||||
Trash2,
|
|
||||||
Weight,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
|
||||||
import NewConsignmentPage from "./NewConsignmentPage";
|
|
||||||
import DeleteConsignmentDialog from "./DeleteConsignmentDialog";
|
|
||||||
import {
|
|
||||||
getConsignmentById,
|
|
||||||
type ConsignmentStatus,
|
|
||||||
} from "./consignments.mock";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
export default function ConsignmentDetailPage() {
|
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const consignment = id ? getConsignmentById(id) : undefined;
|
|
||||||
|
|
||||||
if (!consignment) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen p-6">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Breadcrumbs
|
|
||||||
items={[
|
|
||||||
{ label: "Consignments", href: "/consignments" },
|
|
||||||
{ label: "Not found" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Card className="p-8 text-center">
|
|
||||||
<h1 className="text-2xl font-bold text-slate-900">
|
|
||||||
Consignment not found
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
The consignment you're looking for doesn't exist or has been removed.
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
to="/consignments"
|
|
||||||
className="mt-6 inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
<ArrowLeft />
|
|
||||||
Back to Consignments
|
|
||||||
</Link>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen p-6">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Breadcrumbs
|
|
||||||
items={[
|
|
||||||
{ label: "Consignments", href: "/consignments" },
|
|
||||||
{ label: consignment.trackingNumber },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card className="p-6">
|
|
||||||
<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-primary text-primary-foreground">
|
|
||||||
<Package />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="flex items-center gap-3 text-3xl font-bold tracking-tight text-slate-900">
|
|
||||||
{consignment.trackingNumber}
|
|
||||||
{consignment.hazardous ? (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-1 text-xs font-semibold text-red-700">
|
|
||||||
<AlertTriangle />
|
|
||||||
Hazardous
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</h1>
|
|
||||||
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
|
|
||||||
<span>{consignment.customer}</span>
|
|
||||||
<span className="text-slate-300">•</span>
|
|
||||||
<span>Booking {consignment.bookingReference}</span>
|
|
||||||
<span className="text-slate-300">•</span>
|
|
||||||
<StatusBadge status={consignment.status} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<NewConsignmentPage
|
|
||||||
mode="edit"
|
|
||||||
consignment={{
|
|
||||||
trackingNumber: consignment.trackingNumber,
|
|
||||||
bookingId: consignment.bookingId,
|
|
||||||
bookingReference: consignment.bookingReference,
|
|
||||||
cargoType: consignment.cargoType,
|
|
||||||
description: consignment.description,
|
|
||||||
weightKg: consignment.weightKg,
|
|
||||||
volumeM3: consignment.volumeM3,
|
|
||||||
pieces: consignment.pieces,
|
|
||||||
hazardous: consignment.hazardous,
|
|
||||||
specialHandling: consignment.specialHandling,
|
|
||||||
status: consignment.status,
|
|
||||||
estimatedDelivery: consignment.estimatedDelivery,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button>Edit Consignment</Button>
|
|
||||||
</NewConsignmentPage>
|
|
||||||
|
|
||||||
<DeleteConsignmentDialog
|
|
||||||
trackingNumber={consignment.trackingNumber}
|
|
||||||
onConfirm={() => navigate("/consignments")}
|
|
||||||
>
|
|
||||||
<Button variant="outline">
|
|
||||||
<Trash2 />
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</DeleteConsignmentDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
|
|
||||||
<RouteEndpoint
|
|
||||||
label="Origin"
|
|
||||||
station={consignment.originStation}
|
|
||||||
/>
|
|
||||||
<ArrowRight className="text-primary" />
|
|
||||||
<RouteEndpoint
|
|
||||||
label="Destination"
|
|
||||||
station={consignment.destinationStation}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
|
||||||
<DetailCard title="Cargo">
|
|
||||||
<DetailRow
|
|
||||||
icon={<Package />}
|
|
||||||
label="Cargo Type"
|
|
||||||
value={consignment.cargoType}
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
icon={<Hash />}
|
|
||||||
label="Pieces"
|
|
||||||
value={String(consignment.pieces)}
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
icon={<Weight />}
|
|
||||||
label="Weight"
|
|
||||||
value={`${consignment.weightKg.toLocaleString()} kg`}
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
icon={<Ruler />}
|
|
||||||
label="Volume"
|
|
||||||
value={`${consignment.volumeM3} m³`}
|
|
||||||
/>
|
|
||||||
</DetailCard>
|
|
||||||
|
|
||||||
<DetailCard title="References & Schedule">
|
|
||||||
<DetailRow
|
|
||||||
icon={<Building2 />}
|
|
||||||
label="Customer"
|
|
||||||
value={consignment.customer}
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
icon={<Hash />}
|
|
||||||
label="Booking"
|
|
||||||
value={consignment.bookingReference}
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
icon={<Calendar />}
|
|
||||||
label="Created"
|
|
||||||
value={consignment.createdAt}
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
icon={<Calendar />}
|
|
||||||
label="Estimated Delivery"
|
|
||||||
value={consignment.estimatedDelivery}
|
|
||||||
/>
|
|
||||||
</DetailCard>
|
|
||||||
|
|
||||||
<DetailCard title="Description">
|
|
||||||
<div className="flex items-start gap-3 text-sm text-slate-700">
|
|
||||||
<Package />
|
|
||||||
<p className="leading-relaxed">{consignment.description}</p>
|
|
||||||
</div>
|
|
||||||
</DetailCard>
|
|
||||||
|
|
||||||
<DetailCard title="Special Handling">
|
|
||||||
<div className="flex items-start gap-3 text-sm text-slate-700">
|
|
||||||
<StickyNote />
|
|
||||||
<p className="leading-relaxed">{consignment.specialHandling}</p>
|
|
||||||
</div>
|
|
||||||
</DetailCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RouteEndpoint({
|
|
||||||
label,
|
|
||||||
station,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
station: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
|
|
||||||
<MapPin />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
<p className="text-lg font-semibold text-slate-900">{station}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DetailCard({
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card className="p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
|
||||||
<div className="mt-4 space-y-3">{children}</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DetailRow({
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="mt-0.5 text-primary">{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 StatusBadge({ status }: { status: ConsignmentStatus }) {
|
|
||||||
const styles: Record<ConsignmentStatus, string> = {
|
|
||||||
Pending: "bg-amber-100 text-amber-700",
|
|
||||||
"In Warehouse": "bg-sky-100 text-sky-700",
|
|
||||||
"In Transit": "bg-indigo-100 text-indigo-700",
|
|
||||||
Delivered: "bg-emerald-100 text-emerald-700",
|
|
||||||
Returned: "bg-red-100 text-red-700",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,429 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
AlertTriangle,
|
|
||||||
ArrowRight,
|
|
||||||
CheckCircle2,
|
|
||||||
Eye,
|
|
||||||
Filter,
|
|
||||||
MoreHorizontal,
|
|
||||||
Package,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
Trash2,
|
|
||||||
Truck,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
|
||||||
import NewConsignmentPage from "./NewConsignmentPage";
|
|
||||||
import DeleteConsignmentDialog from "./DeleteConsignmentDialog";
|
|
||||||
import { consignments, type ConsignmentStatus } from "./consignments.mock";
|
|
||||||
import {
|
|
||||||
DataTable,
|
|
||||||
DataTableFooter,
|
|
||||||
type ColumnDef,
|
|
||||||
usePagination,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
Input,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
type FilterValue = "All" | ConsignmentStatus;
|
|
||||||
|
|
||||||
const FILTERS: FilterValue[] = [
|
|
||||||
"All",
|
|
||||||
"Pending",
|
|
||||||
"In Warehouse",
|
|
||||||
"In Transit",
|
|
||||||
"Delivered",
|
|
||||||
"Returned",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ConsignmentsPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
||||||
const [filter, setFilter] = useState<FilterValue>("All");
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
return consignments.filter((c) => {
|
|
||||||
if (filter !== "All" && c.status !== filter) return false;
|
|
||||||
if (!q) return true;
|
|
||||||
return (
|
|
||||||
c.trackingNumber.toLowerCase().includes(q) ||
|
|
||||||
c.bookingReference.toLowerCase().includes(q) ||
|
|
||||||
c.customer.toLowerCase().includes(q) ||
|
|
||||||
c.originStation.toLowerCase().includes(q) ||
|
|
||||||
c.destinationStation.toLowerCase().includes(q) ||
|
|
||||||
c.description.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, [filter, query]);
|
|
||||||
|
|
||||||
const total = filtered.length;
|
|
||||||
const pageCount = Math.ceil(total / pagination.pageSize);
|
|
||||||
const start = pagination.pageIndex * pagination.pageSize;
|
|
||||||
const end = Math.min(start + pagination.pageSize, total);
|
|
||||||
|
|
||||||
const paginatedData = useMemo(
|
|
||||||
() => filtered.slice(start, end),
|
|
||||||
[start, end, filtered],
|
|
||||||
);
|
|
||||||
|
|
||||||
const inTransitCount = consignments.filter(
|
|
||||||
(c) => c.status === "In Transit",
|
|
||||||
).length;
|
|
||||||
const deliveredCount = consignments.filter(
|
|
||||||
(c) => c.status === "Delivered",
|
|
||||||
).length;
|
|
||||||
const hazmatCount = consignments.filter((c) => c.hazardous).length;
|
|
||||||
|
|
||||||
const columns: ColumnDef<(typeof consignments)[number]>[] = [
|
|
||||||
{
|
|
||||||
id: "trackingNumber",
|
|
||||||
header: "Tracking #",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const c = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
|
||||||
<Package />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="flex items-center gap-2 font-medium text-slate-900">
|
|
||||||
{c.trackingNumber}
|
|
||||||
{c.hazardous ? (
|
|
||||||
<span
|
|
||||||
title="Hazardous"
|
|
||||||
className="inline-flex items-center gap-0.5 rounded-full bg-red-100 px-1.5 py-0.5 text-[10px] font-semibold text-red-700"
|
|
||||||
>
|
|
||||||
<AlertTriangle />
|
|
||||||
DG
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-slate-500">{c.createdAt}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "bookingReference",
|
|
||||||
header: "Booking",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "customer",
|
|
||||||
header: "Customer",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "route",
|
|
||||||
header: "Route",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-700">
|
|
||||||
<span>{row.original.originStation}</span>
|
|
||||||
<ArrowRight className="text-slate-400" />
|
|
||||||
<span>{row.original.destinationStation}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "cargo",
|
|
||||||
header: "Cargo",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const c = row.original;
|
|
||||||
return (
|
|
||||||
<div className="text-sm text-slate-700">
|
|
||||||
<p>{c.cargoType}</p>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
{c.pieces} pcs · {c.weightKg.toLocaleString()} kg · {c.volumeM3}{" "}
|
|
||||||
m³
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "status",
|
|
||||||
header: "Status",
|
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
size: 40,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const consignment = row.original;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex justify-end"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon">
|
|
||||||
<MoreHorizontal />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => navigate(`/consignments/${consignment.id}`)}
|
|
||||||
>
|
|
||||||
<Eye />
|
|
||||||
View
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<NewConsignmentPage
|
|
||||||
mode="edit"
|
|
||||||
consignment={{
|
|
||||||
trackingNumber: consignment.trackingNumber,
|
|
||||||
bookingId: consignment.bookingId,
|
|
||||||
bookingReference: consignment.bookingReference,
|
|
||||||
cargoType: consignment.cargoType,
|
|
||||||
description: consignment.description,
|
|
||||||
weightKg: consignment.weightKg,
|
|
||||||
volumeM3: consignment.volumeM3,
|
|
||||||
pieces: consignment.pieces,
|
|
||||||
hazardous: consignment.hazardous,
|
|
||||||
specialHandling: consignment.specialHandling,
|
|
||||||
status: consignment.status,
|
|
||||||
estimatedDelivery: consignment.estimatedDelivery,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
|
|
||||||
<Pencil />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</NewConsignmentPage>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DeleteConsignmentDialog
|
|
||||||
trackingNumber={consignment.trackingNumber}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={(e: Event) => e.preventDefault()}
|
|
||||||
variant="destructive"
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DeleteConsignmentDialog>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen p-6">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Breadcrumbs items={[{ label: "Consignments" }]} />
|
|
||||||
|
|
||||||
<Card className="p-6 flex-row justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
|
||||||
Consignments
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-secondary-foreground">
|
|
||||||
Track cargo units and their handling status.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
|
||||||
<div className="relative w-full sm:w-80">
|
|
||||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="search"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => {
|
|
||||||
setQuery(e.target.value);
|
|
||||||
setPagination({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
placeholder="Search consignments..."
|
|
||||||
className="pl-8!"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NewConsignmentPage>
|
|
||||||
<Button>
|
|
||||||
<Plus />
|
|
||||||
New Consignment
|
|
||||||
</Button>
|
|
||||||
</NewConsignmentPage>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">Total Consignments</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
||||||
{consignments.length}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
||||||
<Package />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">In Transit</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
||||||
{inTransitCount}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
||||||
<Truck />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">Delivered</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
||||||
{deliveredCount}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
||||||
<CheckCircle2 />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">Hazardous</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
||||||
{hazmatCount}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-red-100 text-red-600">
|
|
||||||
<AlertTriangle />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="p-2">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{FILTERS.map((f) => {
|
|
||||||
const isActive = f === filter;
|
|
||||||
const count =
|
|
||||||
f === "All"
|
|
||||||
? consignments.length
|
|
||||||
: consignments.filter((c) => c.status === f).length;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={f}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setFilter(f);
|
|
||||||
setPagination({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className={
|
|
||||||
isActive
|
|
||||||
? "inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
|
||||||
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-primary/10 hover:text-primary"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{f}
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
isActive
|
|
||||||
? "rounded-full bg-white/20 px-2 py-0.5 text-xs"
|
|
||||||
: "rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="gap-0">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between border-b">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Consignment List</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Cargo units and their current handling status.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button variant="secondary" size="sm">
|
|
||||||
<Filter />
|
|
||||||
Filter
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="px-0">
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={paginatedData}
|
|
||||||
status="success"
|
|
||||||
onRowClick={(row) =>
|
|
||||||
navigate(
|
|
||||||
`/consignments/${(row as (typeof consignments)[number]).id}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
pagination={{
|
|
||||||
pageIndex: pagination.pageIndex,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
pageCount: pageCount,
|
|
||||||
totalCount: total,
|
|
||||||
}}
|
|
||||||
tableOptions={{
|
|
||||||
state: { pagination },
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
}}
|
|
||||||
containerClassName="border-b shadow-none"
|
|
||||||
footer={DataTableFooter}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: ConsignmentStatus }) {
|
|
||||||
const styles: Record<ConsignmentStatus, string> = {
|
|
||||||
Pending: "bg-amber-100 text-amber-700",
|
|
||||||
"In Warehouse": "bg-sky-100 text-sky-700",
|
|
||||||
"In Transit": "bg-indigo-100 text-indigo-700",
|
|
||||||
Delivered: "bg-emerald-100 text-emerald-700",
|
|
||||||
Returned: "bg-red-100 text-red-700",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
export interface DeleteConsignmentDialogProps {
|
|
||||||
trackingNumber: string;
|
|
||||||
onConfirm?: () => void;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DeleteConsignmentDialog({
|
|
||||||
trackingNumber,
|
|
||||||
onConfirm,
|
|
||||||
children,
|
|
||||||
}: DeleteConsignmentDialogProps) {
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="sm:max-w-md rounded-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl font-bold">
|
|
||||||
Remove consignment?
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
This will permanently remove consignment{" "}
|
|
||||||
<span className="font-semibold text-slate-900">
|
|
||||||
{trackingNumber}
|
|
||||||
</span>
|
|
||||||
. This action cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-2">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
</DialogClose>
|
|
||||||
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button
|
|
||||||
onClick={onConfirm}
|
|
||||||
className="bg-red-600 text-white hover:bg-red-700"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import { Calendar, Hash, Package, Ruler, Weight } from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
|
|
||||||
import { bookings } from "../bookings/bookings.mock";
|
|
||||||
import type {
|
|
||||||
ConsignmentCargo,
|
|
||||||
ConsignmentStatus,
|
|
||||||
} from "./consignments.mock";
|
|
||||||
|
|
||||||
export interface ConsignmentFormData {
|
|
||||||
trackingNumber?: string;
|
|
||||||
bookingId?: number;
|
|
||||||
bookingReference?: string;
|
|
||||||
cargoType?: ConsignmentCargo;
|
|
||||||
description?: string;
|
|
||||||
weightKg?: number;
|
|
||||||
volumeM3?: number;
|
|
||||||
pieces?: number;
|
|
||||||
hazardous?: boolean;
|
|
||||||
specialHandling?: string;
|
|
||||||
status?: ConsignmentStatus;
|
|
||||||
estimatedDelivery?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NewConsignmentPageProps {
|
|
||||||
mode?: "create" | "edit";
|
|
||||||
consignment?: ConsignmentFormData;
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectClass =
|
|
||||||
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20";
|
|
||||||
|
|
||||||
export default function NewConsignmentPage({
|
|
||||||
mode = "create",
|
|
||||||
consignment,
|
|
||||||
children,
|
|
||||||
}: NewConsignmentPageProps = {}) {
|
|
||||||
const isEdit = mode === "edit";
|
|
||||||
const title = isEdit ? "Edit Consignment" : "New Consignment";
|
|
||||||
const description = isEdit
|
|
||||||
? "Update consignment details."
|
|
||||||
: "Register a new consignment under a freight booking.";
|
|
||||||
const submitLabel = isEdit ? "Save Changes" : "Create Consignment";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{children ?? (
|
|
||||||
<Button>{isEdit ? "Edit" : "New Consignment"}</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
|
||||||
<DialogDescription>{description}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
|
||||||
{/* Tracking Number */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Tracking Number *</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
defaultValue={consignment?.trackingNumber ?? ""}
|
|
||||||
placeholder="e.g. CGM-0001"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Status</Label>
|
|
||||||
<select
|
|
||||||
defaultValue={consignment?.status ?? "Pending"}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option>Pending</option>
|
|
||||||
<option>In Warehouse</option>
|
|
||||||
<option>In Transit</option>
|
|
||||||
<option>Delivered</option>
|
|
||||||
<option>Returned</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Booking */}
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label>Freight Booking *</Label>
|
|
||||||
<select
|
|
||||||
defaultValue={consignment?.bookingId ?? ""}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option value="" disabled>
|
|
||||||
Select freight booking
|
|
||||||
</option>
|
|
||||||
{bookings.map((b) => (
|
|
||||||
<option key={b.id} value={b.id}>
|
|
||||||
{b.reference} — {b.customer} ({b.originStation} →{" "}
|
|
||||||
{b.destinationStation})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cargo Type */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Cargo Type *</Label>
|
|
||||||
<select
|
|
||||||
defaultValue={consignment?.cargoType ?? "Containerized"}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option>Containerized</option>
|
|
||||||
<option>Bulk</option>
|
|
||||||
<option>Liquid</option>
|
|
||||||
<option>Refrigerated</option>
|
|
||||||
<option>Hazardous</option>
|
|
||||||
<option>General</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pieces */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Pieces</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Package className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
defaultValue={consignment?.pieces ?? 1}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Weight */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Weight (kg)</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Weight className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step="0.1"
|
|
||||||
defaultValue={consignment?.weightKg ?? 0}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Volume */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Volume (m³)</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Ruler className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step="0.1"
|
|
||||||
defaultValue={consignment?.volumeM3 ?? 0}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ETA */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Estimated Delivery</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
defaultValue={consignment?.estimatedDelivery ?? ""}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hazardous */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Hazardous Goods</Label>
|
|
||||||
<label className="flex h-10 items-center gap-2 rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
defaultChecked={consignment?.hazardous ?? false}
|
|
||||||
className="h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
|
|
||||||
/>
|
|
||||||
<span>Mark as hazardous (DG)</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label>Description</Label>
|
|
||||||
<Textarea
|
|
||||||
defaultValue={consignment?.description ?? ""}
|
|
||||||
placeholder="Describe the consignment contents..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Special Handling */}
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label>Special Handling</Label>
|
|
||||||
<Textarea
|
|
||||||
defaultValue={consignment?.specialHandling ?? ""}
|
|
||||||
placeholder="Any handling instructions..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
|
||||||
{submitLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
ArrowLeft,
|
|
||||||
Building2,
|
|
||||||
FileText,
|
|
||||||
Globe,
|
|
||||||
Loader2,
|
|
||||||
Mail,
|
|
||||||
MapPin,
|
|
||||||
Phone,
|
|
||||||
StickyNote,
|
|
||||||
Trash2,
|
|
||||||
User,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
|
||||||
import NewCustomerPage from "./NewCustomerPage";
|
|
||||||
import DeleteCustomerDialog from "./DeleteCustomerDialog";
|
|
||||||
import { useCustomer, useDeleteCustomer } from "@/hooks/useCustomers";
|
|
||||||
import type { CustomerStatus } from "@/types/customers";
|
|
||||||
|
|
||||||
export default function CustomerDetailPage() {
|
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const { data: customer, isLoading, isError, error } = useCustomer(id);
|
|
||||||
const deleteMutation = useDeleteCustomer();
|
|
||||||
|
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-slate-50 p-6">
|
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
|
||||||
<Breadcrumbs
|
|
||||||
items={[{ label: "Customers", href: "/customers" }, { label: "…" }]}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-center rounded-3xl bg-white p-12 text-sm text-slate-500 shadow-sm">
|
|
||||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-[#10B981]" />
|
|
||||||
Loading customer…
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError || !customer) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-slate-50 p-6">
|
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
|
||||||
<Breadcrumbs
|
|
||||||
items={[
|
|
||||||
{ label: "Customers", href: "/customers" },
|
|
||||||
{ label: "Not found" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="rounded-3xl bg-white p-8 text-center shadow-sm">
|
|
||||||
<AlertCircle className="mx-auto mb-3 h-6 w-6 text-red-500" />
|
|
||||||
<h1 className="text-2xl font-bold text-slate-900">
|
|
||||||
{isError ? "Failed to load customer" : "Customer not found"}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-sm text-slate-500">
|
|
||||||
{isError && error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "The customer you're looking for doesn't exist or has been removed."}
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
to="/customers"
|
|
||||||
className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
Back to Customers
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
deleteMutation.mutate(customer.id, {
|
|
||||||
onSuccess: () => navigate("/customers"),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-slate-50 p-6">
|
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
|
||||||
<Breadcrumbs
|
|
||||||
items={[
|
|
||||||
{ label: "Customers", href: "/customers" },
|
|
||||||
{ label: customer.name },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="rounded-3xl bg-white p-6 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-[#10B981] text-white">
|
|
||||||
<User className="h-8 w-8" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
|
||||||
{customer.name}
|
|
||||||
</h1>
|
|
||||||
<div className="mt-1 flex items-center gap-3 text-sm text-slate-500">
|
|
||||||
<span className="font-mono text-xs">#{customer.id.slice(0, 8)}</span>
|
|
||||||
<span className="text-slate-300">•</span>
|
|
||||||
<span>{customer.company ?? "—"}</span>
|
|
||||||
<span className="text-slate-300">•</span>
|
|
||||||
<StatusBadge status={customer.status} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditOpen(true)}
|
|
||||||
className="inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
|
|
||||||
>
|
|
||||||
Edit Customer
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDeleteOpen(true)}
|
|
||||||
className="inline-flex items-center gap-2 rounded-2xl border border-red-200 px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-red-50"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Detail grid */}
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
|
||||||
<DetailCard title="Company Information">
|
|
||||||
<DetailRow
|
|
||||||
icon={<Building2 className="h-4 w-4" />}
|
|
||||||
label="Company Name"
|
|
||||||
value={customer.company ?? "—"}
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
icon={<FileText className="h-4 w-4" />}
|
|
||||||
label="Customer Type"
|
|
||||||
value={customer.customerType}
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
icon={<FileText className="h-4 w-4" />}
|
|
||||||
label="TIN Number"
|
|
||||||
value={customer.tinNumber ?? "—"}
|
|
||||||
/>
|
|
||||||
</DetailCard>
|
|
||||||
|
|
||||||
<DetailCard title="Contact">
|
|
||||||
<DetailRow
|
|
||||||
icon={<User className="h-4 w-4" />}
|
|
||||||
label="Contact Person"
|
|
||||||
value={customer.name}
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
icon={<Mail className="h-4 w-4" />}
|
|
||||||
label="Email"
|
|
||||||
value={customer.email}
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
icon={<Phone className="h-4 w-4" />}
|
|
||||||
label="Phone"
|
|
||||||
value={customer.phone}
|
|
||||||
/>
|
|
||||||
</DetailCard>
|
|
||||||
|
|
||||||
<DetailCard title="Location">
|
|
||||||
<DetailRow
|
|
||||||
icon={<MapPin className="h-4 w-4" />}
|
|
||||||
label="City"
|
|
||||||
value={customer.city ?? "—"}
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
icon={<Globe className="h-4 w-4" />}
|
|
||||||
label="Country"
|
|
||||||
value={customer.country ?? "—"}
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
icon={<MapPin className="h-4 w-4" />}
|
|
||||||
label="Address"
|
|
||||||
value={customer.address ?? "—"}
|
|
||||||
/>
|
|
||||||
</DetailCard>
|
|
||||||
|
|
||||||
<DetailCard title="Notes">
|
|
||||||
<div className="flex items-start gap-3 text-sm text-slate-700">
|
|
||||||
<StickyNote className="mt-0.5 h-4 w-4 text-[#10B981]" />
|
|
||||||
<p className="leading-relaxed">{customer.notes ?? "—"}</p>
|
|
||||||
</div>
|
|
||||||
</DetailCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NewCustomerPage
|
|
||||||
mode="edit"
|
|
||||||
customer={customer}
|
|
||||||
open={editOpen}
|
|
||||||
onOpenChange={setEditOpen}
|
|
||||||
/>
|
|
||||||
<DeleteCustomerDialog
|
|
||||||
customerName={customer.name}
|
|
||||||
onConfirm={handleDelete}
|
|
||||||
open={deleteOpen}
|
|
||||||
onOpenChange={setDeleteOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DetailCard({
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
|
||||||
<div className="mt-4 space-y-3">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DetailRow({
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="mt-0.5 text-[#10B981]">{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 StatusBadge({ status }: { status: CustomerStatus }) {
|
|
||||||
const styles: Record<CustomerStatus, string> = {
|
|
||||||
Active: "bg-emerald-100 text-emerald-700",
|
|
||||||
Pending: "bg-amber-100 text-amber-700",
|
|
||||||
Inactive: "bg-red-100 text-red-700",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,386 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
Clock3,
|
|
||||||
Eye,
|
|
||||||
Filter,
|
|
||||||
Loader2,
|
|
||||||
MoreHorizontal,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
Trash2,
|
|
||||||
User,
|
|
||||||
UserCheck,
|
|
||||||
Users,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
|
||||||
import NewCustomerPage from "./NewCustomerPage";
|
|
||||||
import DeleteCustomerDialog from "./DeleteCustomerDialog";
|
|
||||||
import { useCustomers, useDeleteCustomer } from "@/hooks/useCustomers";
|
|
||||||
import type { Customer, CustomerStatus } from "@/types/customers";
|
|
||||||
import {
|
|
||||||
DataTable,
|
|
||||||
DataTableFooter,
|
|
||||||
type ColumnDef,
|
|
||||||
usePagination,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
Input,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
type ActiveDialog = "edit" | "delete";
|
|
||||||
|
|
||||||
export default function CustomerPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
|
|
||||||
const [activeDialog, setActiveDialog] = useState<ActiveDialog | null>(null);
|
|
||||||
const [activeCustomer, setActiveCustomer] = useState<Customer | null>(null);
|
|
||||||
|
|
||||||
const openDialogFor = (dialog: ActiveDialog, customer: Customer) => {
|
|
||||||
// Defer past the DropdownMenu close cycle so Radix doesn't leave
|
|
||||||
// `pointer-events: none` on <body>.
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
document.body.style.pointerEvents = "";
|
|
||||||
setActiveCustomer(customer);
|
|
||||||
setActiveDialog(dialog);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const closeDialog = () => setActiveDialog(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const id = requestAnimationFrame(() => {
|
|
||||||
if (document.body.style.pointerEvents === "none") {
|
|
||||||
document.body.style.pointerEvents = "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => cancelAnimationFrame(id);
|
|
||||||
}, [activeDialog]);
|
|
||||||
|
|
||||||
const { data, isLoading, isError, error } = useCustomers();
|
|
||||||
const deleteMutation = useDeleteCustomer();
|
|
||||||
|
|
||||||
const customers = useMemo<Customer[]>(
|
|
||||||
() => (Array.isArray(data) ? data : []),
|
|
||||||
[data],
|
|
||||||
);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
if (!q) return customers;
|
|
||||||
return customers.filter(
|
|
||||||
(c) =>
|
|
||||||
c.name.toLowerCase().includes(q) ||
|
|
||||||
c.email.toLowerCase().includes(q) ||
|
|
||||||
(c.company ?? "").toLowerCase().includes(q) ||
|
|
||||||
c.phone.toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
}, [customers, query]);
|
|
||||||
|
|
||||||
const total = filtered.length;
|
|
||||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
|
||||||
const start = pagination.pageIndex * pagination.pageSize;
|
|
||||||
const end = Math.min(start + pagination.pageSize, total);
|
|
||||||
|
|
||||||
const paginatedData = useMemo(
|
|
||||||
() => filtered.slice(start, end),
|
|
||||||
[start, end, filtered],
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeCount = customers.filter((c) => c.status === "Active").length;
|
|
||||||
const pendingCount = customers.filter((c) => c.status === "Pending").length;
|
|
||||||
|
|
||||||
const status: "loading" | "error" | "success" = isLoading
|
|
||||||
? "loading"
|
|
||||||
: isError
|
|
||||||
? "error"
|
|
||||||
: "success";
|
|
||||||
|
|
||||||
const columns: ColumnDef<Customer>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: "name",
|
|
||||||
header: "Customer",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const customer = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-secondary text-secondary-foreground border">
|
|
||||||
<User className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-slate-900">{customer.name}</p>
|
|
||||||
<p className="text-sm text-slate-500">
|
|
||||||
{customer.company ?? "—"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "email",
|
|
||||||
header: "Email",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="text-sm text-slate-700">{row.original.email}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "phone",
|
|
||||||
header: "Phone",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="text-sm text-slate-700">{row.original.phone}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "customerType",
|
|
||||||
header: "Type",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="inline-flex rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-600">
|
|
||||||
{row.original.customerType}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "status",
|
|
||||||
header: "Status",
|
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
size: 40,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const customer = row.original;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex justify-end"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<DropdownMenu modal={false}>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon">
|
|
||||||
<MoreHorizontal />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => navigate(`/customers/${customer.id}`)}
|
|
||||||
>
|
|
||||||
<Eye />
|
|
||||||
View
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => openDialogFor("edit", customer)}
|
|
||||||
>
|
|
||||||
<Pencil />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => openDialogFor("delete", customer)}
|
|
||||||
variant="destructive"
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen p-6">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Breadcrumbs items={[{ label: "Customers" }]} />
|
|
||||||
|
|
||||||
<Card className="p-6 flex-row justify-between ">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
|
||||||
Customers
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-secondary-foreground ">
|
|
||||||
Manage and monitor your customer records.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
|
||||||
<div className="relative w-full sm:w-80">
|
|
||||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="search"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => {
|
|
||||||
setQuery(e.target.value);
|
|
||||||
setPagination({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
placeholder="Search customers..."
|
|
||||||
className="pl-8!"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NewCustomerPage>
|
|
||||||
<Button>
|
|
||||||
<Plus />
|
|
||||||
Add Customer
|
|
||||||
</Button>
|
|
||||||
</NewCustomerPage>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
<StatCard
|
|
||||||
title="Total Customers"
|
|
||||||
value={customers.length}
|
|
||||||
icon={<Users className="h-5 w-5" />}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Active Accounts"
|
|
||||||
value={activeCount}
|
|
||||||
icon={<UserCheck className="h-5 w-5" />}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Pending Requests"
|
|
||||||
value={pendingCount}
|
|
||||||
icon={<Clock3 className="h-5 w-5" />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isError ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center gap-3 py-6 text-sm text-red-600">
|
|
||||||
<AlertCircle className="h-5 w-5" />
|
|
||||||
Failed to load customers.{" "}
|
|
||||||
{error instanceof Error ? error.message : "Unknown error."}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Card className="gap-0">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between border-b ">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Customer List</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Recent customer activities and records.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button variant="secondary" size="sm">
|
|
||||||
<Filter />
|
|
||||||
Filter
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="px-0">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin text-primary" />
|
|
||||||
Loading customers…
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={paginatedData}
|
|
||||||
status={status}
|
|
||||||
onRowClick={(row) => navigate(`/customers/${row.id}`)}
|
|
||||||
pagination={{
|
|
||||||
pageIndex: pagination.pageIndex,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
pageCount: pageCount,
|
|
||||||
totalCount: total,
|
|
||||||
}}
|
|
||||||
tableOptions={{
|
|
||||||
state: { pagination },
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
}}
|
|
||||||
containerClassName="border-b shadow-none"
|
|
||||||
footer={DataTableFooter}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hoisted controlled dialogs (avoid Radix nested DropdownMenu+Dialog
|
|
||||||
unmount + pointer-events conflict). */}
|
|
||||||
{activeCustomer ? (
|
|
||||||
<>
|
|
||||||
<NewCustomerPage
|
|
||||||
key={`edit-${activeCustomer.id}`}
|
|
||||||
mode="edit"
|
|
||||||
customer={activeCustomer}
|
|
||||||
open={activeDialog === "edit"}
|
|
||||||
onOpenChange={(next) => (next ? null : closeDialog())}
|
|
||||||
/>
|
|
||||||
<DeleteCustomerDialog
|
|
||||||
key={`delete-${activeCustomer.id}`}
|
|
||||||
customerName={activeCustomer.name}
|
|
||||||
onConfirm={() => deleteMutation.mutate(activeCustomer.id)}
|
|
||||||
open={activeDialog === "delete"}
|
|
||||||
onOpenChange={(next) => (next ? null : closeDialog())}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
icon,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
value: number;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">{title}</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary text-white">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: CustomerStatus }) {
|
|
||||||
const styles: Record<CustomerStatus, string> = {
|
|
||||||
Active: "bg-emerald-100 text-emerald-700",
|
|
||||||
Pending: "bg-amber-100 text-amber-700",
|
|
||||||
Inactive: "bg-red-100 text-red-700",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
Button,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
export interface DeleteCustomerDialogProps {
|
|
||||||
customerName: string;
|
|
||||||
onConfirm?: () => void;
|
|
||||||
children?: ReactNode;
|
|
||||||
open?: boolean;
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DeleteCustomerDialog({
|
|
||||||
customerName,
|
|
||||||
onConfirm,
|
|
||||||
children,
|
|
||||||
open: openProp,
|
|
||||||
onOpenChange,
|
|
||||||
}: DeleteCustomerDialogProps) {
|
|
||||||
const isControlled = openProp !== undefined;
|
|
||||||
const [internalOpen, setInternalOpen] = useState(false);
|
|
||||||
const open = isControlled ? openProp : internalOpen;
|
|
||||||
const setOpen = (next: boolean) => {
|
|
||||||
if (!isControlled) setInternalOpen(next);
|
|
||||||
onOpenChange?.(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl font-bold">
|
|
||||||
Delete customer?
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
This will permanently remove{" "}
|
|
||||||
<span className="font-semibold text-slate-900">{customerName}</span>{" "}
|
|
||||||
from your records. This action cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-2">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
</DialogClose>
|
|
||||||
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button
|
|
||||||
onClick={onConfirm}
|
|
||||||
className="bg-red-600 text-white hover:bg-red-700"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Building2,
|
|
||||||
Mail,
|
|
||||||
Phone,
|
|
||||||
User,
|
|
||||||
Globe,
|
|
||||||
MapPin,
|
|
||||||
FileText,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
export interface CustomerFormData {
|
|
||||||
companyName?: string;
|
|
||||||
customerType?: string;
|
|
||||||
contactPerson?: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
tinNumber?: string;
|
|
||||||
city?: string;
|
|
||||||
country?: string;
|
|
||||||
address?: string;
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NewCustomerPageProps {
|
|
||||||
mode?: "create" | "edit";
|
|
||||||
customer?: CustomerFormData;
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NewCustomerPage({
|
|
||||||
mode = "create",
|
|
||||||
customer,
|
|
||||||
children,
|
|
||||||
}: NewCustomerPageProps = {}) {
|
|
||||||
const isEdit = mode === "edit";
|
|
||||||
const title = isEdit ? "Edit Customer" : "New Customer";
|
|
||||||
const description = isEdit
|
|
||||||
? "Update existing customer information."
|
|
||||||
: "Create and manage customer information.";
|
|
||||||
const submitLabel = isEdit ? "Save Changes" : "Create Customer";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>{description}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
|
||||||
{/* Company Name */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Company Name *</Label>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<Building2 className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
|
|
||||||
<Input
|
|
||||||
defaultValue={customer?.companyName ?? ""}
|
|
||||||
placeholder="Enter company name"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Customer Type */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Customer Type *</Label>
|
|
||||||
|
|
||||||
<select
|
|
||||||
defaultValue={customer?.customerType ?? "Importer"}
|
|
||||||
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
|
||||||
>
|
|
||||||
<option>Importer</option>
|
|
||||||
<option>Exporter</option>
|
|
||||||
<option>Supplier</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact Person */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Contact Person</Label>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<User className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
|
|
||||||
<Input
|
|
||||||
defaultValue={customer?.contactPerson ?? ""}
|
|
||||||
placeholder="Enter contact person"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Email *</Label>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<Mail className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
defaultValue={customer?.email ?? ""}
|
|
||||||
placeholder="Enter email"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Phone */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Phone</Label>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<Phone className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
|
|
||||||
<Input
|
|
||||||
defaultValue={customer?.phone ?? ""}
|
|
||||||
placeholder="Enter phone"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* TIN */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>TIN Number</Label>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<FileText className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
|
|
||||||
<Input
|
|
||||||
defaultValue={customer?.tinNumber ?? ""}
|
|
||||||
placeholder="Enter TIN number"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* City */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>City</Label>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<MapPin className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
|
|
||||||
<Input
|
|
||||||
defaultValue={customer?.city ?? ""}
|
|
||||||
placeholder="Enter city"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Country */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Country</Label>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<Globe className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
|
|
||||||
<Input
|
|
||||||
defaultValue={customer?.country ?? ""}
|
|
||||||
placeholder="Enter country"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Address */}
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label>Address</Label>
|
|
||||||
|
|
||||||
<Textarea
|
|
||||||
defaultValue={customer?.address ?? ""}
|
|
||||||
placeholder="Enter address"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notes */}
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label>Notes</Label>
|
|
||||||
|
|
||||||
<Textarea
|
|
||||||
defaultValue={customer?.notes ?? ""}
|
|
||||||
placeholder="Additional notes..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
|
|
||||||
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
|
||||||
{submitLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,624 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Building2,
|
|
||||||
Mail,
|
|
||||||
Phone,
|
|
||||||
User,
|
|
||||||
Globe,
|
|
||||||
MapPin,
|
|
||||||
FileText,
|
|
||||||
CreditCard,
|
|
||||||
Briefcase,
|
|
||||||
Users,
|
|
||||||
UserCircle,
|
|
||||||
StickyNote,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
|
||||||
import { customersService } from "@/services/customers.service";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
export interface CustomerFormData {
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
companyName: string;
|
|
||||||
companyEmail: string;
|
|
||||||
companyPhone: string;
|
|
||||||
companyLocation: string;
|
|
||||||
companyAddress: string;
|
|
||||||
contactPersonName: string;
|
|
||||||
contactPersonPhone: string;
|
|
||||||
tinNumber: string;
|
|
||||||
vatNumber: string;
|
|
||||||
fanNumber: string;
|
|
||||||
generalManagerName: string;
|
|
||||||
generalManagerEmail: string;
|
|
||||||
generalManagerPhone: string;
|
|
||||||
poaName?: string;
|
|
||||||
poaPhone?: string;
|
|
||||||
poaAddress?: string;
|
|
||||||
poaEmail?: string;
|
|
||||||
poaLocation?: string;
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NewCustomerPageProps {
|
|
||||||
mode?: "create" | "edit";
|
|
||||||
customer?: Partial<CustomerFormData>;
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NewCustomerPage({
|
|
||||||
mode = "create",
|
|
||||||
customer,
|
|
||||||
children,
|
|
||||||
}: NewCustomerPageProps = {}) {
|
|
||||||
const isEdit = mode === "edit";
|
|
||||||
const title = isEdit ? "Edit Customer" : "New Customer";
|
|
||||||
const description = isEdit
|
|
||||||
? "Update existing customer information."
|
|
||||||
: "Create and manage customer information.";
|
|
||||||
const submitLabel = isEdit ? "Save Changes" : "Create Customer";
|
|
||||||
const currentUser = JSON.parse(localStorage.getItem("currentUser")?? "{}");
|
|
||||||
console.log(currentUser)
|
|
||||||
const [formData, setFormData] = useState<CustomerFormData>({
|
|
||||||
firstName: currentUser?.name?.en?.split(" ")?.[0] ?? "",
|
|
||||||
lastName: currentUser?.name?.en?.split(" ")?.[1] ?? "",
|
|
||||||
email: currentUser?.email ?? "",
|
|
||||||
phone: currentUser?.phoneNumber ?? "",
|
|
||||||
companyName: customer?.companyName ?? "",
|
|
||||||
companyEmail: customer?.companyEmail ?? "",
|
|
||||||
companyPhone: customer?.companyPhone ?? "",
|
|
||||||
companyLocation: customer?.companyLocation ?? "",
|
|
||||||
companyAddress: customer?.companyAddress ?? "",
|
|
||||||
contactPersonName: customer?.contactPersonName ?? "",
|
|
||||||
contactPersonPhone: customer?.contactPersonPhone ?? "",
|
|
||||||
tinNumber: customer?.tinNumber ?? "",
|
|
||||||
vatNumber: customer?.vatNumber ?? "",
|
|
||||||
fanNumber: customer?.fanNumber ?? "",
|
|
||||||
generalManagerName: customer?.generalManagerName ?? "",
|
|
||||||
generalManagerEmail: customer?.generalManagerEmail ?? "",
|
|
||||||
generalManagerPhone: customer?.generalManagerPhone ?? "",
|
|
||||||
poaName: customer?.poaName ?? "",
|
|
||||||
poaPhone: customer?.poaPhone ?? "",
|
|
||||||
poaAddress: customer?.poaAddress ?? "",
|
|
||||||
poaEmail: customer?.poaEmail ?? "",
|
|
||||||
poaLocation: customer?.poaLocation ?? "",
|
|
||||||
notes: customer?.notes ?? "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
||||||
const { name, value } = e.target;
|
|
||||||
setFormData(prev => ({ ...prev, [name]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
|
||||||
const {
|
|
||||||
|
|
||||||
companyName,
|
|
||||||
companyEmail,
|
|
||||||
companyPhone,
|
|
||||||
companyLocation,
|
|
||||||
companyAddress,
|
|
||||||
contactPersonName,
|
|
||||||
contactPersonPhone,
|
|
||||||
tinNumber,
|
|
||||||
vatNumber,
|
|
||||||
fanNumber,
|
|
||||||
generalManagerName,
|
|
||||||
generalManagerEmail,
|
|
||||||
generalManagerPhone,
|
|
||||||
} = formData;
|
|
||||||
|
|
||||||
if (
|
|
||||||
|
|
||||||
!companyName ||
|
|
||||||
!companyEmail ||
|
|
||||||
!companyPhone ||
|
|
||||||
!companyLocation ||
|
|
||||||
!companyAddress ||
|
|
||||||
!contactPersonName ||
|
|
||||||
!contactPersonPhone ||
|
|
||||||
!tinNumber ||
|
|
||||||
!vatNumber ||
|
|
||||||
!fanNumber ||
|
|
||||||
!generalManagerName ||
|
|
||||||
!generalManagerEmail ||
|
|
||||||
!generalManagerPhone
|
|
||||||
) {
|
|
||||||
alert("Please fill all mandatory fields.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
if (!emailRegex.test(companyEmail)) {
|
|
||||||
alert("Please enter a valid email address.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!emailRegex.test(companyEmail)) {
|
|
||||||
alert("Please enter a valid company email address.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!emailRegex.test(generalManagerEmail)) {
|
|
||||||
alert("Please enter a valid general manager email address.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tinNumber.length !== 10 || !/^\d+$/.test(tinNumber)) {
|
|
||||||
alert("TIN must be exactly 10 digits.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fanNumber.length !== 16 || !/^\d+$/.test(fanNumber)) {
|
|
||||||
alert("FAN must be exactly 16 digits.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!validateForm()) return;
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const apiUrl = `${import.meta.env.VITE_API_URL}/api${URL_CONSTANTS.CUSTOMERS.BASE}`;
|
|
||||||
|
|
||||||
// const response = await fetch(apiUrl, {
|
|
||||||
// method: 'POST',
|
|
||||||
// headers: {
|
|
||||||
// 'Content-Type': 'application/json',
|
|
||||||
// },
|
|
||||||
// body: JSON.stringify({...formData, userId: currentUser?.id}),
|
|
||||||
// });
|
|
||||||
|
|
||||||
const response = await customersService.create({...formData, userId: currentUser?.id})
|
|
||||||
|
|
||||||
console.log(";;;;", response)
|
|
||||||
if(response){
|
|
||||||
// navigate("/")
|
|
||||||
window.navigation.reload();
|
|
||||||
}
|
|
||||||
if (!response) {
|
|
||||||
// throw new Error(data.message || `Failed to ${isEdit ? 'update' : 'create'} customer`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// console.log(`Customer ${isEdit ? 'updated' : 'created'}:`, data);
|
|
||||||
alert(`Customer ${isEdit ? 'updated' : 'created'} successfully!`);
|
|
||||||
|
|
||||||
// Close dialog or reset form here if needed
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
alert(error instanceof Error ? error.message : `Failed to ${isEdit ? 'update' : 'create'} customer`);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl rounded-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
|
||||||
<DialogDescription>{description}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
|
||||||
{/* Personal Information Section */}
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<User className="h-5 w-5 text-[#10B981]" />
|
|
||||||
<h3 className="font-semibold text-lg">Personal Information</h3>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
|
||||||
{/* First Name */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>First Name <span className="text-red-500">*</span></Label>
|
|
||||||
<div className="relative">
|
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
name="firstName"
|
|
||||||
value={formData.firstName}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Enter first name"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Last Name */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Last Name <span className="text-red-500">*</span></Label>
|
|
||||||
<div className="relative">
|
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
name="lastName"
|
|
||||||
value={formData.lastName}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Enter last name"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Email <span className="text-red-500">*</span></Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
value={formData.email}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Enter email"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Phone */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Phone <span className="text-red-500">*</span></Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
name="phone"
|
|
||||||
value={formData.phone}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Enter phone number"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Company Information Section */}
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<div className="flex items-center gap-2 mb-3 mt-2">
|
|
||||||
<Building2 className="h-5 w-5 text-[#10B981]" />
|
|
||||||
<h3 className="font-semibold text-lg">Company Information</h3>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
|
||||||
{/* Company Name */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Company Name <span className="text-red-500">*</span></Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Building2 className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
name="companyName"
|
|
||||||
value={formData.companyName}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Enter company name"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Company Email */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Company Email <span className="text-red-500">*</span></Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
name="companyEmail"
|
|
||||||
value={formData.companyEmail}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Enter company email"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Company Phone */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Company Phone <span className="text-red-500">*</span></Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
name="companyPhone"
|
|
||||||
value={formData.companyPhone}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Enter company phone"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Company Location */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Company Location <span className="text-red-500">*</span></Label>
|
|
||||||
<div className="relative">
|
|
||||||
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
name="companyLocation"
|
|
||||||
value={formData.companyLocation}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Enter company location"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Company Address */}
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label>Company Address <span className="text-red-500">*</span></Label>
|
|
||||||
<div className="relative">
|
|
||||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Textarea
|
|
||||||
name="companyAddress"
|
|
||||||
value={formData.companyAddress}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Enter company address"
|
|
||||||
className="pl-10 resize-none"
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tax & Registration Section */}
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<div className="flex items-center gap-2 mb-3 mt-2">
|
|
||||||
<CreditCard className="h-5 w-5 text-[#10B981]" />
|
|
||||||
<h3 className="font-semibold text-lg">Tax & Registration Numbers</h3>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-5 md:grid-cols-3">
|
|
||||||
{/* TIN Number */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>TIN Number <span className="text-red-500">*</span></Label>
|
|
||||||
<Input
|
|
||||||
name="tinNumber"
|
|
||||||
value={formData.tinNumber}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="10-digit TIN"
|
|
||||||
maxLength={10}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* VAT Number */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>VAT Number <span className="text-red-500">*</span></Label>
|
|
||||||
<Input
|
|
||||||
name="vatNumber"
|
|
||||||
value={formData.vatNumber}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Enter VAT number"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* FAN Number */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>FAN Number <span className="text-red-500">*</span></Label>
|
|
||||||
<Input
|
|
||||||
name="fanNumber"
|
|
||||||
value={formData.fanNumber}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="16-digit FAN"
|
|
||||||
maxLength={16}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* General Manager Section */}
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<div className="flex items-center gap-2 mb-3 mt-2">
|
|
||||||
<Briefcase className="h-5 w-5 text-[#10B981]" />
|
|
||||||
<h3 className="font-semibold text-lg">General Manager</h3>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
|
||||||
{/* General Manager Name */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>General Manager Name <span className="text-red-500">*</span></Label>
|
|
||||||
<div className="relative">
|
|
||||||
<UserCircle className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
name="generalManagerName"
|
|
||||||
value={formData.generalManagerName}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Enter general manager name"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* General Manager Email */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>General Manager Email <span className="text-red-500">*</span></Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
name="generalManagerEmail"
|
|
||||||
value={formData.generalManagerEmail}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Enter general manager email"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* General Manager Phone */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>General Manager Phone <span className="text-red-500">*</span></Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
name="generalManagerPhone"
|
|
||||||
value={formData.generalManagerPhone}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Enter general manager phone"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact Person Section */}
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<div className="flex items-center gap-2 mb-3 mt-2">
|
|
||||||
<Users className="h-5 w-5 text-[#10B981]" />
|
|
||||||
<h3 className="font-semibold text-lg">Contact Person</h3>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
|
||||||
{/* Contact Person Name */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Contact Person Name <span className="text-red-500">*</span></Label>
|
|
||||||
<div className="relative">
|
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
name="contactPersonName"
|
|
||||||
value={formData.contactPersonName}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Enter contact person name"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact Person Phone */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Contact Person Phone <span className="text-red-500">*</span></Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
name="contactPersonPhone"
|
|
||||||
value={formData.contactPersonPhone}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Enter contact person phone"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Power of Attorney Section (Optional) */}
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<div className="flex items-center gap-2 mb-3 mt-2">
|
|
||||||
<FileText className="h-5 w-5 text-[#10B981]" />
|
|
||||||
<h3 className="font-semibold text-lg">Power of Attorney (Optional)</h3>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
|
||||||
{/* POA Name */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>PoA Name</Label>
|
|
||||||
<Input
|
|
||||||
name="poaName"
|
|
||||||
value={formData.poaName ?? ""}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Enter PoA name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* POA Phone */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>PoA Phone</Label>
|
|
||||||
<Input
|
|
||||||
name="poaPhone"
|
|
||||||
value={formData.poaPhone ?? ""}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Enter PoA phone"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* POA Email */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>PoA Email</Label>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
name="poaEmail"
|
|
||||||
value={formData.poaEmail ?? ""}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Enter PoA email"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* POA Location */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>PoA Location</Label>
|
|
||||||
<Input
|
|
||||||
name="poaLocation"
|
|
||||||
value={formData.poaLocation ?? ""}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Enter PoA location"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* POA Address */}
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label>PoA Address</Label>
|
|
||||||
<Textarea
|
|
||||||
name="poaAddress"
|
|
||||||
value={formData.poaAddress ?? ""}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Enter PoA address"
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notes Section */}
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<div className="flex items-center gap-2 mb-3 mt-2">
|
|
||||||
<StickyNote className="h-5 w-5 text-[#10B981]" />
|
|
||||||
<h3 className="font-semibold text-lg">Additional Notes</h3>
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
name="notes"
|
|
||||||
value={formData.notes ?? ""}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Add any additional notes about the customer..."
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 mt-4">
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
<Button
|
|
||||||
className="bg-[#10B981] text-white hover:bg-[#10B981]/90"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Submitting..." : submitLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
ArrowLeft,
|
|
||||||
Building2,
|
|
||||||
FileText,
|
|
||||||
Globe,
|
|
||||||
Loader2,
|
|
||||||
Mail,
|
|
||||||
MapPin,
|
|
||||||
Phone,
|
|
||||||
StickyNote,
|
|
||||||
Trash2,
|
|
||||||
User,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
|
||||||
import NewCustomerPage from "./NewCustomerPage";
|
|
||||||
import DeleteCustomerDialog from "./DeleteCustomerDialog";
|
|
||||||
import { useCustomer, useDeleteCustomer } from "@/hooks/useCustomers";
|
|
||||||
import type { CustomerStatus } from "@/types/customers";
|
|
||||||
|
|
||||||
export default function CustomerDetailPage() {
|
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const { data: customer, isLoading, isError, error } = useCustomer(id);
|
|
||||||
const deleteMutation = useDeleteCustomer();
|
|
||||||
|
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-slate-50 p-6">
|
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
|
||||||
<Breadcrumbs
|
|
||||||
items={[{ label: "Customers", href: "/customers" }, { label: "…" }]}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-center rounded-3xl bg-white p-12 text-sm text-slate-500 shadow-sm">
|
|
||||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-[#10B981]" />
|
|
||||||
Loading customer…
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError || !customer) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-slate-50 p-6">
|
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
|
||||||
<Breadcrumbs
|
|
||||||
items={[
|
|
||||||
{ label: "Customers", href: "/customers" },
|
|
||||||
{ label: "Not found" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="rounded-3xl bg-white p-8 text-center shadow-sm">
|
|
||||||
<AlertCircle className="mx-auto mb-3 h-6 w-6 text-red-500" />
|
|
||||||
<h1 className="text-2xl font-bold text-slate-900">
|
|
||||||
{isError ? "Failed to load customer" : "Customer not found"}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-sm text-slate-500">
|
|
||||||
{isError && error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "The customer you're looking for doesn't exist or has been removed."}
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
to="/customers"
|
|
||||||
className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
Back to Customers
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
deleteMutation.mutate(customer.id, {
|
|
||||||
onSuccess: () => navigate("/customers"),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-slate-50 p-6">
|
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
|
||||||
<Breadcrumbs
|
|
||||||
items={[
|
|
||||||
{ label: "Customers", href: "/customers" },
|
|
||||||
{ label: customer.name },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="rounded-3xl bg-white p-6 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-[#10B981] text-white">
|
|
||||||
<User className="h-8 w-8" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
|
||||||
{customer.name}
|
|
||||||
</h1>
|
|
||||||
<div className="mt-1 flex items-center gap-3 text-sm text-slate-500">
|
|
||||||
<span className="font-mono text-xs">#{customer.id.slice(0, 8)}</span>
|
|
||||||
<span className="text-slate-300">•</span>
|
|
||||||
<span>{customer.company ?? "—"}</span>
|
|
||||||
<span className="text-slate-300">•</span>
|
|
||||||
<StatusBadge status={customer.status} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditOpen(true)}
|
|
||||||
className="inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
|
|
||||||
>
|
|
||||||
Edit Customer
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDeleteOpen(true)}
|
|
||||||
className="inline-flex items-center gap-2 rounded-2xl border border-red-200 px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-red-50"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Detail grid */}
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
|
||||||
<DetailCard title="Company Information">
|
|
||||||
<DetailRow
|
|
||||||
icon={<Building2 className="h-4 w-4" />}
|
|
||||||
label="Company Name"
|
|
||||||
value={customer.company ?? "—"}
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
icon={<FileText className="h-4 w-4" />}
|
|
||||||
label="Customer Type"
|
|
||||||
value={customer.customerType}
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
icon={<FileText className="h-4 w-4" />}
|
|
||||||
label="TIN Number"
|
|
||||||
value={customer.tinNumber ?? "—"}
|
|
||||||
/>
|
|
||||||
</DetailCard>
|
|
||||||
|
|
||||||
<DetailCard title="Contact">
|
|
||||||
<DetailRow
|
|
||||||
icon={<User className="h-4 w-4" />}
|
|
||||||
label="Contact Person"
|
|
||||||
value={customer.name}
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
icon={<Mail className="h-4 w-4" />}
|
|
||||||
label="Email"
|
|
||||||
value={customer.email}
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
icon={<Phone className="h-4 w-4" />}
|
|
||||||
label="Phone"
|
|
||||||
value={customer.phone}
|
|
||||||
/>
|
|
||||||
</DetailCard>
|
|
||||||
|
|
||||||
<DetailCard title="Location">
|
|
||||||
<DetailRow
|
|
||||||
icon={<MapPin className="h-4 w-4" />}
|
|
||||||
label="City"
|
|
||||||
value={customer.city ?? "—"}
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
icon={<Globe className="h-4 w-4" />}
|
|
||||||
label="Country"
|
|
||||||
value={customer.country ?? "—"}
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
icon={<MapPin className="h-4 w-4" />}
|
|
||||||
label="Address"
|
|
||||||
value={customer.address ?? "—"}
|
|
||||||
/>
|
|
||||||
</DetailCard>
|
|
||||||
|
|
||||||
<DetailCard title="Notes">
|
|
||||||
<div className="flex items-start gap-3 text-sm text-slate-700">
|
|
||||||
<StickyNote className="mt-0.5 h-4 w-4 text-[#10B981]" />
|
|
||||||
<p className="leading-relaxed">{customer.notes ?? "—"}</p>
|
|
||||||
</div>
|
|
||||||
</DetailCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NewCustomerPage
|
|
||||||
mode="edit"
|
|
||||||
customer={customer}
|
|
||||||
open={editOpen}
|
|
||||||
onOpenChange={setEditOpen}
|
|
||||||
/>
|
|
||||||
<DeleteCustomerDialog
|
|
||||||
customerName={customer.name}
|
|
||||||
onConfirm={handleDelete}
|
|
||||||
open={deleteOpen}
|
|
||||||
onOpenChange={setDeleteOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DetailCard({
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
|
||||||
<div className="mt-4 space-y-3">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DetailRow({
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="mt-0.5 text-[#10B981]">{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 StatusBadge({ status }: { status: CustomerStatus }) {
|
|
||||||
const styles: Record<CustomerStatus, string> = {
|
|
||||||
Active: "bg-emerald-100 text-emerald-700",
|
|
||||||
Pending: "bg-amber-100 text-amber-700",
|
|
||||||
Inactive: "bg-red-100 text-red-700",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,386 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
Clock3,
|
|
||||||
Eye,
|
|
||||||
Filter,
|
|
||||||
Loader2,
|
|
||||||
MoreHorizontal,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
Trash2,
|
|
||||||
User,
|
|
||||||
UserCheck,
|
|
||||||
Users,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
|
||||||
import NewCustomerPage from "./NewCustomerPage";
|
|
||||||
import DeleteCustomerDialog from "./DeleteCustomerDialog";
|
|
||||||
import { useCustomers, useDeleteCustomer } from "@/hooks/useCustomers";
|
|
||||||
import type { Customer, CustomerStatus } from "@/types/customers";
|
|
||||||
import {
|
|
||||||
DataTable,
|
|
||||||
DataTableFooter,
|
|
||||||
type ColumnDef,
|
|
||||||
usePagination,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
Input,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
type ActiveDialog = "edit" | "delete";
|
|
||||||
|
|
||||||
export default function CustomerPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
|
|
||||||
const [activeDialog, setActiveDialog] = useState<ActiveDialog | null>(null);
|
|
||||||
const [activeCustomer, setActiveCustomer] = useState<Customer | null>(null);
|
|
||||||
|
|
||||||
const openDialogFor = (dialog: ActiveDialog, customer: Customer) => {
|
|
||||||
// Defer past the DropdownMenu close cycle so Radix doesn't leave
|
|
||||||
// `pointer-events: none` on <body>.
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
document.body.style.pointerEvents = "";
|
|
||||||
setActiveCustomer(customer);
|
|
||||||
setActiveDialog(dialog);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const closeDialog = () => setActiveDialog(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const id = requestAnimationFrame(() => {
|
|
||||||
if (document.body.style.pointerEvents === "none") {
|
|
||||||
document.body.style.pointerEvents = "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => cancelAnimationFrame(id);
|
|
||||||
}, [activeDialog]);
|
|
||||||
|
|
||||||
const { data, isLoading, isError, error } = useCustomers();
|
|
||||||
const deleteMutation = useDeleteCustomer();
|
|
||||||
|
|
||||||
const customers = useMemo<Customer[]>(
|
|
||||||
() => (Array.isArray(data) ? data : []),
|
|
||||||
[data],
|
|
||||||
);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
if (!q) return customers;
|
|
||||||
return customers.filter(
|
|
||||||
(c) =>
|
|
||||||
c.name.toLowerCase().includes(q) ||
|
|
||||||
c.email.toLowerCase().includes(q) ||
|
|
||||||
(c.company ?? "").toLowerCase().includes(q) ||
|
|
||||||
c.phone.toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
}, [customers, query]);
|
|
||||||
|
|
||||||
const total = filtered.length;
|
|
||||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
|
||||||
const start = pagination.pageIndex * pagination.pageSize;
|
|
||||||
const end = Math.min(start + pagination.pageSize, total);
|
|
||||||
|
|
||||||
const paginatedData = useMemo(
|
|
||||||
() => filtered.slice(start, end),
|
|
||||||
[start, end, filtered],
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeCount = customers.filter((c) => c.status === "Active").length;
|
|
||||||
const pendingCount = customers.filter((c) => c.status === "Pending").length;
|
|
||||||
|
|
||||||
const status: "loading" | "error" | "success" = isLoading
|
|
||||||
? "loading"
|
|
||||||
: isError
|
|
||||||
? "error"
|
|
||||||
: "success";
|
|
||||||
|
|
||||||
const columns: ColumnDef<Customer>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: "name",
|
|
||||||
header: "Customer",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const customer = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-secondary text-secondary-foreground border">
|
|
||||||
<User className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-slate-900">{customer.name}</p>
|
|
||||||
<p className="text-sm text-slate-500">
|
|
||||||
{customer.company ?? "—"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "email",
|
|
||||||
header: "Email",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="text-sm text-slate-700">{row.original.email}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "phone",
|
|
||||||
header: "Phone",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="text-sm text-slate-700">{row.original.phone}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "customerType",
|
|
||||||
header: "Type",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="inline-flex rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-600">
|
|
||||||
{row.original.customerType}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "status",
|
|
||||||
header: "Status",
|
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
size: 40,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const customer = row.original;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex justify-end"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<DropdownMenu modal={false}>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon">
|
|
||||||
<MoreHorizontal />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => navigate(`/customers/${customer.id}`)}
|
|
||||||
>
|
|
||||||
<Eye />
|
|
||||||
View
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => openDialogFor("edit", customer)}
|
|
||||||
>
|
|
||||||
<Pencil />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => openDialogFor("delete", customer)}
|
|
||||||
variant="destructive"
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen p-6">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Breadcrumbs items={[{ label: "Customers" }]} />
|
|
||||||
|
|
||||||
<Card className="p-6 flex-row justify-between ">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
|
||||||
Customers
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-secondary-foreground ">
|
|
||||||
Manage and monitor your customer records.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
|
||||||
<div className="relative w-full sm:w-80">
|
|
||||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="search"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => {
|
|
||||||
setQuery(e.target.value);
|
|
||||||
setPagination({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
placeholder="Search customers..."
|
|
||||||
className="pl-8!"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NewCustomerPage>
|
|
||||||
<Button>
|
|
||||||
<Plus />
|
|
||||||
Add Customer
|
|
||||||
</Button>
|
|
||||||
</NewCustomerPage>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
<StatCard
|
|
||||||
title="Total Customers"
|
|
||||||
value={customers.length}
|
|
||||||
icon={<Users className="h-5 w-5" />}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Active Accounts"
|
|
||||||
value={activeCount}
|
|
||||||
icon={<UserCheck className="h-5 w-5" />}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Pending Requests"
|
|
||||||
value={pendingCount}
|
|
||||||
icon={<Clock3 className="h-5 w-5" />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isError ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center gap-3 py-6 text-sm text-red-600">
|
|
||||||
<AlertCircle className="h-5 w-5" />
|
|
||||||
Failed to load customers.{" "}
|
|
||||||
{error instanceof Error ? error.message : "Unknown error."}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Card className="gap-0">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between border-b ">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Customer List</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Recent customer activities and records.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button variant="secondary" size="sm">
|
|
||||||
<Filter />
|
|
||||||
Filter
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="px-0">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin text-primary" />
|
|
||||||
Loading customers…
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={paginatedData}
|
|
||||||
status={status}
|
|
||||||
onRowClick={(row) => navigate(`/customers/${row.id}`)}
|
|
||||||
pagination={{
|
|
||||||
pageIndex: pagination.pageIndex,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
pageCount: pageCount,
|
|
||||||
totalCount: total,
|
|
||||||
}}
|
|
||||||
tableOptions={{
|
|
||||||
state: { pagination },
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
}}
|
|
||||||
containerClassName="border-b shadow-none"
|
|
||||||
footer={DataTableFooter}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hoisted controlled dialogs (avoid Radix nested DropdownMenu+Dialog
|
|
||||||
unmount + pointer-events conflict). */}
|
|
||||||
{activeCustomer ? (
|
|
||||||
<>
|
|
||||||
<NewCustomerPage
|
|
||||||
key={`edit-${activeCustomer.id}`}
|
|
||||||
mode="edit"
|
|
||||||
customer={activeCustomer}
|
|
||||||
open={activeDialog === "edit"}
|
|
||||||
onOpenChange={(next) => (next ? null : closeDialog())}
|
|
||||||
/>
|
|
||||||
<DeleteCustomerDialog
|
|
||||||
key={`delete-${activeCustomer.id}`}
|
|
||||||
customerName={activeCustomer.name}
|
|
||||||
onConfirm={() => deleteMutation.mutate(activeCustomer.id)}
|
|
||||||
open={activeDialog === "delete"}
|
|
||||||
onOpenChange={(next) => (next ? null : closeDialog())}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
icon,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
value: number;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">{title}</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary text-white">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: CustomerStatus }) {
|
|
||||||
const styles: Record<CustomerStatus, string> = {
|
|
||||||
Active: "bg-emerald-100 text-emerald-700",
|
|
||||||
Pending: "bg-amber-100 text-amber-700",
|
|
||||||
Inactive: "bg-red-100 text-red-700",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
Button,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
export interface DeleteCustomerDialogProps {
|
|
||||||
customerName: string;
|
|
||||||
onConfirm?: () => void;
|
|
||||||
children?: ReactNode;
|
|
||||||
open?: boolean;
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DeleteCustomerDialog({
|
|
||||||
customerName,
|
|
||||||
onConfirm,
|
|
||||||
children,
|
|
||||||
open: openProp,
|
|
||||||
onOpenChange,
|
|
||||||
}: DeleteCustomerDialogProps) {
|
|
||||||
const isControlled = openProp !== undefined;
|
|
||||||
const [internalOpen, setInternalOpen] = useState(false);
|
|
||||||
const open = isControlled ? openProp : internalOpen;
|
|
||||||
const setOpen = (next: boolean) => {
|
|
||||||
if (!isControlled) setInternalOpen(next);
|
|
||||||
onOpenChange?.(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl font-bold">
|
|
||||||
Delete customer?
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
This will permanently remove{" "}
|
|
||||||
<span className="font-semibold text-slate-900">{customerName}</span>{" "}
|
|
||||||
from your records. This action cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-2">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
</DialogClose>
|
|
||||||
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button
|
|
||||||
onClick={onConfirm}
|
|
||||||
className="bg-red-600 text-white hover:bg-red-700"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
import { useEffect, useState, type ReactNode } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
Input,
|
|
||||||
Label,
|
|
||||||
Button,
|
|
||||||
Textarea,
|
|
||||||
SmartFileInput,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Building2,
|
|
||||||
Mail,
|
|
||||||
Phone,
|
|
||||||
User,
|
|
||||||
Globe,
|
|
||||||
MapPin,
|
|
||||||
FileText,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
useCreateCustomer,
|
|
||||||
useUpdateCustomer,
|
|
||||||
} from "@/hooks/useCustomers";
|
|
||||||
import { getFileUploadSettingByCode } from "@/services/fileUploadSettings.service";
|
|
||||||
import { FILE_SETTINGS } from "@/constants/FILE_SETTINGS";
|
|
||||||
import type {
|
|
||||||
CreateCustomerDto,
|
|
||||||
Customer,
|
|
||||||
CustomerStatus,
|
|
||||||
CustomerType,
|
|
||||||
} from "@/types/customers";
|
|
||||||
|
|
||||||
const CUSTOMER_TYPES: CustomerType[] = ["Importer", "Exporter", "Supplier"];
|
|
||||||
const CUSTOMER_STATUSES: CustomerStatus[] = ["Active", "Pending", "Inactive"];
|
|
||||||
|
|
||||||
export interface NewCustomerPageProps {
|
|
||||||
mode?: "create" | "edit";
|
|
||||||
customer?: Customer;
|
|
||||||
children?: ReactNode;
|
|
||||||
/** Controlled open. When omitted, the dialog manages its own open state. */
|
|
||||||
open?: boolean;
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FormState = {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
company: string;
|
|
||||||
customerType: CustomerType;
|
|
||||||
status: CustomerStatus;
|
|
||||||
tinNumber: string;
|
|
||||||
city: string;
|
|
||||||
country: string;
|
|
||||||
address: string;
|
|
||||||
notes: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptyForm = (): FormState => ({
|
|
||||||
name: "",
|
|
||||||
email: "",
|
|
||||||
phone: "",
|
|
||||||
company: "",
|
|
||||||
customerType: "Importer",
|
|
||||||
status: "Active",
|
|
||||||
tinNumber: "",
|
|
||||||
city: "",
|
|
||||||
country: "",
|
|
||||||
address: "",
|
|
||||||
notes: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const fromCustomer = (c: Customer): FormState => ({
|
|
||||||
name: c.name ?? "",
|
|
||||||
email: c.email ?? "",
|
|
||||||
phone: c.phone ?? "",
|
|
||||||
company: c.company ?? "",
|
|
||||||
customerType: c.customerType ?? "Importer",
|
|
||||||
status: c.status ?? "Active",
|
|
||||||
tinNumber: c.tinNumber ?? "",
|
|
||||||
city: c.city ?? "",
|
|
||||||
country: c.country ?? "",
|
|
||||||
address: c.address ?? "",
|
|
||||||
notes: c.notes ?? "",
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function NewCustomerPage({
|
|
||||||
mode = "create",
|
|
||||||
customer,
|
|
||||||
children,
|
|
||||||
open: openProp,
|
|
||||||
onOpenChange,
|
|
||||||
}: NewCustomerPageProps = {}) {
|
|
||||||
const isEdit = mode === "edit";
|
|
||||||
const isControlled = openProp !== undefined;
|
|
||||||
const [internalOpen, setInternalOpen] = useState(false);
|
|
||||||
const open = isControlled ? openProp : internalOpen;
|
|
||||||
const setOpen = (next: boolean) => {
|
|
||||||
if (!isControlled) setInternalOpen(next);
|
|
||||||
onOpenChange?.(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [form, setForm] = useState<FormState>(
|
|
||||||
customer ? fromCustomer(customer) : emptyForm(),
|
|
||||||
);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [files, setFiles] = useState<Record<string, File | File[] | null>>({});
|
|
||||||
|
|
||||||
// Reset form whenever the dialog opens with a different customer.
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setForm(customer ? fromCustomer(customer) : emptyForm());
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
}, [open, customer]);
|
|
||||||
|
|
||||||
const { data: customerRegistrationFiles } = useQuery(
|
|
||||||
getFileUploadSettingByCode.queryOptions({
|
|
||||||
input: FILE_SETTINGS.CUSTOMER_REGISTRATION,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const createMutation = useCreateCustomer();
|
|
||||||
const updateMutation = useUpdateCustomer();
|
|
||||||
const pending = createMutation.isPending || updateMutation.isPending;
|
|
||||||
|
|
||||||
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
|
||||||
setForm((prev) => ({ ...prev, [key]: value }));
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
if (!form.name.trim() || !form.email.trim() || !form.phone.trim()) {
|
|
||||||
setError("Name, email, and phone are required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
|
|
||||||
setError("Please enter a valid email address.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: CreateCustomerDto = {
|
|
||||||
name: form.name.trim(),
|
|
||||||
email: form.email.trim(),
|
|
||||||
phone: form.phone.trim(),
|
|
||||||
customerType: form.customerType,
|
|
||||||
status: form.status,
|
|
||||||
company: form.company.trim() || undefined,
|
|
||||||
tinNumber: form.tinNumber.trim() || undefined,
|
|
||||||
city: form.city.trim() || undefined,
|
|
||||||
country: form.country.trim() || undefined,
|
|
||||||
address: form.address.trim() || undefined,
|
|
||||||
notes: form.notes.trim() || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDone = () => {
|
|
||||||
setOpen(false);
|
|
||||||
if (!isEdit) setForm(emptyForm());
|
|
||||||
};
|
|
||||||
const onError = (err: unknown) => {
|
|
||||||
setError(
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "Something went wrong. Try again.",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEdit && customer) {
|
|
||||||
updateMutation.mutate(
|
|
||||||
{ id: customer.id, dto: payload },
|
|
||||||
{ onSuccess: onDone, onError },
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
createMutation.mutate(payload, { onSuccess: onDone, onError });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const title = isEdit ? "Edit Customer" : "New Customer";
|
|
||||||
const description = isEdit
|
|
||||||
? "Update existing customer information."
|
|
||||||
: "Create and manage customer information.";
|
|
||||||
const submitLabel = isEdit ? "Save Changes" : "Create Customer";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
{!isControlled ? (
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
|
|
||||||
</DialogTrigger>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl!">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
|
||||||
<DialogDescription>{description}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
|
||||||
<Field label="Company Name">
|
|
||||||
<Building2 className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
value={form.company}
|
|
||||||
onChange={(e) => set("company", e.target.value)}
|
|
||||||
placeholder="Enter company name"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Customer Type *</Label>
|
|
||||||
<select
|
|
||||||
value={form.customerType}
|
|
||||||
onChange={(e) =>
|
|
||||||
set("customerType", e.target.value as CustomerType)
|
|
||||||
}
|
|
||||||
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
|
||||||
>
|
|
||||||
{CUSTOMER_TYPES.map((t) => (
|
|
||||||
<option key={t} value={t}>
|
|
||||||
{t}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Field label="Contact Person *">
|
|
||||||
<User className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
value={form.name}
|
|
||||||
onChange={(e) => set("name", e.target.value)}
|
|
||||||
placeholder="Enter contact person"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Email *">
|
|
||||||
<Mail className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
value={form.email}
|
|
||||||
onChange={(e) => set("email", e.target.value)}
|
|
||||||
placeholder="Enter email"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Phone *">
|
|
||||||
<Phone className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
value={form.phone}
|
|
||||||
onChange={(e) => set("phone", e.target.value)}
|
|
||||||
placeholder="Enter phone"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="TIN Number">
|
|
||||||
<FileText className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
value={form.tinNumber}
|
|
||||||
onChange={(e) => set("tinNumber", e.target.value)}
|
|
||||||
placeholder="Enter TIN number"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="City">
|
|
||||||
<MapPin className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
value={form.city}
|
|
||||||
onChange={(e) => set("city", e.target.value)}
|
|
||||||
placeholder="Enter city"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Country">
|
|
||||||
<Globe className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
value={form.country}
|
|
||||||
onChange={(e) => set("country", e.target.value)}
|
|
||||||
placeholder="Enter country"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Status</Label>
|
|
||||||
<select
|
|
||||||
value={form.status}
|
|
||||||
onChange={(e) => set("status", e.target.value as CustomerStatus)}
|
|
||||||
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
|
||||||
>
|
|
||||||
{CUSTOMER_STATUSES.map((s) => (
|
|
||||||
<option key={s} value={s}>
|
|
||||||
{s}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label>Address</Label>
|
|
||||||
<Textarea
|
|
||||||
value={form.address}
|
|
||||||
onChange={(e) => set("address", e.target.value)}
|
|
||||||
placeholder="Enter address"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label>Notes</Label>
|
|
||||||
<Textarea
|
|
||||||
value={form.notes}
|
|
||||||
onChange={(e) => set("notes", e.target.value)}
|
|
||||||
placeholder="Additional notes..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{customerRegistrationFiles ? (
|
|
||||||
<div>
|
|
||||||
<SmartFileInput
|
|
||||||
file={customerRegistrationFiles}
|
|
||||||
value={files}
|
|
||||||
onChange={setFiles}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline" disabled={pending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={pending}
|
|
||||||
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{pending ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
submitLabel
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({
|
|
||||||
label,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{label}</Label>
|
|
||||||
<div className="relative">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
export type CustomerStatus = "Active" | "Pending" | "Inactive";
|
|
||||||
export type CustomerType = "Importer" | "Exporter" | "Supplier";
|
|
||||||
|
|
||||||
export interface Customer {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
company: string;
|
|
||||||
status: CustomerStatus;
|
|
||||||
customerType: CustomerType;
|
|
||||||
phone: string;
|
|
||||||
tinNumber: string;
|
|
||||||
city: string;
|
|
||||||
country: string;
|
|
||||||
address: string;
|
|
||||||
notes: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const seedCustomers: Customer[] = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: "Abel Tesfaye",
|
|
||||||
email: "abel@example.com",
|
|
||||||
company: "Addis Logistics",
|
|
||||||
status: "Active",
|
|
||||||
customerType: "Importer",
|
|
||||||
phone: "+251 911 234 567",
|
|
||||||
tinNumber: "0012345678",
|
|
||||||
city: "Addis Ababa",
|
|
||||||
country: "Ethiopia",
|
|
||||||
address: "Bole Road, Sub-City 03, Building 17",
|
|
||||||
notes: "Top-tier importer. Prefers weekly invoicing.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: "Sara Bekele",
|
|
||||||
email: "sara@example.com",
|
|
||||||
company: "Blue Nile Trading",
|
|
||||||
status: "Pending",
|
|
||||||
customerType: "Exporter",
|
|
||||||
phone: "+251 922 345 678",
|
|
||||||
tinNumber: "0023456789",
|
|
||||||
city: "Dire Dawa",
|
|
||||||
country: "Ethiopia",
|
|
||||||
address: "Industrial Park, Zone B, Warehouse 4",
|
|
||||||
notes: "Awaiting compliance documents.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: "Henok Alemu",
|
|
||||||
email: "henok@example.com",
|
|
||||||
company: "Ethio Freight",
|
|
||||||
status: "Inactive",
|
|
||||||
customerType: "Supplier",
|
|
||||||
phone: "+251 933 456 789",
|
|
||||||
tinNumber: "0034567890",
|
|
||||||
city: "Djibouti City",
|
|
||||||
country: "Djibouti",
|
|
||||||
address: "Port Quarter, Avenue 26, Block 9",
|
|
||||||
notes: "Account paused since last quarter.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const extras: Array<{ name: string; company: string; city: string; country: string }> = [
|
|
||||||
{ name: "Yohannes Girma", company: "Habesha Imports", city: "Addis Ababa", country: "Ethiopia" },
|
|
||||||
{ name: "Meron Asfaw", company: "Sheba Trading", city: "Adama", country: "Ethiopia" },
|
|
||||||
{ name: "Daniel Kebede", company: "Awash Cargo", city: "Hawassa", country: "Ethiopia" },
|
|
||||||
{ name: "Liya Tadesse", company: "Lalibela Logistics", city: "Bahir Dar", country: "Ethiopia" },
|
|
||||||
{ name: "Samuel Worku", company: "Rift Valley Freight", city: "Mekelle", country: "Ethiopia" },
|
|
||||||
{ name: "Hanna Mulugeta", company: "Simien Exports", city: "Gondar", country: "Ethiopia" },
|
|
||||||
{ name: "Bereket Hailu", company: "Omo River Co.", city: "Jimma", country: "Ethiopia" },
|
|
||||||
{ name: "Tigist Wolde", company: "Tana Shipping", city: "Dessie", country: "Ethiopia" },
|
|
||||||
{ name: "Kalkidan Mesfin", company: "Coffee Belt Traders", city: "Addis Ababa", country: "Ethiopia" },
|
|
||||||
{ name: "Nahom Solomon", company: "Highland Freight", city: "Harar", country: "Ethiopia" },
|
|
||||||
{ name: "Ali Mohamed", company: "Red Sea Cargo", city: "Djibouti City", country: "Djibouti" },
|
|
||||||
{ name: "Fatima Hassan", company: "Gulf Logistics", city: "Tadjoura", country: "Djibouti" },
|
|
||||||
{ name: "Omar Ibrahim", company: "Bab-el-Mandeb Trading", city: "Ali Sabieh", country: "Djibouti" },
|
|
||||||
{ name: "Amina Said", company: "Horn of Africa Imports", city: "Dikhil", country: "Djibouti" },
|
|
||||||
{ name: "Yusuf Abdulahi", company: "Saharan Exports", city: "Obock", country: "Djibouti" },
|
|
||||||
{ name: "Selam Negash", company: "Equator Freight", city: "Arba Minch", country: "Ethiopia" },
|
|
||||||
{ name: "Mikias Lemma", company: "Gibe Trading", city: "Sodo", country: "Ethiopia" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const statuses: CustomerStatus[] = ["Active", "Pending", "Inactive"];
|
|
||||||
const types: CustomerType[] = ["Importer", "Exporter", "Supplier"];
|
|
||||||
|
|
||||||
const generated: Customer[] = extras.map((entry, i) => {
|
|
||||||
const id = seedCustomers.length + i + 1;
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: entry.name,
|
|
||||||
email: `${entry.name.toLowerCase().replace(/\s+/g, ".")}@example.com`,
|
|
||||||
company: entry.company,
|
|
||||||
status: statuses[i % statuses.length] as CustomerStatus,
|
|
||||||
customerType: types[i % types.length] as CustomerType,
|
|
||||||
phone: `+251 9${String(40 + i).padStart(2, "0")} ${String(100 + i * 13).slice(0, 3)} ${String(200 + i * 17).slice(0, 3)}`,
|
|
||||||
tinNumber: String(40000000 + i * 12345).padStart(10, "0"),
|
|
||||||
city: entry.city,
|
|
||||||
country: entry.country,
|
|
||||||
address: `Block ${i + 1}, Street ${10 + i}, Quarter ${(i % 5) + 1}`,
|
|
||||||
notes: `Mock customer #${id}.`,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const customers: Customer[] = [...seedCustomers, ...generated];
|
|
||||||
|
|
||||||
export function getCustomerById(id: number | string): Customer | undefined {
|
|
||||||
const numericId = typeof id === "string" ? Number(id) : id;
|
|
||||||
return customers.find((c) => c.id === numericId);
|
|
||||||
}
|
|
||||||
@@ -1,676 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
Area,
|
|
||||||
AreaChart,
|
|
||||||
Bar,
|
|
||||||
BarChart,
|
|
||||||
CartesianGrid,
|
|
||||||
Cell,
|
|
||||||
Legend,
|
|
||||||
Line,
|
|
||||||
LineChart,
|
|
||||||
Pie,
|
|
||||||
PieChart,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
} from "recharts";
|
|
||||||
import {
|
|
||||||
ArrowDownRight,
|
|
||||||
ArrowRight,
|
|
||||||
ArrowUpRight,
|
|
||||||
CheckCircle2,
|
|
||||||
CircleDot,
|
|
||||||
DollarSign,
|
|
||||||
Package,
|
|
||||||
Truck,
|
|
||||||
Users,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
|
||||||
import { bookings } from "../bookings/bookings.mock";
|
|
||||||
import { consignments } from "../consignments/consignments.mock";
|
|
||||||
import { customers } from "../customers/customers.mock";
|
|
||||||
import { invoices } from "../billing/invoices.mock";
|
|
||||||
import { shipments } from "../tracking/shipments.mock";
|
|
||||||
import { trains } from "../trains/trains.mock";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
const BRAND = "#10B981";
|
|
||||||
const BRAND_LIGHT = "#6EE7B7";
|
|
||||||
const BRAND_LIGHTER = "#D1FAE5";
|
|
||||||
|
|
||||||
const STATUS_PALETTE: Record<string, string> = {
|
|
||||||
Pending: "#d97706",
|
|
||||||
Confirmed: "#0ea5e9",
|
|
||||||
"In Transit": "#6366f1",
|
|
||||||
Delivered: "#059669",
|
|
||||||
Cancelled: "#dc2626",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
|
||||||
const totalRevenue = useMemo(
|
|
||||||
() =>
|
|
||||||
invoices
|
|
||||||
.filter((inv) => inv.status === "Paid" && inv.currency === "USD")
|
|
||||||
.reduce((sum, inv) => sum + inv.amount, 0),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeShipments = shipments.filter(
|
|
||||||
(s) => s.status === "In Transit",
|
|
||||||
).length;
|
|
||||||
const onTimeRate = Math.round(
|
|
||||||
(shipments.filter((s) => s.status !== "Delayed").length /
|
|
||||||
shipments.length) *
|
|
||||||
100,
|
|
||||||
);
|
|
||||||
|
|
||||||
const bookingsTrend = useMemo(() => {
|
|
||||||
const months = ["Dec", "Jan", "Feb", "Mar", "Apr", "May"];
|
|
||||||
const total = bookings.length;
|
|
||||||
return months.map((label, i) => ({
|
|
||||||
month: label,
|
|
||||||
bookings: Math.round(total * (0.5 + i * 0.12) + i * 3),
|
|
||||||
delivered: Math.round(total * (0.3 + i * 0.1) + i * 2),
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const revenueByCurrency = useMemo(() => {
|
|
||||||
const buckets = new Map<string, number>();
|
|
||||||
invoices.forEach((inv) => {
|
|
||||||
if (inv.status !== "Paid") return;
|
|
||||||
buckets.set(
|
|
||||||
inv.currency,
|
|
||||||
(buckets.get(inv.currency) ?? 0) + inv.amount,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return Array.from(buckets.entries()).map(([currency, value]) => ({
|
|
||||||
currency,
|
|
||||||
value: Math.round(value),
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const bookingStatusData = useMemo(() => {
|
|
||||||
const buckets = new Map<string, number>();
|
|
||||||
bookings.forEach((b) => {
|
|
||||||
buckets.set(b.status, (buckets.get(b.status) ?? 0) + 1);
|
|
||||||
});
|
|
||||||
return Array.from(buckets.entries()).map(([status, count]) => ({
|
|
||||||
name: status,
|
|
||||||
value: count,
|
|
||||||
color: STATUS_PALETTE[status] ?? BRAND,
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const cargoTypeData = useMemo(() => {
|
|
||||||
const buckets = new Map<string, number>();
|
|
||||||
bookings.forEach((b) => {
|
|
||||||
buckets.set(b.cargoType, (buckets.get(b.cargoType) ?? 0) + 1);
|
|
||||||
});
|
|
||||||
return Array.from(buckets.entries())
|
|
||||||
.map(([cargo, count]) => ({ cargo, count }))
|
|
||||||
.sort((a, b) => b.count - a.count);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const corridorPerformance = useMemo(() => {
|
|
||||||
const weeks = ["W1", "W2", "W3", "W4", "W5", "W6", "W7", "W8"];
|
|
||||||
return weeks.map((label, i) => ({
|
|
||||||
week: label,
|
|
||||||
"Addis → Djibouti": 80 + Math.round(Math.sin(i / 2) * 8 + i * 1.2),
|
|
||||||
"Dire Dawa → Djibouti": 72 + Math.round(Math.cos(i / 2) * 6 + i * 0.8),
|
|
||||||
"Adama → Dire Dawa": 65 + Math.round(Math.sin(i / 3) * 10 + i * 1.4),
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fleetUtilization = useMemo(() => {
|
|
||||||
const total = trains.length;
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: "Operational",
|
|
||||||
value: trains.filter((t) => t.status === "Operational").length,
|
|
||||||
color: "#059669",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Idle",
|
|
||||||
value: trains.filter((t) => t.status === "Idle").length,
|
|
||||||
color: "#475569",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Maintenance",
|
|
||||||
value: trains.filter((t) => t.status === "In Maintenance").length,
|
|
||||||
color: "#d97706",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Out of Service",
|
|
||||||
value: trains.filter((t) => t.status === "Out of Service").length,
|
|
||||||
color: "#dc2626",
|
|
||||||
},
|
|
||||||
].filter((entry) => entry.value > 0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const recentShipments = useMemo(
|
|
||||||
() => [...shipments].slice(-5).reverse(),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen p-6">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Breadcrumbs items={[{ label: "Dashboard" }]} />
|
|
||||||
|
|
||||||
<Card className="p-6 flex-row justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
|
||||||
Freight Dashboard
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-secondary-foreground">
|
|
||||||
Operational overview · today · all corridors
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
|
||||||
<span className="rounded-full bg-primary px-3 py-1 text-primary-foreground">
|
|
||||||
● Live
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full bg-primary px-3 py-1 text-primary-foreground">
|
|
||||||
Last 30 days
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<KpiCard
|
|
||||||
label="Total Revenue (USD)"
|
|
||||||
value={`$${totalRevenue.toLocaleString(undefined, {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
})}`}
|
|
||||||
delta="+12.4%"
|
|
||||||
trend="up"
|
|
||||||
icon={<DollarSign />}
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="Active Bookings"
|
|
||||||
value={String(
|
|
||||||
bookings.filter(
|
|
||||||
(b) => b.status === "Confirmed" || b.status === "In Transit",
|
|
||||||
).length,
|
|
||||||
)}
|
|
||||||
delta="+5.1%"
|
|
||||||
trend="up"
|
|
||||||
icon={<Package />}
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="Active Shipments"
|
|
||||||
value={String(activeShipments)}
|
|
||||||
delta="-2.3%"
|
|
||||||
trend="down"
|
|
||||||
icon={<Truck />}
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="On-time Rate"
|
|
||||||
value={`${onTimeRate}%`}
|
|
||||||
delta="+1.8%"
|
|
||||||
trend="up"
|
|
||||||
icon={<CheckCircle2 />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
|
||||||
<ChartCard
|
|
||||||
title="Bookings vs Deliveries"
|
|
||||||
subtitle="Last 6 months"
|
|
||||||
className="lg:col-span-2"
|
|
||||||
>
|
|
||||||
<ResponsiveContainer width="100%" height={280}>
|
|
||||||
<AreaChart
|
|
||||||
data={bookingsTrend}
|
|
||||||
margin={{ top: 10, right: 10, left: -10, bottom: 0 }}
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="brandFill" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor={BRAND} stopOpacity={0.5} />
|
|
||||||
<stop offset="100%" stopColor={BRAND} stopOpacity={0} />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="lightFill" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop
|
|
||||||
offset="0%"
|
|
||||||
stopColor={BRAND_LIGHT}
|
|
||||||
stopOpacity={0.4}
|
|
||||||
/>
|
|
||||||
<stop
|
|
||||||
offset="100%"
|
|
||||||
stopColor={BRAND_LIGHT}
|
|
||||||
stopOpacity={0}
|
|
||||||
/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="month"
|
|
||||||
stroke="#94a3b8"
|
|
||||||
style={{ fontSize: "12px" }}
|
|
||||||
/>
|
|
||||||
<YAxis stroke="#94a3b8" style={{ fontSize: "12px" }} />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
border: "1px solid #e2e8f0",
|
|
||||||
borderRadius: "12px",
|
|
||||||
fontSize: "12px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Legend
|
|
||||||
wrapperStyle={{ fontSize: "12px" }}
|
|
||||||
iconType="circle"
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="bookings"
|
|
||||||
stroke={BRAND}
|
|
||||||
strokeWidth={2}
|
|
||||||
fill="url(#brandFill)"
|
|
||||||
name="Bookings"
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="delivered"
|
|
||||||
stroke={BRAND_LIGHT}
|
|
||||||
strokeWidth={2}
|
|
||||||
fill="url(#lightFill)"
|
|
||||||
name="Delivered"
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</ChartCard>
|
|
||||||
|
|
||||||
<ChartCard title="Booking Status" subtitle="Current distribution">
|
|
||||||
<ResponsiveContainer width="100%" height={280}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={bookingStatusData}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={50}
|
|
||||||
outerRadius={90}
|
|
||||||
paddingAngle={3}
|
|
||||||
dataKey="value"
|
|
||||||
>
|
|
||||||
{bookingStatusData.map((entry, i) => (
|
|
||||||
<Cell key={i} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
border: "1px solid #e2e8f0",
|
|
||||||
borderRadius: "12px",
|
|
||||||
fontSize: "12px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Legend
|
|
||||||
wrapperStyle={{ fontSize: "11px" }}
|
|
||||||
iconType="circle"
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</ChartCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
|
||||||
<ChartCard
|
|
||||||
title="Corridor On-time %"
|
|
||||||
subtitle="Weekly performance, top 3 corridors"
|
|
||||||
className="lg:col-span-2"
|
|
||||||
>
|
|
||||||
<ResponsiveContainer width="100%" height={280}>
|
|
||||||
<LineChart
|
|
||||||
data={corridorPerformance}
|
|
||||||
margin={{ top: 10, right: 10, left: -10, bottom: 0 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="week"
|
|
||||||
stroke="#94a3b8"
|
|
||||||
style={{ fontSize: "12px" }}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
stroke="#94a3b8"
|
|
||||||
style={{ fontSize: "12px" }}
|
|
||||||
domain={[0, 100]}
|
|
||||||
unit="%"
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
border: "1px solid #e2e8f0",
|
|
||||||
borderRadius: "12px",
|
|
||||||
fontSize: "12px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Legend
|
|
||||||
wrapperStyle={{ fontSize: "12px" }}
|
|
||||||
iconType="circle"
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="Addis → Djibouti"
|
|
||||||
stroke={BRAND}
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={{ r: 3 }}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="Dire Dawa → Djibouti"
|
|
||||||
stroke="#059669"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={{ r: 3 }}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="Adama → Dire Dawa"
|
|
||||||
stroke="#d97706"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={{ r: 3 }}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</ChartCard>
|
|
||||||
|
|
||||||
<ChartCard title="Cargo Mix" subtitle="Bookings by cargo type">
|
|
||||||
<ResponsiveContainer width="100%" height={280}>
|
|
||||||
<BarChart
|
|
||||||
data={cargoTypeData}
|
|
||||||
layout="vertical"
|
|
||||||
margin={{ top: 0, right: 20, left: 30, bottom: 0 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid
|
|
||||||
strokeDasharray="3 3"
|
|
||||||
stroke="#e2e8f0"
|
|
||||||
horizontal={false}
|
|
||||||
/>
|
|
||||||
<XAxis
|
|
||||||
type="number"
|
|
||||||
stroke="#94a3b8"
|
|
||||||
style={{ fontSize: "12px" }}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
type="category"
|
|
||||||
dataKey="cargo"
|
|
||||||
stroke="#94a3b8"
|
|
||||||
style={{ fontSize: "12px" }}
|
|
||||||
width={100}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
cursor={{ fill: "#10B98114" }}
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
border: "1px solid #e2e8f0",
|
|
||||||
borderRadius: "12px",
|
|
||||||
fontSize: "12px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="count"
|
|
||||||
fill={BRAND}
|
|
||||||
radius={[0, 8, 8, 0]}
|
|
||||||
/>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</ChartCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
|
||||||
<ChartCard
|
|
||||||
title="Revenue by Currency"
|
|
||||||
subtitle="Paid invoices, all time"
|
|
||||||
>
|
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
|
||||||
<BarChart
|
|
||||||
data={revenueByCurrency}
|
|
||||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="currency"
|
|
||||||
stroke="#94a3b8"
|
|
||||||
style={{ fontSize: "12px" }}
|
|
||||||
/>
|
|
||||||
<YAxis stroke="#94a3b8" style={{ fontSize: "12px" }} />
|
|
||||||
<Tooltip
|
|
||||||
cursor={{ fill: "#10B98114" }}
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
border: "1px solid #e2e8f0",
|
|
||||||
borderRadius: "12px",
|
|
||||||
fontSize: "12px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="value" radius={[8, 8, 0, 0]}>
|
|
||||||
{revenueByCurrency.map((_, i) => (
|
|
||||||
<Cell
|
|
||||||
key={i}
|
|
||||||
fill={[BRAND, BRAND_LIGHT, BRAND_LIGHTER][i % 3]}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</ChartCard>
|
|
||||||
|
|
||||||
<ChartCard title="Train Status" subtitle={`${trains.length} units`}>
|
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={fleetUtilization}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={60}
|
|
||||||
outerRadius={90}
|
|
||||||
paddingAngle={3}
|
|
||||||
dataKey="value"
|
|
||||||
>
|
|
||||||
{fleetUtilization.map((entry, i) => (
|
|
||||||
<Cell key={i} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
border: "1px solid #e2e8f0",
|
|
||||||
borderRadius: "12px",
|
|
||||||
fontSize: "12px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Legend
|
|
||||||
wrapperStyle={{ fontSize: "11px" }}
|
|
||||||
iconType="circle"
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</ChartCard>
|
|
||||||
|
|
||||||
<Card className="p-6">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between px-0">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Recent Shipments</CardTitle>
|
|
||||||
<CardDescription>Latest activity</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
to="/tracking"
|
|
||||||
className="inline-flex items-center gap-1 text-xs font-medium text-primary transition hover:underline"
|
|
||||||
>
|
|
||||||
View all
|
|
||||||
<ArrowRight />
|
|
||||||
</Link>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="px-0">
|
|
||||||
<ul className="space-y-3">
|
|
||||||
{recentShipments.map((s) => (
|
|
||||||
<li
|
|
||||||
key={s.id}
|
|
||||||
className="flex items-center justify-between gap-3 rounded-2xl border p-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 overflow-hidden">
|
|
||||||
<CircleDot
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
s.status === "Delivered"
|
|
||||||
? "#059669"
|
|
||||||
: s.status === "Delayed"
|
|
||||||
? "#dc2626"
|
|
||||||
: "#6366f1",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-semibold text-slate-900">
|
|
||||||
{s.reference}
|
|
||||||
</p>
|
|
||||||
<p className="truncate text-xs text-slate-500">
|
|
||||||
{s.originStation} → {s.destinationStation}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
|
||||||
{s.progress}%
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
|
||||||
<SummaryCard
|
|
||||||
label="Customers"
|
|
||||||
value={String(customers.length)}
|
|
||||||
href="/customers"
|
|
||||||
icon={<Users />}
|
|
||||||
/>
|
|
||||||
<SummaryCard
|
|
||||||
label="Consignments"
|
|
||||||
value={String(consignments.length)}
|
|
||||||
href="/consignments"
|
|
||||||
icon={<Package />}
|
|
||||||
/>
|
|
||||||
<SummaryCard
|
|
||||||
label="Trains in Fleet"
|
|
||||||
value={String(trains.length)}
|
|
||||||
href="/trains"
|
|
||||||
icon={<Truck />}
|
|
||||||
/>
|
|
||||||
<SummaryCard
|
|
||||||
label="Open Invoices"
|
|
||||||
value={String(
|
|
||||||
invoices.filter(
|
|
||||||
(inv) => inv.status === "Sent" || inv.status === "Overdue",
|
|
||||||
).length,
|
|
||||||
)}
|
|
||||||
href="/billing"
|
|
||||||
icon={<DollarSign />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function KpiCard({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
delta,
|
|
||||||
trend,
|
|
||||||
icon,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
delta: string;
|
|
||||||
trend: "up" | "down";
|
|
||||||
icon: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const trendColor = trend === "up" ? "text-emerald-600" : "text-red-600";
|
|
||||||
const TrendIcon = trend === "up" ? ArrowUpRight : ArrowDownRight;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent 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>
|
|
||||||
<div
|
|
||||||
className={`mt-2 inline-flex items-center gap-1 text-xs font-medium ${trendColor}`}
|
|
||||||
>
|
|
||||||
<TrendIcon />
|
|
||||||
{delta}
|
|
||||||
<span className="text-slate-400">vs last period</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChartCard({
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{title}</CardTitle>
|
|
||||||
{subtitle ? <CardDescription>{subtitle}</CardDescription> : null}
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>{children}</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SummaryCard({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
href,
|
|
||||||
icon,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
href: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={href}
|
|
||||||
className="flex items-center justify-between rounded-xl border bg-card px-4 py-4 text-card-foreground shadow-xs transition hover:bg-accent"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary text-primary-foreground">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500">{label}</p>
|
|
||||||
<p className="text-lg font-bold text-slate-900">{value}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ArrowRight className="text-slate-400" />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { bookings } from "../bookings/bookings.mock";
|
import { bookings } from "../bookings/bookings.mock";
|
||||||
import { consignments } from "../consignments/consignments.mock";
|
|
||||||
|
|
||||||
export type DocumentType =
|
export type DocumentType =
|
||||||
| "Bill of Lading"
|
| "Bill of Lading"
|
||||||
@@ -80,55 +79,59 @@ function fileNameFor(type: DocumentType, ref: string, format: DocumentFormat) {
|
|||||||
return `${slug}-${ref.toLowerCase()}.${format.toLowerCase()}`;
|
return `${slug}-${ref.toLowerCase()}.${format.toLowerCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const documents: DocumentRecord[] = Array.from({ length: 24 }, (_, i) => {
|
export const documents: DocumentRecord[] = Array.from(
|
||||||
const id = i + 1;
|
{ length: 24 },
|
||||||
const type = types[i % types.length] as DocumentType;
|
(_, i) => {
|
||||||
const format = formats[i % formats.length] as DocumentFormat;
|
const id = i + 1;
|
||||||
const status = statuses[i % statuses.length] as DocumentStatus;
|
const type = types[i % types.length] as DocumentType;
|
||||||
const uploadedAt = new Date(2026, 4, 1 + (i % 14));
|
const format = formats[i % formats.length] as DocumentFormat;
|
||||||
|
const status = statuses[i % statuses.length] as DocumentStatus;
|
||||||
|
const uploadedAt = new Date(2026, 4, 1 + (i % 14));
|
||||||
|
|
||||||
const linkPick = i % 3;
|
const linkPick = i % 3;
|
||||||
let linkedType: DocumentLinkType;
|
let linkedType: DocumentLinkType;
|
||||||
let linkedReference: string;
|
let linkedReference: string;
|
||||||
if (linkPick === 0) {
|
if (linkPick === 0) {
|
||||||
const booking = bookings[i % bookings.length] as (typeof bookings)[number];
|
const booking = bookings[
|
||||||
linkedType = "Booking";
|
i % bookings.length
|
||||||
linkedReference = booking.reference;
|
] as (typeof bookings)[number];
|
||||||
} else if (linkPick === 1) {
|
linkedType = "Booking";
|
||||||
const consignment = consignments[
|
linkedReference = booking.reference;
|
||||||
i % consignments.length
|
} else if (linkPick === 1) {
|
||||||
] as (typeof consignments)[number];
|
linkedType = "Consignment";
|
||||||
linkedType = "Consignment";
|
linkedReference = "s";
|
||||||
linkedReference = consignment.trackingNumber;
|
} else {
|
||||||
} else {
|
linkedType = "Invoice";
|
||||||
linkedType = "Invoice";
|
linkedReference = `INV-2026-${String(id).padStart(4, "0")}`;
|
||||||
linkedReference = `INV-2026-${String(id).padStart(4, "0")}`;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const name = fileNameFor(type, linkedReference, format);
|
const name = fileNameFor(type, linkedReference, format);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
type,
|
type,
|
||||||
format,
|
format,
|
||||||
sizeBytes: 50_000 + ((i * 73_421) % 4_000_000),
|
sizeBytes: 50_000 + ((i * 73_421) % 4_000_000),
|
||||||
linkedType,
|
linkedType,
|
||||||
linkedReference,
|
linkedReference,
|
||||||
uploadedBy: uploaders[i % uploaders.length] as string,
|
uploadedBy: uploaders[i % uploaders.length] as string,
|
||||||
uploadedAt: uploadedAt.toISOString().slice(0, 10),
|
uploadedAt: uploadedAt.toISOString().slice(0, 10),
|
||||||
status,
|
status,
|
||||||
notes:
|
notes:
|
||||||
i % 3 === 0
|
i % 3 === 0
|
||||||
? "Original signed copy."
|
? "Original signed copy."
|
||||||
: i % 3 === 1
|
: i % 3 === 1
|
||||||
? "Scanned from physical document."
|
? "Scanned from physical document."
|
||||||
: "Generated by system.",
|
: "Generated by system.",
|
||||||
objectKey: `edr-freight/${linkedType.toLowerCase()}/${linkedReference}/${name}`,
|
objectKey: `edr-freight/${linkedType.toLowerCase()}/${linkedReference}/${name}`,
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export function getDocumentById(id: number | string): DocumentRecord | undefined {
|
export function getDocumentById(
|
||||||
|
id: number | string,
|
||||||
|
): DocumentRecord | undefined {
|
||||||
const numericId = typeof id === "string" ? Number(id) : id;
|
const numericId = typeof id === "string" ? Number(id) : id;
|
||||||
return documents.find((d) => d.id === numericId);
|
return documents.find((d) => d.id === numericId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,486 +0,0 @@
|
|||||||
import { useEffect, 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 {
|
|
||||||
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";
|
|
||||||
import { customersService } from "@/services/customers.service";
|
|
||||||
|
|
||||||
export default function MyPortalPage() {
|
|
||||||
const me = useMemo(() => getCurrentCustomer(), []);
|
|
||||||
const myBookings = useMemo(() => getMyBookings(), []);
|
|
||||||
const myShipments = useMemo(() => getMyShipments(), []);
|
|
||||||
const myInvoices = useMemo(() => getMyInvoices(), []);
|
|
||||||
|
|
||||||
const userId = localStorage.getItem("userId");
|
|
||||||
useEffect(() => {
|
|
||||||
customersService.getByUserId(userId || "").then((res: any) => {
|
|
||||||
}).catch((err) => {
|
|
||||||
console.error(err)
|
|
||||||
})
|
|
||||||
}, [userId]);
|
|
||||||
|
|
||||||
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-[#059669] to-[#10B981] 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">
|
|
||||||
<Link
|
|
||||||
to="/bookings/new"
|
|
||||||
className="inline-flex items-center gap-2 rounded-2xl bg-white px-4 py-2 text-sm font-semibold text-[#10B981] transition hover:bg-slate-100"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
New Booking
|
|
||||||
</Link>
|
|
||||||
<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-[#10B981] 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-[#10B981]/20 hover:bg-[#10B981]/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-[#10B981]" />
|
|
||||||
{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-[#10B981] 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-[#10B981] 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-[#10B981]/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-[#10B981]/10 hover:text-[#10B981]"
|
|
||||||
>
|
|
||||||
<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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
|
||||||
>
|
|
||||||
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-[#10B981] 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-[#10B981]/20 hover:bg-[#10B981]/5"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Receipt className="h-4 w-4 text-[#10B981]" />
|
|
||||||
<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-[#10B981] text-white";
|
|
||||||
|
|
||||||
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-[#10B981]/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-[#10B981]">{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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
|
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Building2,
|
|
||||||
CheckCircle2,
|
|
||||||
DollarSign,
|
|
||||||
Package,
|
|
||||||
Plus,
|
|
||||||
Truck,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
|
||||||
|
|
||||||
import {
|
|
||||||
getCurrentCustomer,
|
|
||||||
getMyBookings,
|
|
||||||
getMyInvoices,
|
|
||||||
getMyShipments,
|
|
||||||
} from "@/lib/currentCustomer";
|
|
||||||
|
|
||||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
|
||||||
|
|
||||||
import { customersService } from "@/services/customers.service";
|
|
||||||
|
|
||||||
import { authService } from "@/services/auth.service";
|
|
||||||
import NewCustomerPage from "../customers/NewCustomerPage";
|
|
||||||
import { Button } from "@edr/ui-common";
|
|
||||||
|
|
||||||
export default function MyPortalPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// STATE
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [customer, setCustomer] = useState<any>(null);
|
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// MOCK DATA
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
|
|
||||||
const myBookings = useMemo(() => getMyBookings(), []);
|
|
||||||
const myShipments = useMemo(() => getMyShipments(), []);
|
|
||||||
const myInvoices = useMemo(() => getMyInvoices(), []);
|
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// LOADING
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
|
||||||
<div className="rounded-2xl bg-white px-6 py-4 shadow-sm">
|
|
||||||
<p className="text-sm text-slate-600">Loading portal...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// CUSTOMER MISSING (extra safety)
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
|
|
||||||
if (!customer) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
|
||||||
<div className="text-center space-y-4">
|
|
||||||
<p className="text-slate-600">No customer profile found</p>
|
|
||||||
|
|
||||||
{/* <Link
|
|
||||||
to="/customers/register"
|
|
||||||
className="inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-white"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Create Customer Profile
|
|
||||||
</Link> */}
|
|
||||||
<NewCustomerPage>
|
|
||||||
<Button className="inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-white">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Create Customer Profile
|
|
||||||
</Button>
|
|
||||||
</NewCustomerPage>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// KPI CALCULATIONS
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
|
|
||||||
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(
|
|
||||||
(i) => i.status === "Sent" || i.status === "Overdue",
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalOutstanding = outstandingInvoices.reduce(
|
|
||||||
(sum, i) => (i.currency === "USD" ? sum + i.amount : sum),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalSpent = myInvoices.reduce(
|
|
||||||
(sum, i) =>
|
|
||||||
i.status === "Paid" && i.currency === "USD" ? sum + i.amount : sum,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// RENDER
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
|
|
||||||
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" }]} />
|
|
||||||
|
|
||||||
{/* HERO */}
|
|
||||||
<div className="rounded-3xl bg-gradient-to-r from-[#059669] to-[#10B981] p-6 text-white">
|
|
||||||
<div className="flex justify-between flex-col md:flex-row gap-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="h-14 w-14 flex items-center justify-center rounded-2xl bg-white/20 text-xl font-bold">
|
|
||||||
{customer.companyName?.charAt(0)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold">
|
|
||||||
{customer.firstName} {customer.lastName}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="text-sm opacity-80 flex items-center gap-2">
|
|
||||||
<Building2 className="h-4 w-4" />
|
|
||||||
{customer.companyName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Link
|
|
||||||
to="/bookings/new"
|
|
||||||
className="bg-white text-[#10B981] px-4 py-2 rounded-xl font-semibold flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
New Booking
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
to="/tracking"
|
|
||||||
className="border border-white px-4 py-2 rounded-xl flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Truck className="h-4 w-4" />
|
|
||||||
Track
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* KPI */}
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<KpiCard
|
|
||||||
label="Active Bookings"
|
|
||||||
value={String(activeBookings.length)}
|
|
||||||
sub={`${myBookings.length} total`}
|
|
||||||
icon={<Package className="h-5 w-5" />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<KpiCard
|
|
||||||
label="In Transit"
|
|
||||||
value={String(activeShipments.length)}
|
|
||||||
sub={`${myShipments.length} shipments`}
|
|
||||||
icon={<Truck className="h-5 w-5" />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<KpiCard
|
|
||||||
label="Outstanding"
|
|
||||||
value={formatCurrency(totalOutstanding, "USD")}
|
|
||||||
sub={`${outstandingInvoices.length} invoices`}
|
|
||||||
icon={<DollarSign className="h-5 w-5" />}
|
|
||||||
tone={
|
|
||||||
outstandingInvoices.some((i) => i.status === "Overdue")
|
|
||||||
? "danger"
|
|
||||||
: "brand"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<KpiCard
|
|
||||||
label="Total Spent"
|
|
||||||
value={formatCurrency(totalSpent, "USD")}
|
|
||||||
sub="Paid invoices"
|
|
||||||
icon={<CheckCircle2 className="h-5 w-5" />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// KPI CARD
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
|
|
||||||
function KpiCard({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
sub,
|
|
||||||
icon,
|
|
||||||
href,
|
|
||||||
tone = "brand",
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
sub: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
href?: string;
|
|
||||||
tone?: "brand" | "danger";
|
|
||||||
}) {
|
|
||||||
const iconClassName =
|
|
||||||
tone === "danger" ? "bg-red-100 text-red-600" : "bg-[#10B981] text-white";
|
|
||||||
|
|
||||||
const content = (
|
|
||||||
<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 ${iconClassName}`}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const className =
|
|
||||||
"rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20";
|
|
||||||
|
|
||||||
if (href) {
|
|
||||||
return (
|
|
||||||
<Link to={href} className={`block ${className}`}>
|
|
||||||
{content}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className={className}>{content}</div>;
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
export interface DeleteTrainDialogProps {
|
|
||||||
trainCode: string;
|
|
||||||
onConfirm?: () => void;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DeleteTrainDialog({
|
|
||||||
trainCode,
|
|
||||||
onConfirm,
|
|
||||||
children,
|
|
||||||
}: DeleteTrainDialogProps) {
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="sm:max-w-md rounded-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl font-bold">
|
|
||||||
Retire train?
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
This will permanently retire train{" "}
|
|
||||||
<span className="font-semibold text-slate-900">{trainCode}</span>{" "}
|
|
||||||
from the fleet. This action cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-2">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
</DialogClose>
|
|
||||||
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button
|
|
||||||
onClick={onConfirm}
|
|
||||||
className="bg-red-600 text-white hover:bg-red-700"
|
|
||||||
>
|
|
||||||
Retire
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import {
|
|
||||||
Building2,
|
|
||||||
Calendar,
|
|
||||||
Factory,
|
|
||||||
Gauge,
|
|
||||||
MapPin,
|
|
||||||
Train as TrainIcon,
|
|
||||||
Weight,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
import type { TrainStatus, TrainType } from "./trains.mock";
|
|
||||||
|
|
||||||
export interface TrainFormData {
|
|
||||||
code?: string;
|
|
||||||
name?: string;
|
|
||||||
type?: TrainType;
|
|
||||||
status?: TrainStatus;
|
|
||||||
capacityTons?: number;
|
|
||||||
depot?: string;
|
|
||||||
manufacturer?: string;
|
|
||||||
mileageKm?: number;
|
|
||||||
lastMaintenance?: string;
|
|
||||||
nextMaintenance?: string;
|
|
||||||
currentAssignment?: string;
|
|
||||||
yearBuilt?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NewTrainPageProps {
|
|
||||||
mode?: "create" | "edit";
|
|
||||||
train?: TrainFormData;
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectClass =
|
|
||||||
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20";
|
|
||||||
|
|
||||||
export default function NewTrainPage({
|
|
||||||
mode = "create",
|
|
||||||
train,
|
|
||||||
children,
|
|
||||||
}: NewTrainPageProps = {}) {
|
|
||||||
const isEdit = mode === "edit";
|
|
||||||
const title = isEdit ? "Edit Train" : "New Train";
|
|
||||||
const description = isEdit
|
|
||||||
? "Update train fleet information."
|
|
||||||
: "Add a new train to the fleet roster.";
|
|
||||||
const submitLabel = isEdit ? "Save Changes" : "Add Train";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{children ?? <Button>{isEdit ? "Edit" : "New Train"}</Button>}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
|
||||||
<DialogDescription>{description}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
|
||||||
{/* Code */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Train Code *</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<TrainIcon className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
defaultValue={train?.code ?? ""}
|
|
||||||
placeholder="e.g. LOC-001"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Name */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Name</Label>
|
|
||||||
<Input
|
|
||||||
defaultValue={train?.name ?? ""}
|
|
||||||
placeholder="e.g. Awash Express"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Type */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Type *</Label>
|
|
||||||
<select
|
|
||||||
defaultValue={train?.type ?? "Locomotive"}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option>Locomotive</option>
|
|
||||||
<option>Freight Wagon</option>
|
|
||||||
<option>Tanker Wagon</option>
|
|
||||||
<option>Container Wagon</option>
|
|
||||||
<option>Reefer Wagon</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Status</Label>
|
|
||||||
<select
|
|
||||||
defaultValue={train?.status ?? "Operational"}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option>Operational</option>
|
|
||||||
<option>In Maintenance</option>
|
|
||||||
<option>Idle</option>
|
|
||||||
<option>Out of Service</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Capacity */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Capacity (Tons)</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Weight className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
defaultValue={train?.capacityTons ?? 0}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Depot */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Depot</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
defaultValue={train?.depot ?? ""}
|
|
||||||
placeholder="e.g. Addis Ababa"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Manufacturer */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Manufacturer</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Factory className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
defaultValue={train?.manufacturer ?? ""}
|
|
||||||
placeholder="e.g. CRRC Zhuzhou"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Year Built */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Year Built</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1950}
|
|
||||||
max={2030}
|
|
||||||
defaultValue={train?.yearBuilt ?? 2020}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mileage */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Mileage (km)</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Gauge className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
defaultValue={train?.mileageKm ?? 0}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Current Assignment */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Current Assignment</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Building2 className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
defaultValue={train?.currentAssignment ?? ""}
|
|
||||||
placeholder="e.g. BK-026003 or —"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Last Maintenance */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Last Maintenance</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
defaultValue={train?.lastMaintenance ?? ""}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Next Maintenance */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Next Maintenance</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
defaultValue={train?.nextMaintenance ?? ""}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
|
||||||
{submitLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Eye,
|
|
||||||
Filter,
|
|
||||||
Gauge,
|
|
||||||
MoreHorizontal,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
Train as TrainIcon,
|
|
||||||
Trash2,
|
|
||||||
Wrench,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
|
||||||
import NewTrainPage from "./NewTrainPage";
|
|
||||||
import DeleteTrainDialog from "./DeleteTrainDialog";
|
|
||||||
import { trains, type TrainStatus } from "./trains.mock";
|
|
||||||
import {
|
|
||||||
DataTable,
|
|
||||||
DataTableFooter,
|
|
||||||
type ColumnDef,
|
|
||||||
usePagination,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
Input,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
type FilterValue = "All" | TrainStatus;
|
|
||||||
|
|
||||||
const FILTERS: FilterValue[] = [
|
|
||||||
"All",
|
|
||||||
"Operational",
|
|
||||||
"In Maintenance",
|
|
||||||
"Idle",
|
|
||||||
"Out of Service",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function TrainsPage() {
|
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
||||||
const [filter, setFilter] = useState<FilterValue>("All");
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
return trains.filter((t) => {
|
|
||||||
if (filter !== "All" && t.status !== filter) return false;
|
|
||||||
if (!q) return true;
|
|
||||||
return (
|
|
||||||
t.code.toLowerCase().includes(q) ||
|
|
||||||
t.name.toLowerCase().includes(q) ||
|
|
||||||
t.depot.toLowerCase().includes(q) ||
|
|
||||||
t.type.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, [filter, query]);
|
|
||||||
|
|
||||||
const total = filtered.length;
|
|
||||||
const pageCount = Math.ceil(total / pagination.pageSize);
|
|
||||||
const start = pagination.pageIndex * pagination.pageSize;
|
|
||||||
const end = Math.min(start + pagination.pageSize, total);
|
|
||||||
|
|
||||||
const paginatedData = useMemo(
|
|
||||||
() => filtered.slice(start, end),
|
|
||||||
[start, end, filtered],
|
|
||||||
);
|
|
||||||
|
|
||||||
const operationalCount = trains.filter(
|
|
||||||
(t) => t.status === "Operational",
|
|
||||||
).length;
|
|
||||||
const maintenanceCount = trains.filter(
|
|
||||||
(t) => t.status === "In Maintenance",
|
|
||||||
).length;
|
|
||||||
|
|
||||||
const columns: ColumnDef<(typeof trains)[number]>[] = [
|
|
||||||
{
|
|
||||||
id: "train",
|
|
||||||
header: "Train",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const t = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
|
||||||
<TrainIcon />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-slate-900">{t.code}</p>
|
|
||||||
<p className="text-sm text-slate-500">{t.name}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "type",
|
|
||||||
header: "Type",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "capacityTons",
|
|
||||||
header: "Capacity",
|
|
||||||
cell: ({ row }) => <span>{row.original.capacityTons}t</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "depot",
|
|
||||||
header: "Depot",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "mileageKm",
|
|
||||||
header: "Mileage",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span>{row.original.mileageKm.toLocaleString()} km</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "status",
|
|
||||||
header: "Status",
|
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
size: 40,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const train = row.original;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex justify-end"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon">
|
|
||||||
<MoreHorizontal />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Eye />
|
|
||||||
View
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<NewTrainPage
|
|
||||||
mode="edit"
|
|
||||||
train={{
|
|
||||||
code: train.code,
|
|
||||||
name: train.name,
|
|
||||||
type: train.type,
|
|
||||||
status: train.status,
|
|
||||||
capacityTons: train.capacityTons,
|
|
||||||
depot: train.depot,
|
|
||||||
manufacturer: train.manufacturer,
|
|
||||||
mileageKm: train.mileageKm,
|
|
||||||
lastMaintenance: train.lastMaintenance,
|
|
||||||
nextMaintenance: train.nextMaintenance,
|
|
||||||
currentAssignment: train.currentAssignment,
|
|
||||||
yearBuilt: train.yearBuilt,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
|
|
||||||
<Pencil />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</NewTrainPage>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DeleteTrainDialog trainCode={train.code}>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={(e: Event) => e.preventDefault()}
|
|
||||||
variant="destructive"
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
Retire
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DeleteTrainDialog>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen p-6">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Breadcrumbs items={[{ label: "Trains" }]} />
|
|
||||||
|
|
||||||
<Card className="p-6 flex-row justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
|
||||||
Trains
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-secondary-foreground">
|
|
||||||
Fleet roster, capacity, and maintenance status.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
|
||||||
<div className="relative w-full sm:w-80">
|
|
||||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="search"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => {
|
|
||||||
setQuery(e.target.value);
|
|
||||||
setPagination({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
placeholder="Search trains..."
|
|
||||||
className="pl-8!"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NewTrainPage>
|
|
||||||
<Button>
|
|
||||||
<Plus />
|
|
||||||
New Train
|
|
||||||
</Button>
|
|
||||||
</NewTrainPage>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">Total Trains</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
||||||
{trains.length}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
||||||
<TrainIcon />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">Operational</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
||||||
{operationalCount}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
||||||
<Gauge />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">In Maintenance</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
||||||
{maintenanceCount}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
||||||
<Wrench />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="p-2">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{FILTERS.map((f) => {
|
|
||||||
const isActive = f === filter;
|
|
||||||
const count =
|
|
||||||
f === "All"
|
|
||||||
? trains.length
|
|
||||||
: trains.filter((t) => t.status === f).length;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={f}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setFilter(f);
|
|
||||||
setPagination({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className={
|
|
||||||
isActive
|
|
||||||
? "inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
|
||||||
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-primary/10 hover:text-primary"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{f}
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
isActive
|
|
||||||
? "rounded-full bg-white/20 px-2 py-0.5 text-xs"
|
|
||||||
: "rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="gap-0">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between border-b">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Fleet Roster</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
All locomotives and wagons in service.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button variant="secondary" size="sm">
|
|
||||||
<Filter />
|
|
||||||
Filter
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="px-0">
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={paginatedData}
|
|
||||||
status="success"
|
|
||||||
onRowClick={() => { }}
|
|
||||||
pagination={{
|
|
||||||
pageIndex: pagination.pageIndex,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
pageCount: pageCount,
|
|
||||||
totalCount: total,
|
|
||||||
}}
|
|
||||||
tableOptions={{
|
|
||||||
state: { pagination },
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
}}
|
|
||||||
containerClassName="border-b shadow-none"
|
|
||||||
footer={DataTableFooter}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: TrainStatus }) {
|
|
||||||
const styles: Record<TrainStatus, string> = {
|
|
||||||
Operational: "bg-emerald-100 text-emerald-700",
|
|
||||||
"In Maintenance": "bg-amber-100 text-amber-700",
|
|
||||||
Idle: "bg-slate-100 text-slate-600",
|
|
||||||
"Out of Service": "bg-red-100 text-red-700",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
export type TrainType =
|
|
||||||
| "Locomotive"
|
|
||||||
| "Freight Wagon"
|
|
||||||
| "Tanker Wagon"
|
|
||||||
| "Container Wagon"
|
|
||||||
| "Reefer Wagon";
|
|
||||||
|
|
||||||
export type TrainStatus =
|
|
||||||
| "Operational"
|
|
||||||
| "In Maintenance"
|
|
||||||
| "Idle"
|
|
||||||
| "Out of Service";
|
|
||||||
|
|
||||||
export interface Train {
|
|
||||||
id: number;
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
type: TrainType;
|
|
||||||
status: TrainStatus;
|
|
||||||
capacityTons: number;
|
|
||||||
depot: string;
|
|
||||||
manufacturer: string;
|
|
||||||
mileageKm: number;
|
|
||||||
lastMaintenance: string;
|
|
||||||
nextMaintenance: string;
|
|
||||||
currentAssignment: string;
|
|
||||||
yearBuilt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const seedTrains: Array<Omit<Train, "id" | "code">> = [
|
|
||||||
{
|
|
||||||
name: "Awash Express",
|
|
||||||
type: "Locomotive",
|
|
||||||
status: "Operational",
|
|
||||||
capacityTons: 240,
|
|
||||||
depot: "Addis Ababa",
|
|
||||||
manufacturer: "CRRC Zhuzhou",
|
|
||||||
mileageKm: 184320,
|
|
||||||
lastMaintenance: "2026-04-12",
|
|
||||||
nextMaintenance: "2026-07-12",
|
|
||||||
currentAssignment: "BK-026001",
|
|
||||||
yearBuilt: 2018,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Rift Valley Hauler",
|
|
||||||
type: "Locomotive",
|
|
||||||
status: "Operational",
|
|
||||||
capacityTons: 240,
|
|
||||||
depot: "Adama",
|
|
||||||
manufacturer: "CRRC Zhuzhou",
|
|
||||||
mileageKm: 156780,
|
|
||||||
lastMaintenance: "2026-03-28",
|
|
||||||
nextMaintenance: "2026-06-28",
|
|
||||||
currentAssignment: "BK-026004",
|
|
||||||
yearBuilt: 2019,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Djibouti Freighter",
|
|
||||||
type: "Container Wagon",
|
|
||||||
status: "Operational",
|
|
||||||
capacityTons: 60,
|
|
||||||
depot: "Dire Dawa",
|
|
||||||
manufacturer: "CRRC Yangtze",
|
|
||||||
mileageKm: 92110,
|
|
||||||
lastMaintenance: "2026-04-02",
|
|
||||||
nextMaintenance: "2026-08-02",
|
|
||||||
currentAssignment: "BK-026003",
|
|
||||||
yearBuilt: 2020,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Highlander 1",
|
|
||||||
type: "Freight Wagon",
|
|
||||||
status: "In Maintenance",
|
|
||||||
capacityTons: 80,
|
|
||||||
depot: "Addis Ababa",
|
|
||||||
manufacturer: "CRRC Yangtze",
|
|
||||||
mileageKm: 211450,
|
|
||||||
lastMaintenance: "2026-05-10",
|
|
||||||
nextMaintenance: "2026-05-20",
|
|
||||||
currentAssignment: "—",
|
|
||||||
yearBuilt: 2017,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Highlander 2",
|
|
||||||
type: "Freight Wagon",
|
|
||||||
status: "Operational",
|
|
||||||
capacityTons: 80,
|
|
||||||
depot: "Mojo",
|
|
||||||
manufacturer: "CRRC Yangtze",
|
|
||||||
mileageKm: 198020,
|
|
||||||
lastMaintenance: "2026-04-18",
|
|
||||||
nextMaintenance: "2026-07-18",
|
|
||||||
currentAssignment: "BK-026007",
|
|
||||||
yearBuilt: 2017,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Sheba Tanker",
|
|
||||||
type: "Tanker Wagon",
|
|
||||||
status: "Operational",
|
|
||||||
capacityTons: 70,
|
|
||||||
depot: "Awash",
|
|
||||||
manufacturer: "CRRC Zhuzhou",
|
|
||||||
mileageKm: 132540,
|
|
||||||
lastMaintenance: "2026-04-05",
|
|
||||||
nextMaintenance: "2026-07-05",
|
|
||||||
currentAssignment: "BK-026010",
|
|
||||||
yearBuilt: 2019,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Lalibela Cooler",
|
|
||||||
type: "Reefer Wagon",
|
|
||||||
status: "Idle",
|
|
||||||
capacityTons: 55,
|
|
||||||
depot: "Adama",
|
|
||||||
manufacturer: "CRRC Yangtze",
|
|
||||||
mileageKm: 67890,
|
|
||||||
lastMaintenance: "2026-03-22",
|
|
||||||
nextMaintenance: "2026-06-22",
|
|
||||||
currentAssignment: "—",
|
|
||||||
yearBuilt: 2021,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Awash Express II",
|
|
||||||
type: "Locomotive",
|
|
||||||
status: "Operational",
|
|
||||||
capacityTons: 240,
|
|
||||||
depot: "Mieso",
|
|
||||||
manufacturer: "CRRC Zhuzhou",
|
|
||||||
mileageKm: 145600,
|
|
||||||
lastMaintenance: "2026-04-22",
|
|
||||||
nextMaintenance: "2026-07-22",
|
|
||||||
currentAssignment: "BK-026013",
|
|
||||||
yearBuilt: 2019,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Coffee Belt Wagon",
|
|
||||||
type: "Container Wagon",
|
|
||||||
status: "Operational",
|
|
||||||
capacityTons: 60,
|
|
||||||
depot: "Addis Ababa",
|
|
||||||
manufacturer: "CRRC Yangtze",
|
|
||||||
mileageKm: 88240,
|
|
||||||
lastMaintenance: "2026-04-09",
|
|
||||||
nextMaintenance: "2026-08-09",
|
|
||||||
currentAssignment: "BK-026016",
|
|
||||||
yearBuilt: 2020,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Red Sea Hauler",
|
|
||||||
type: "Locomotive",
|
|
||||||
status: "Out of Service",
|
|
||||||
capacityTons: 240,
|
|
||||||
depot: "Djibouti City",
|
|
||||||
manufacturer: "CRRC Zhuzhou",
|
|
||||||
mileageKm: 264100,
|
|
||||||
lastMaintenance: "2026-02-14",
|
|
||||||
nextMaintenance: "2026-08-14",
|
|
||||||
currentAssignment: "—",
|
|
||||||
yearBuilt: 2015,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Ali Sabieh Express",
|
|
||||||
type: "Freight Wagon",
|
|
||||||
status: "Operational",
|
|
||||||
capacityTons: 80,
|
|
||||||
depot: "Ali Sabieh",
|
|
||||||
manufacturer: "CRRC Yangtze",
|
|
||||||
mileageKm: 102330,
|
|
||||||
lastMaintenance: "2026-04-15",
|
|
||||||
nextMaintenance: "2026-07-15",
|
|
||||||
currentAssignment: "BK-026019",
|
|
||||||
yearBuilt: 2020,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Holhol Tanker",
|
|
||||||
type: "Tanker Wagon",
|
|
||||||
status: "In Maintenance",
|
|
||||||
capacityTons: 70,
|
|
||||||
depot: "Holhol",
|
|
||||||
manufacturer: "CRRC Zhuzhou",
|
|
||||||
mileageKm: 178600,
|
|
||||||
lastMaintenance: "2026-05-08",
|
|
||||||
nextMaintenance: "2026-05-22",
|
|
||||||
currentAssignment: "—",
|
|
||||||
yearBuilt: 2018,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Aysha Carrier",
|
|
||||||
type: "Container Wagon",
|
|
||||||
status: "Operational",
|
|
||||||
capacityTons: 60,
|
|
||||||
depot: "Aysha",
|
|
||||||
manufacturer: "CRRC Yangtze",
|
|
||||||
mileageKm: 75940,
|
|
||||||
lastMaintenance: "2026-04-19",
|
|
||||||
nextMaintenance: "2026-08-19",
|
|
||||||
currentAssignment: "BK-026022",
|
|
||||||
yearBuilt: 2021,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Mojo Reefer",
|
|
||||||
type: "Reefer Wagon",
|
|
||||||
status: "Idle",
|
|
||||||
capacityTons: 55,
|
|
||||||
depot: "Mojo",
|
|
||||||
manufacturer: "CRRC Yangtze",
|
|
||||||
mileageKm: 49870,
|
|
||||||
lastMaintenance: "2026-04-01",
|
|
||||||
nextMaintenance: "2026-07-01",
|
|
||||||
currentAssignment: "—",
|
|
||||||
yearBuilt: 2022,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Simien Locomotive",
|
|
||||||
type: "Locomotive",
|
|
||||||
status: "Operational",
|
|
||||||
capacityTons: 240,
|
|
||||||
depot: "Addis Ababa",
|
|
||||||
manufacturer: "CRRC Zhuzhou",
|
|
||||||
mileageKm: 121340,
|
|
||||||
lastMaintenance: "2026-04-25",
|
|
||||||
nextMaintenance: "2026-07-25",
|
|
||||||
currentAssignment: "BK-026008",
|
|
||||||
yearBuilt: 2020,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Gibe Freight",
|
|
||||||
type: "Freight Wagon",
|
|
||||||
status: "Operational",
|
|
||||||
capacityTons: 80,
|
|
||||||
depot: "Adama",
|
|
||||||
manufacturer: "CRRC Yangtze",
|
|
||||||
mileageKm: 168200,
|
|
||||||
lastMaintenance: "2026-04-11",
|
|
||||||
nextMaintenance: "2026-07-11",
|
|
||||||
currentAssignment: "BK-026011",
|
|
||||||
yearBuilt: 2018,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const trains: Train[] = seedTrains.map((entry, i) => {
|
|
||||||
const id = i + 1;
|
|
||||||
const prefix =
|
|
||||||
entry.type === "Locomotive"
|
|
||||||
? "LOC"
|
|
||||||
: entry.type === "Tanker Wagon"
|
|
||||||
? "TNK"
|
|
||||||
: entry.type === "Reefer Wagon"
|
|
||||||
? "RFR"
|
|
||||||
: entry.type === "Container Wagon"
|
|
||||||
? "CNT"
|
|
||||||
: "WGN";
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
code: `${prefix}-${String(id).padStart(3, "0")}`,
|
|
||||||
...entry,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export function getTrainById(id: number | string): Train | undefined {
|
|
||||||
const numericId = typeof id === "string" ? Number(id) : id;
|
|
||||||
return trains.find((t) => t.id === numericId);
|
|
||||||
}
|
|
||||||
@@ -1,31 +1,23 @@
|
|||||||
import type { Freight, PaginatedResponse } from "@edr/types";
|
import type { Freight, PaginatedResponse } from "@edr/types";
|
||||||
import { client as api } from "@/utils/api";
|
|
||||||
|
import { client } from "../utils/api";
|
||||||
|
|
||||||
export type CreateBookingPayload = Freight.CreateBookingDto;
|
export type CreateBookingPayload = Freight.CreateBookingDto;
|
||||||
|
|
||||||
export const bookingsService = {
|
export const bookingsService = {
|
||||||
list: async (): Promise<PaginatedResponse<Freight.IBooking>> => {
|
list: async (): Promise<PaginatedResponse<Freight.IBooking>> => {
|
||||||
const { data } = await api.get("/bookings");
|
const { data } = await client.get("/bookings");
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
get: async (id: string): Promise<Freight.IBooking> => {
|
get: async (id: string): Promise<Freight.IBooking> => {
|
||||||
const { data } = await api.get(`/bookings/${id}`);
|
const { data } = await client.get(`/bookings/${id}`);
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
create: async (payload: CreateBookingPayload): Promise<Freight.IBooking> => {
|
create: async (payload: CreateBookingPayload): Promise<Freight.IBooking> => {
|
||||||
const fd = new FormData();
|
const { data } = await client.post("/api/bookings", payload);
|
||||||
for (const [key, value] of Object.entries(payload)) {
|
|
||||||
if (value === undefined || value === null) continue;
|
|
||||||
if (Array.isArray(value) || typeof value === "object") {
|
|
||||||
fd.append(key, JSON.stringify(value));
|
|
||||||
} else {
|
|
||||||
fd.append(key, String(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const { data } = await api.post("/api/bookings", fd);
|
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
remove: async (id: string): Promise<void> => {
|
remove: async (id: string): Promise<void> => {
|
||||||
await api.delete(`/bookings/${id}`);
|
await client.delete(`/bookings/${id}`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -53,6 +53,9 @@ importers:
|
|||||||
'@nestjs/core':
|
'@nestjs/core':
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
version: 11.1.23(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.23)(@nestjs/platform-express@11.1.23)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 11.1.23(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.23)(@nestjs/platform-express@11.1.23)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/mapped-types':
|
||||||
|
specifier: ^2.1.1
|
||||||
|
version: 2.1.1(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||||
'@nestjs/microservices':
|
'@nestjs/microservices':
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
version: 11.1.23(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 11.1.23(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
|||||||
Reference in New Issue
Block a user