add onboarding

This commit is contained in:
yaschalew
2026-05-26 10:15:59 +03:00
73 changed files with 6030 additions and 912 deletions

View File

@@ -18,9 +18,11 @@ import {
Settings,
UserCircle,
FileUp,
MapPinned,
} 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";
@@ -46,14 +48,16 @@ import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import SignupPage from "./pages/accounts/SignupPage";
import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
import Station from "./components/stations/Station";
const sidebarItems: SidebarItem[] = [
{ label: "My Portal", href: "/", icon: <UserCircle /> },
{ label: "Dashboard", href: "/dashboard", icon: <LayoutDashboard /> },
{ label: "Customers", href: "/customers", icon: <Users /> },
{ label: "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: "Stations", href: "/stations", icon: <MapPinned /> },
{ label: "Trains", href: "/trains", icon: <Train /> },
{ label: "Billing", href: "/billing", icon: <Receipt /> },
{ label: "Documents", href: "/documents", icon: <FileText /> },
@@ -118,9 +122,10 @@ const App = () => {
onLogout={handleLogout}
>
<Routes>
<Route path="/" element={<MyPortalPage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/bookings" element={<BookingsPage />} />
<Route path="/" element={<MyPortalPage />} />
<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 />} />
@@ -129,13 +134,11 @@ const App = () => {
<Route path="/consignments" element={<ConsignmentsPage />} />
<Route path="/consignments/:id" element={<ConsignmentDetailPage />} />
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/stations" element={<Station />} />
<Route path="/trains" element={<TrainsPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/documents" element={<DocumentsPage />} />
<Route
path="/admin/dropdowns"
element={<DropdownSettingsPage />}
/>
<Route path="/admin/dropdowns" element={<DropdownSettingsPage />} />
<Route
path="/admin/file-uploads"
element={<FileUploadSettingsPage />}

View File

@@ -0,0 +1,228 @@
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>
);
}

View File

@@ -1,3 +1,4 @@
export const FILE_SETTINGS = {
CUSTOMER_REGISTRATION: "customer_registration"
CUSTOMER_REGISTRATION: "customer_registration",
}

View File

@@ -12,7 +12,8 @@ export const URL_CONSTANTS = {
GENERATE_VERIFICATION_CODE: "/api/auth/generate-verification-code",
BASE: "/users",
BY_ID: (id: string | number) => `/users/${id}`,
SET_PASSWORD: "/api/auth/set-password"
SET_PASSWORD: "/api/auth/set-password",
ME: "/api/auth/me"
},
ROLES: {
@@ -67,10 +68,11 @@ export const URL_CONSTANTS = {
BY_ID: (id: string | number) => `/customers/${id}`,
BOOKINGS: (id: string | number) => `/customers/${id}/bookings`,
},
CUSTOMERS_API: {
BASE: "/api/customers",
BY_ID: (id: string) => `/api/customers/${id}`,
BY_USER_ID: (id: string) => `/api/customers/user/${id}`
},
BOOKINGS: {

View File

@@ -15,7 +15,7 @@ import {
import Breadcrumbs from "@/components/Breadcrumbs";
import DeleteBookingDialog from "./DeleteBookingDialog";
import { getBookingById, type BookingStatus } from "./bookings.mock";
import { getBookingById, deleteBooking, type BookingStatus } from "./bookings.mock";
import { Button, Card } from "@edr/ui-common";
export default function BookingDetailPage() {
@@ -88,7 +88,10 @@ export default function BookingDetailPage() {
<DeleteBookingDialog
bookingReference={booking.reference}
onConfirm={() => navigate("/bookings")}
onConfirm={() => {
deleteBooking(booking.id);
navigate("/bookings");
}}
>
<Button variant="outline">
<Trash2 />

View File

@@ -0,0 +1,333 @@
import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import {
ArrowRight,
Clock,
Eye,
Filter,
MoreHorizontal,
Package,
Plus,
Search,
Trash2,
Truck,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import DeleteBookingDialog from "./DeleteBookingDialog";
import { getMyBookings } from "@/lib/currentCustomer";
import { deleteBooking, type Booking, 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 MyBookings() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [searchTerm, setSearchTerm] = useState("");
const [myBookings, setMyBookings] = useState(() => getMyBookings());
const handleDeleteConfirm = (id: number) => {
deleteBooking(id);
setMyBookings(getMyBookings());
};
const filteredData = useMemo(() => {
return myBookings.filter((b) => {
const term = searchTerm.toLowerCase();
return (
b.reference.toLowerCase().includes(term) ||
b.originStation.toLowerCase().includes(term) ||
b.destinationStation.toLowerCase().includes(term) ||
b.cargoDescription.toLowerCase().includes(term) ||
b.status.toLowerCase().includes(term)
);
});
}, [myBookings, searchTerm]);
const total = filteredData.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(() => filteredData.slice(start, end), [filteredData, start, end]);
const activeCount = useMemo(() => {
return myBookings.filter(
(b) => b.status === "Confirmed" || b.status === "In Transit",
).length;
}, [myBookings]);
const pendingCount = useMemo(() => {
return myBookings.filter((b) => b.status === "Pending").length;
}, [myBookings]);
const columns: ColumnDef<Booking>[] = [
{
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 className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-slate-900">{booking.reference}</p>
<p className="text-sm text-slate-500">{booking.requestedDate}</p>
</div>
</div>
);
},
},
{
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 > 0 ? `${b.containerCount} × ${b.containerType} · ` : ""}{b.weightTons}t
</p>
</div>
);
},
},
{
accessorKey: "transportMode",
header: "Transport",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{row.original.transportMode}
</span>
),
},
{
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}
onConfirm={() => handleDeleteConfirm(booking.id)}
>
<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: "My Bookings" }]} />
{/* Header Section Card */}
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
My Bookings
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
View and manage your freight booking requests.
</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..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8!"
/>
</div>
<Link to="/bookings/new">
<Button>
<Plus />
New Booking
</Button>
</Link>
</div>
</Card>
{/* Stat Cards */}
<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">
{myBookings.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">Active Bookings</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{activeCount}
</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 Approval</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{pendingCount}
</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>
{/* Data Table */}
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Recent Requests</CardTitle>
<CardDescription>
A list of your recent freight bookings and their statuses.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
Filter
</Button>
</CardHeader>
<CardContent className="px-0">
{total === 0 ? (
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
<Package className="h-12 w-12 text-slate-300 mb-4" />
<h3 className="text-sm font-semibold text-slate-900">No bookings found</h3>
<p className="text-xs text-slate-500 mt-1 max-w-sm">
{searchTerm ? "No bookings match your current search filter." : "You haven't requested any bookings yet."}
</p>
</div>
) : (
<DataTable
columns={columns}
data={paginatedData}
status="success"
onRowClick={(row) => navigate(`/bookings/${(row as Booking).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>
);
}

View File

@@ -5,6 +5,10 @@ import { useNavigate } from "react-router-dom";
import { CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@edr/ui-common";
import Breadcrumbs from "@/components/Breadcrumbs";
import { addBooking } from "./bookings.mock";
import { getCurrentCustomer } from "@/lib/currentCustomer";
import { api } from "@/services/api";
import type { CreateBookingPayload } from "@/services/bookings.service";
import {
MOCK_VALID_CONTRACTS,
STEPS,
@@ -35,8 +39,8 @@ export default function NewBookingPage() {
const [submitted, setSubmitted] = useState(false);
const form = useForm<BookingFormValues>({
resolver: zodResolver(bookingFormSchema),
defaultValues: initialBookingFormValues,
resolver: zodResolver(bookingFormSchema),
mode: "onChange",
});
@@ -119,6 +123,123 @@ export default function NewBookingPage() {
return;
}
const me = getCurrentCustomer();
const reference =
data.draftContractId ||
data.previousContractRef ||
`EDR-DRAFT-${Date.now()}`;
const qtyCount =
data.cargoType === "container"
? data.containers.reduce((acc, c) => acc + Number(c.qty || 0), 0)
: 1;
const totalWeight =
data.cargoType === "container"
? data.containers.reduce(
(acc, c) => acc + Number(c.vgm || 0) * Number(c.qty || 0),
0,
)
: Number(data.cargoWeight || 0);
const description =
data.cargoType === "container"
? data.containers.map((c) => `${c.qty} × ${c.type}`).join(", ")
: data.freightType === "bulk"
? `Bulk - ${data.bulkCommodity === "Others" ? data.bulkCommodityOther : data.bulkCommodity}`
: `Break-Bulk - ${data.breakBulkType === "Others" ? data.breakBulkTypeOther : data.breakBulkType}`;
const newBooking = {
id: Date.now(),
reference,
customerId: me.id,
customer: me.company,
cargoType: (data.cargoType === "container"
? "Containerized"
: "Bulk") as any,
originStation: data.originYard,
destinationStation: data.destinationYard,
transportMode: (data.serviceType === "rail"
? "Rail"
: "Multimodal") as any,
containerType: (data.cargoType === "container" &&
data.containers[0]?.type === "40ft"
? "40FT"
: "20FT") as any,
containerCount: qtyCount,
weightTons: totalWeight,
requestedDate: new Date().toISOString().slice(0, 10),
priority: (data.isHazardous ? "High" : "Normal") as any,
cargoDescription: description,
specialInstructions: data.notes || "Standard handling required",
status: "Pending" as any,
};
addBooking(newBooking);
// Call API using api.bookings.create.call
const apiPayload = {
reference,
customerId: String(me.id),
scheduledDate: new Date().toISOString().slice(0, 10),
totalAmount: 0,
contractType: data.contractType.toUpperCase(),
previousContractId: data.previousContractRef || undefined,
serviceType:
data.serviceType === "rail" ? "RAIL_ONLY" : "RAIL_AND_FORWARDING",
firstMileEnabled: data.firstMileEnabled,
firstMilePickupAddress: data.firstMileEnabled
? data.pickUpAddress
: undefined,
lastMileEnabled: data.lastMileEnabled,
lastMileDeliveryAddress: data.lastMileEnabled
? data.deliveryAddress
: undefined,
equipmentReturn:
data.equipmentReturn === "with_return"
? "WITH_RETURN"
: "WITHOUT_RETURN",
originStation: data.originYard,
destinationStation: data.destinationYard,
cargoTotalWeightVgm: totalWeight,
freightType: data.cargoType === "container" ? "BREAK_BULK" : "BULK",
freightSubtype:
data.cargoType === "container"
? undefined
: data.freightType === "bulk"
? data.bulkCommodity
: data.breakBulkType,
isHazardous: data.isHazardous,
isRefrigerated: data.isRefrigerated,
tradeDirection:
getRouteDirection(data.originYard, data.destinationYard) === "export"
? "EXPORT"
: "IMPORT",
paymentCurrency: "USD",
allowConsolidation: data.consolidationEnabled,
...(data.cargoType === "container" && data.containers.length > 0
? {
containers: data.containers.map((c) => ({
type: c.type === "40ft" ? "40FT" as const : "20FT" as const,
qty: Number(c.qty || 1),
vgm: Number(c.vgm || 0),
})),
}
: {}),
};
api.bookings.create
.call(apiPayload as CreateBookingPayload)
.then((created) => {
console.log("Successfully created booking via API:", created);
})
.catch((err) => {
console.warn(
"API call failed (expected if API server is offline), falling back to mock storage:",
err,
);
});
setSubmitted(true);
setTimeout(() => navigate("/bookings"), 2500);
}

View File

@@ -83,7 +83,7 @@ function pickStation(i: number, offset: number) {
return stations[(i + offset) % stations.length] as string;
}
export const bookings: Booking[] = Array.from({ length: 22 }, (_, i) => {
const INITIAL_BOOKINGS: Booking[] = Array.from({ length: 22 }, (_, i) => {
const customer = customers[i % customers.length] as (typeof customers)[number];
const id = i + 1;
const requested = new Date(2026, 4, 1 + (i % 28));
@@ -125,6 +125,43 @@ export const bookings: Booking[] = Array.from({ length: 22 }, (_, i) => {
};
});
const getStoredBookings = (): Booking[] => {
if (typeof window === "undefined" || !window.localStorage) {
return INITIAL_BOOKINGS;
}
const data = localStorage.getItem("edr_bookings");
if (!data) {
localStorage.setItem("edr_bookings", JSON.stringify(INITIAL_BOOKINGS));
return INITIAL_BOOKINGS;
}
try {
return JSON.parse(data);
} catch (e) {
return INITIAL_BOOKINGS;
}
};
export const bookings: Booking[] = getStoredBookings();
export function saveBookingsToStorage() {
if (typeof window !== "undefined" && window.localStorage) {
localStorage.setItem("edr_bookings", JSON.stringify(bookings));
}
}
export function addBooking(booking: Booking) {
bookings.unshift(booking);
saveBookingsToStorage();
}
export function deleteBooking(id: number) {
const index = bookings.findIndex((b) => b.id === id);
if (index !== -1) {
bookings.splice(index, 1);
saveBookingsToStorage();
}
}
export function getBookingById(id: number | string): Booking | undefined {
const numericId = typeof id === "string" ? Number(id) : id;
return bookings.find((b) => b.id === numericId);

View File

@@ -46,7 +46,7 @@ export const REQUIRED_DOC_KEYS = [
"tin_certificate",
"business_license",
"business_registration",
"national_id",
// "national_id",
] as const;
export const STEPS = [
@@ -141,9 +141,6 @@ export const BOOKING_DOCS_SETTING = {
],
};
const requiredString = (message: string) =>
z.string().trim().min(1, { message });
const fileValueSchema = z.union([
z.custom<File>(),
z.array(z.custom<File>()),
@@ -152,10 +149,10 @@ const fileValueSchema = z.union([
export const bookingFormSchema = z
.object({
contractType: z.enum(["new", "renewal", ""]),
contractType: z.enum(["new", "renewal"], "Select a contract type."),
previousContractRef: z.string(),
draftContractId: z.string(),
serviceType: z.enum(["rail", "rail_forwarding", ""]),
serviceType: z.enum(["rail", "rail_forwarding"], "Select a service type."),
firstMileEnabled: z.boolean(),
pickUpAddress: z.string(),
lastMileEnabled: z.boolean(),
@@ -163,9 +160,9 @@ export const bookingFormSchema = z
equipmentReturn: z.enum(["with_return", "without_return"]),
originYard: z.string(),
destinationYard: z.string(),
cargoType: z.enum(["container", "bulk", ""]),
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
cargoWeight: z.string(),
freightType: z.enum(["bulk", "break_bulk", ""]),
freightType: z.enum(["bulk", "break_bulk", ""]).default(""),
bulkCommodity: z.string(),
bulkCommodityOther: z.string(),
breakBulkType: z.string(),
@@ -191,14 +188,6 @@ export const bookingFormSchema = z
termsAccepted: z.boolean(),
})
.superRefine((data, ctx) => {
if (!data.contractType) {
ctx.addIssue({
code: "custom",
path: ["contractType"],
message: "Select a contract type.",
});
}
if (data.contractType === "new" && !data.draftContractId.trim()) {
ctx.addIssue({
code: "custom",
@@ -215,14 +204,6 @@ export const bookingFormSchema = z
});
}
if (!data.serviceType) {
ctx.addIssue({
code: "custom",
path: ["serviceType"],
message: "Select a service type.",
});
}
if (data.firstMileEnabled && !data.pickUpAddress.trim()) {
ctx.addIssue({
code: "custom",
@@ -267,14 +248,6 @@ export const bookingFormSchema = z
});
}
if (!data.cargoType) {
ctx.addIssue({
code: "custom",
path: ["cargoType"],
message: "Select a cargo type.",
});
}
if (data.cargoType === "bulk") {
if (!data.freightType) {
ctx.addIssue({
@@ -385,11 +358,9 @@ export const bookingFormSchema = z
export type BookingFormValues = z.infer<typeof bookingFormSchema>;
export const initialBookingFormValues: BookingFormValues = {
contractType: "",
export const initialBookingFormValues: Partial<BookingFormValues> = {
previousContractRef: "",
draftContractId: "",
serviceType: "",
firstMileEnabled: false,
pickUpAddress: "",
lastMileEnabled: false,
@@ -397,9 +368,7 @@ export const initialBookingFormValues: BookingFormValues = {
equipmentReturn: "with_return",
originYard: "",
destinationYard: "",
cargoType: "",
cargoWeight: "",
freightType: "",
bulkCommodity: "",
bulkCommodityOther: "",
breakBulkType: "",
@@ -471,6 +440,12 @@ export function getRouteDirection(
dest: string,
): RouteDirection {
if (!origin || !dest) return null;
const oLocation = getStationLocation(origin);
const dLocation = getStationLocation(dest);
if (oLocation === "inside" && dLocation === "outside") return "export";
if (oLocation === "outside" && dLocation === "inside") return "import";
if (oLocation === "inside" && dLocation === "inside") return "domestic";
const oEth = ETHIOPIA_STATIONS.has(origin);
const dEth = ETHIOPIA_STATIONS.has(dest);
if (oEth && !dEth) return "export";
@@ -479,6 +454,13 @@ export function getRouteDirection(
return null;
}
function getStationLocation(value: string): "inside" | "outside" | null {
const normalized = value.trim().toLowerCase();
if (normalized.startsWith("inside")) return "inside";
if (normalized.startsWith("outside")) return "outside";
return null;
}
export function calcWagons(containers: ContainerConfig[]): WagonCalcResult {
const Ft40Wagons = containers
.filter((c) => c.type === "40ft")

View File

@@ -129,18 +129,24 @@ export function SelectField({
error,
label,
placeholder,
disabled,
children,
}: {
field: ControllerRenderProps<BookingFormValues>;
error?: RhfFieldError;
label: string;
placeholder: string;
disabled?: boolean;
children: ReactNode;
}) {
return (
<Field data-invalid={Boolean(error)}>
<FieldLabel>{label}</FieldLabel>
<Select value={String(field.value)} onValueChange={field.onChange}>
<Select
value={String(field.value)}
onValueChange={field.onChange}
disabled={disabled}
>
<SelectTrigger
className={cn("w-full ", error ? "border-destructive!" : "")}
aria-invalid={Boolean(error)}

View File

@@ -1,14 +1,25 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { Flame, MapPin, Snowflake } from "lucide-react";
import { Field, Separator, Switch } from "@edr/ui-common";
import { Field, SelectItem, Separator, Switch } from "@edr/ui-common";
import { type BookingFormValues, getRouteDirection, STATIONS } from "./schema";
import { SelectField, SelectOptions, StepHeader, StepLabel } from "./shared";
import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings";
import { DropdownOption } from "@/types/dropdownSettings";
type BookingForm = UseFormReturn<BookingFormValues>;
const STATION_DROPDOWN_CODE = "stations_ter";
export function Step4Route({ form }: { form: BookingForm }) {
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");
const {
data: stationSetting,
isLoading: stationsLoading,
isError: stationsError,
error: stationsFetchError,
} = useDropdownSettingByCode(STATION_DROPDOWN_CODE);
const stationOptions = getStationOptions(stationSetting?.children);
const direction = getRouteDirection(originYard, destinationYard);
const directionStyle: Record<string, string> = {
export: "bg-sky-50 text-sky-800 border-sky-200",
@@ -16,10 +27,11 @@ export function Step4Route({ form }: { form: BookingForm }) {
domestic: "bg-muted text-muted-foreground border-border",
};
const directionLabel: Record<string, string> = {
export: "Export workflow (Ethiopia to Djibouti)",
import: "Import workflow (Djibouti to Ethiopia)",
export: "Export workflow (inside country to outside country)",
import: "Import workflow (outside country to inside country)",
domestic: "Domestic corridor",
};
const stationSelectDisabled = stationsLoading || stationOptions.length === 0;
return (
<div className="space-y-6">
@@ -29,6 +41,7 @@ export function Step4Route({ form }: { form: BookingForm }) {
/>
<div className="space-y-3">
<StepLabel>Route</StepLabel>
<div className="grid gap-3 sm:grid-cols-2">
<Controller
name="originYard"
@@ -39,9 +52,12 @@ export function Step4Route({ form }: { form: BookingForm }) {
error={fieldState.error}
label="Origin Yard*"
placeholder="Select origin..."
disabled={stationSelectDisabled}
>
<SelectOptions
options={STATIONS.filter((s) => s !== destinationYard)}
<StationSelectOptions
options={stationOptions}
excludeValue={destinationYard}
isLoading={stationsLoading}
/>
</SelectField>
)}
@@ -55,14 +71,25 @@ export function Step4Route({ form }: { form: BookingForm }) {
error={fieldState.error}
label="Destination Yard *"
placeholder="Select destination..."
disabled={stationSelectDisabled}
>
<SelectOptions
options={STATIONS.filter((s) => s !== originYard)}
<StationSelectOptions
options={stationOptions}
excludeValue={originYard}
isLoading={stationsLoading}
/>
</SelectField>
)}
/>
</div>
{stationsError && (
<AlertBox tone="error">
Failed to load stations from the API.{" "}
{stationsFetchError instanceof Error
? stationsFetchError.message
: "Try again later."}
</AlertBox>
)}
{direction && (
<div
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium ${directionStyle[direction]}`}
@@ -117,3 +144,53 @@ export function Step4Route({ form }: { form: BookingForm }) {
</div>
);
}
function getStationOptions(options?: DropdownOption[]): DropdownOption[] {
return [...(options ?? [])].sort((a, b) => a.order - b.order);
}
function StationSelectOptions({
options,
excludeValue,
isLoading,
}: {
options: DropdownOption[];
excludeValue: string;
isLoading: boolean;
}) {
if (isLoading) {
return (
<SelectItem value="__stations_loading" disabled>
Loading stations...
</SelectItem>
);
}
const availableOptions = options.filter(
(option) => option.value !== excludeValue,
);
if (availableOptions.length === 0) {
return (
<SelectItem value="__stations_empty" disabled>
No stations available
</SelectItem>
);
}
return (
<>
{availableOptions.map((option) => (
<SelectItem
key={option.id}
value={option.value}
disabled={option.disabled}
>
{option.label}
</SelectItem>
))}
</>
);
}

View File

@@ -0,0 +1,223 @@
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>
);
}

View File

@@ -1,21 +1,19 @@
import { useEffect, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import type { ReactNode } from "react";
import { useState } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
Input,
Label,
Button,
Textarea,
SmartFileInput,
} from "@edr/ui-common";
} 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,
@@ -25,360 +23,602 @@ import {
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";
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 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?: Customer;
customer?: Partial<CustomerFormData>;
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";
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 open={open} onOpenChange={setOpen}>
{!isControlled ? (
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
</DialogTrigger>
) : null}
<Dialog>
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl!">
<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">
<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>
{/* 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>
<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>
{/* 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>
<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>
{/* 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>
<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>
{/* 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>
<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>
{/* 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>
<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>
{/* 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>
<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>
{/* 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>
<div className="space-y-2 md:col-span-2">
<Label>Address</Label>
{/* 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
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..."
name="notes"
value={formData.notes ?? ""}
onChange={handleChange}
placeholder="Add any additional notes about the customer..."
rows={3}
/>
</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"
<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={pending}
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
disabled={isSubmitting}
>
{pending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
submitLabel
)}
{isSubmitting ? "Submitting..." : 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>
);
}
}

View File

@@ -0,0 +1,273 @@
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>
);
}

View File

@@ -0,0 +1,386 @@
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>
);
}

View File

@@ -0,0 +1,72 @@
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>
);
}

View File

@@ -0,0 +1,384 @@
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>
);
}

View File

@@ -0,0 +1,110 @@
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);
}

View File

@@ -0,0 +1,486 @@
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>
);
}

View File

@@ -1,365 +1,293 @@
import { useMemo } from "react";
import { Link } from "react-router-dom";
import {
ArrowRight,
useEffect,
useMemo,
useState,
} from "react";
import {
Link,
useNavigate,
} from "react-router-dom";
import {
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";
import { getMyInfo } from "@/services/account";
import NewCustomerPage from "../customers/NewCustomerPage";
import { Button } from "@edr/ui-common";
type Customer = {
id: string;
companyName: string;
firstName: string;
lastName: string;
email: string;
phone: string;
};
export default function MyPortalPage() {
const navigate = useNavigate();
// ------------------------------------------------------------
// STATE
// ------------------------------------------------------------
const [loading, setLoading] = useState(true);
const [customer, setCustomer] = useState<any>(null);
// ------------------------------------------------------------
// MOCK DATA
// ------------------------------------------------------------
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);
// ------------------------------------------------------------
// FETCH CUSTOMER
// ------------------------------------------------------------
const recentBookings = [...myBookings].slice(0, 5);
const recentInvoices = [...myInvoices].slice(0, 4);
useEffect(() => {
const initialize = async () => {
try {
setLoading(true);
const userRes = await getMyInfo();
const userId = userRes?.data?.id;
localStorage.setItem("currentUser", JSON.stringify(userRes.data));
// if (!userId) {
// navigate("/login");
// return;
// }
const res = await customersService.getByUserId(userId);
if (res) {
setCustomer(res);
return;
}
// customer not found → onboarding
// navigate("/customers/register");
} catch (error: any) {
console.error("Customer fetch failed:", error);
const status = error?.response?.status;
if (status === 404) {
// navigate("/customers/register");
return;
}
if (status === 401) {
// navigate("/login");
return;
}
} finally {
setLoading(false);
}
};
initialize();
}, [navigate]);
// ------------------------------------------------------------
// 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" }]} />
{/* 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">
{/* 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="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 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>
<p className="text-sm text-white/80">Welcome back</p>
<h1 className="text-3xl font-bold tracking-tight">
{me.name}
<h1 className="text-2xl font-bold">
{customer.firstName} {customer.lastName}
</h1>
<p className="mt-1 flex items-center gap-2 text-sm text-white/80">
<p className="text-sm opacity-80 flex items-center gap-2">
<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>
{customer.companyName}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex 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"
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="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"
className="border border-white px-4 py-2 rounded-xl flex items-center gap-2"
>
<Truck className="h-4 w-4" />
Track Shipment
Track
</Link>
</div>
</div>
</div>
{/* My KPIs */}
{/* KPI */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<KpiCard
label="My Active Bookings"
label="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"}
tone={
outstandingInvoices.some((i) => i.status === "Overdue")
? "danger"
: "brand"
}
/>
<KpiCard
label="Total Spent"
value={formatCurrency(totalSpent, "USD")}
sub="All-time, paid invoices"
sub="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>
);
}
// ------------------------------------------------------------
// KPI CARD
// ------------------------------------------------------------
function KpiCard({
label,
value,
@@ -375,20 +303,29 @@ function KpiCard({
href?: string;
tone?: "brand" | "danger";
}) {
const iconWrap =
const iconClassName =
tone === "danger"
? "bg-red-100 text-red-600"
: "bg-[#10B981] text-white";
const inner = (
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>
<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}`}
className={`flex h-10 w-10 items-center justify-center rounded-2xl ${iconClassName}`}
>
{icon}
</div>
@@ -398,80 +335,20 @@ function KpiCard({
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>
);
}
if (href) {
return (
<Link
to={href}
className={`block ${className}`}
>
{content}
</Link>
);
}
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 className={className}>
{content}
</div>
);
}
function ShipmentBadge({ status }: { status: ShipmentStatus }) {
const styles: Record<ShipmentStatus, string> = {
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Delayed: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}
function BookingBadge({ status }: { status: BookingStatus }) {
const styles: Record<BookingStatus, string> = {
Pending: "bg-amber-100 text-amber-700",
Confirmed: "bg-sky-100 text-sky-700",
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Cancelled: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}
function InvoiceBadge({ status }: { status: InvoiceStatus }) {
const styles: Record<InvoiceStatus, string> = {
Draft: "bg-slate-100 text-slate-600",
Sent: "bg-sky-100 text-sky-700",
Paid: "bg-emerald-100 text-emerald-700",
Overdue: "bg-red-100 text-red-700",
Cancelled: "bg-amber-100 text-amber-700",
};
return (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}
}

View File

@@ -24,6 +24,17 @@ export const createUser = async (
return res.data;
};
export const getMyInfo = async () => {
const res =
await client.get<
ApiResponse<any>
>(
URL_CONSTANTS.USERS.ME
);
return res.data;
};
export const generateVerificationCode = async (
body: VerificationCodePayload
) => {

View File

@@ -8,7 +8,7 @@ import type {
UpdateFileUploadFieldDto,
UpdateFileUploadSettingDto,
} from "@/types/fileUploadSettings";
import { bookingsService } from "./bookings.service";
import { bookingsService, CreateBookingPayload } from "./bookings.service";
import { consignmentsService } from "./consignments.service";
import { trackingService } from "./tracking.service";
import { fileUploadSettingsService } from "./fileUploadSettings.service";
@@ -40,16 +40,11 @@ export const api = {
({ id }) => bookingsService.get(id),
),
create: endpoint<
{
reference: string;
customerId: string;
scheduledDate: string;
totalAmount: number;
trainId?: string;
},
Freight.IBooking
>("bookings", "create", (input) => bookingsService.create(input)),
create: endpoint<CreateBookingPayload, Freight.IBooking>(
"bookings",
"create",
bookingsService.create,
),
remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
bookingsService.remove(id),

View File

@@ -1,14 +1,7 @@
import type { Freight, PaginatedResponse } from "@edr/types";
import { client as api } from "@/utils/api";
import { api } from "./crud";
export interface CreateBookingPayload {
reference: string;
customerId: string;
scheduledDate: string;
totalAmount: number;
trainId?: string;
}
export type CreateBookingPayload = Freight.CreateBookingDto;
export const bookingsService = {
list: async (): Promise<PaginatedResponse<Freight.IBooking>> => {
@@ -20,7 +13,16 @@ export const bookingsService = {
return data.data;
},
create: async (payload: CreateBookingPayload): Promise<Freight.IBooking> => {
const { data } = await api.post("/bookings", payload);
const fd = new FormData();
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;
},
remove: async (id: string): Promise<void> => {

View File

@@ -1,14 +1,14 @@
import type { Freight, PaginatedResponse } from "@edr/types";
import { api } from "../utils/api";
import { client } from "../utils/api";
export const consignmentsService = {
list: async (): Promise<PaginatedResponse<Freight.IConsignment>> => {
const { data } = await api.get("/consignments");
const { data } = await client.get("/consignments");
return data.data;
},
get: async (id: string): Promise<Freight.IConsignment> => {
const { data } = await api.get(`/consignments/${id}`);
const { data } = await client.get(`/consignments/${id}`);
return data.data;
},
};

View File

@@ -23,8 +23,15 @@ export const customersService = {
return unwrap(response.data);
},
create: async (payload: CreateCustomerDto): Promise<Customer> => {
const response = await client.post<ApiResponse<Customer>>(BASE, payload);
getByUserId: async (userId: string): Promise<Customer> => {
const response = await client.get<ApiResponse<Customer>>(
URL_CONSTANTS.CUSTOMERS_API.BY_USER_ID(userId),
);
return unwrap(response.data);
},
create: async (payload: any): Promise<any> => {
const response = await client.post<ApiResponse<any>>(BASE, payload);
return unwrap(response.data);
},

View File

@@ -1,12 +1,12 @@
import type { Freight } from "@edr/types";
import { api } from "../utils/api";
import { client } from "../utils/api";
export const trackingService = {
forConsignment: async (
consignmentId: string,
): Promise<Freight.ITrackingEvent[]> => {
const { data } = await api.get(`/tracking/${consignmentId}`);
const { data } = await client.get(`/tracking/${consignmentId}`);
return data.data;
},
};

View File

@@ -0,0 +1,24 @@
// vite.config.ts
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "file:///home/meng/projects/edr-platform/node_modules/.pnpm/vite@5.4.21_@types+node@24.12.4_lightningcss@1.32.0_terser@5.48.0/node_modules/vite/dist/node/index.js";
import react from "file:///home/meng/projects/edr-platform/node_modules/.pnpm/@vitejs+plugin-react@4.7.0_vite@5.4.21_@types+node@24.12.4_lightningcss@1.32.0_terser@5.48.0_/node_modules/@vitejs/plugin-react/dist/index.js";
import tailwindcss from "file:///home/meng/projects/edr-platform/node_modules/.pnpm/@tailwindcss+vite@4.3.0_vite@5.4.21_@types+node@24.12.4_lightningcss@1.32.0_terser@5.48.0_/node_modules/@tailwindcss/vite/dist/index.mjs";
var __vite_injected_original_import_meta_url = "file:///home/meng/projects/edr-platform/apps/edr-freight-web/portal/vite.config.ts";
var __dirname = path.dirname(fileURLToPath(__vite_injected_original_import_meta_url));
var vite_config_default = defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src")
}
},
server: {
port: 5173,
host: "0.0.0.0"
}
});
export {
vite_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvaG9tZS9tZW5nL3Byb2plY3RzL2Vkci1wbGF0Zm9ybS9hcHBzL2Vkci1mcmVpZ2h0LXdlYi9wb3J0YWxcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9ob21lL21lbmcvcHJvamVjdHMvZWRyLXBsYXRmb3JtL2FwcHMvZWRyLWZyZWlnaHQtd2ViL3BvcnRhbC92aXRlLmNvbmZpZy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vaG9tZS9tZW5nL3Byb2plY3RzL2Vkci1wbGF0Zm9ybS9hcHBzL2Vkci1mcmVpZ2h0LXdlYi9wb3J0YWwvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgcGF0aCBmcm9tIFwibm9kZTpwYXRoXCI7XG5pbXBvcnQgeyBmaWxlVVJMVG9QYXRoIH0gZnJvbSBcIm5vZGU6dXJsXCI7XG5cbmltcG9ydCB7IGRlZmluZUNvbmZpZyB9IGZyb20gXCJ2aXRlXCI7XG5pbXBvcnQgcmVhY3QgZnJvbSBcIkB2aXRlanMvcGx1Z2luLXJlYWN0XCI7XG5pbXBvcnQgdGFpbHdpbmRjc3MgZnJvbSBcIkB0YWlsd2luZGNzcy92aXRlXCI7XG5cbmNvbnN0IF9fZGlybmFtZSA9IHBhdGguZGlybmFtZShmaWxlVVJMVG9QYXRoKGltcG9ydC5tZXRhLnVybCkpO1xuXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICBwbHVnaW5zOiBbcmVhY3QoKSwgdGFpbHdpbmRjc3MoKV0sXG4gIHJlc29sdmU6IHtcbiAgICBhbGlhczoge1xuICAgICAgXCJAXCI6IHBhdGgucmVzb2x2ZShfX2Rpcm5hbWUsIFwiLi9zcmNcIiksXG4gICAgfSxcbiAgfSxcbiAgc2VydmVyOiB7XG4gICAgcG9ydDogNTE3MyxcbiAgICBob3N0OiBcIjAuMC4wLjBcIixcbiAgfSxcbn0pO1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUFzVyxPQUFPLFVBQVU7QUFDdlgsU0FBUyxxQkFBcUI7QUFFOUIsU0FBUyxvQkFBb0I7QUFDN0IsT0FBTyxXQUFXO0FBQ2xCLE9BQU8saUJBQWlCO0FBTHdNLElBQU0sMkNBQTJDO0FBT2pSLElBQU0sWUFBWSxLQUFLLFFBQVEsY0FBYyx3Q0FBZSxDQUFDO0FBRTdELElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQzFCLFNBQVMsQ0FBQyxNQUFNLEdBQUcsWUFBWSxDQUFDO0FBQUEsRUFDaEMsU0FBUztBQUFBLElBQ1AsT0FBTztBQUFBLE1BQ0wsS0FBSyxLQUFLLFFBQVEsV0FBVyxPQUFPO0FBQUEsSUFDdEM7QUFBQSxFQUNGO0FBQUEsRUFDQSxRQUFRO0FBQUEsSUFDTixNQUFNO0FBQUEsSUFDTixNQUFNO0FBQUEsRUFDUjtBQUNGLENBQUM7IiwKICAibmFtZXMiOiBbXQp9Cg==