mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 08:18:20 +00:00
feat(customers): implement customer management page with mock data
- Created CustomersPage component to display a list of companies with search and pagination features. - Added mock data for companies, including various statuses and profiles. - Implemented a service layer to simulate API calls for fetching company data, bookings, documents, and payments. - Defined TypeScript types for company and related entities to ensure type safety. - Integrated Mantine components for UI consistency and improved user experience.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Boxes,
|
Boxes,
|
||||||
|
Building2,
|
||||||
Container,
|
Container,
|
||||||
FileText,
|
FileText,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -27,6 +28,8 @@ import BookingContractPage from "./pages/bookings/BookingContractPage";
|
|||||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||||
|
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||||
|
import CustomersPage from "./pages/customers/CustomersPage";
|
||||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||||
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
||||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||||
@@ -49,13 +52,13 @@ import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"
|
|||||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||||
import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage";
|
import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage";
|
||||||
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
|
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
|
||||||
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
|
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
|
||||||
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
|
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
|
||||||
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
|
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
|
||||||
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
|
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
|
||||||
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||||
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||||
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
|
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
|
||||||
@@ -87,6 +90,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
href: "/dashboard/booking-requests",
|
href: "/dashboard/booking-requests",
|
||||||
icon: <FileText />,
|
icon: <FileText />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Customers",
|
||||||
|
href: "/dashboard/customers",
|
||||||
|
icon: <Building2 />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Payments",
|
label: "Payments",
|
||||||
href: "/dashboard/payments",
|
href: "/dashboard/payments",
|
||||||
@@ -338,6 +346,8 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path="customers" element={<CustomersPage />} />
|
||||||
|
<Route path="customers/:id" element={<CustomerDetailPage />} />
|
||||||
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
||||||
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Box, Card } from "@mantine/core";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface TableCardProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* Minimum width (px) the table is forced to occupy. The Mantine `Table` is
|
||||||
|
* always `width: 100%`, so without a floor it can never overflow its
|
||||||
|
* container and the horizontal scroll never engages. Setting a floor lets
|
||||||
|
* columns keep a sensible width and the card scroll horizontally on narrow
|
||||||
|
* viewports instead of squishing.
|
||||||
|
*/
|
||||||
|
minWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush card shell for a `DataTable`: a borderless, padding-less card whose
|
||||||
|
* single child is a horizontally scrollable region. Pair with the table's
|
||||||
|
* `containerClassName="border-0 shadow-none bg-transparent"` so every table on
|
||||||
|
* the customer pages reads identically (same surface, same scroll behaviour).
|
||||||
|
*/
|
||||||
|
export function TableCard({ children, minWidth = 860 }: TableCardProps) {
|
||||||
|
return (
|
||||||
|
<Card p={0}>
|
||||||
|
<Box style={{ overflowX: "auto" }} w="100%">
|
||||||
|
<Box miw={minWidth}>{children}</Box>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TableCard;
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { Badge, Group, Tooltip } from "@mantine/core";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CompanyProfile,
|
||||||
|
CompanyStatus,
|
||||||
|
CompanyType,
|
||||||
|
CustomerBookingStatus,
|
||||||
|
CustomerPaymentStatus,
|
||||||
|
ProfileStatus,
|
||||||
|
ProfileType,
|
||||||
|
} from "@/types/customer";
|
||||||
|
|
||||||
|
import { humanize } from "./format";
|
||||||
|
|
||||||
|
const badgeStyle = {
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
whiteSpace: "nowrap" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Shared status palette — active/paid green, pending amber, terminal red. */
|
||||||
|
const STATUS_COLOR: Record<CompanyStatus | ProfileStatus, string> = {
|
||||||
|
active: "edr-green",
|
||||||
|
pending: "yellow",
|
||||||
|
suspended: "orange",
|
||||||
|
blacklisted: "red",
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMPANY_TYPE_COLOR: Record<CompanyType, string> = {
|
||||||
|
customer: "edr-green",
|
||||||
|
freight_forwarder: "blue",
|
||||||
|
dj_freight_forwarder: "indigo",
|
||||||
|
transporter: "grape",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROFILE_TYPE_COLOR: Record<ProfileType, string> = {
|
||||||
|
importer: "teal",
|
||||||
|
exporter: "cyan",
|
||||||
|
freight_forwarder: "blue",
|
||||||
|
dj_freight_forwarder: "indigo",
|
||||||
|
transporter: "grape",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompanyStatusBadge({ status }: { status: CompanyStatus }) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color={STATUS_COLOR[status] ?? "gray"}
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
tt="capitalize"
|
||||||
|
fw={600}
|
||||||
|
style={badgeStyle}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompanyTypeBadge({ type }: { type: CompanyType }) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color={COMPANY_TYPE_COLOR[type] ?? "gray"}
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
fw={600}
|
||||||
|
style={badgeStyle}
|
||||||
|
>
|
||||||
|
{humanize(type)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profile chips for a company row: one chip per role (Importer / Exporter / …)
|
||||||
|
* carrying its reference code. Caps at three (a company has at most three
|
||||||
|
* profiles); any extra collapse into a `+N` chip.
|
||||||
|
*/
|
||||||
|
export function ProfileChips({
|
||||||
|
profiles,
|
||||||
|
max = 3,
|
||||||
|
}: {
|
||||||
|
profiles: CompanyProfile[];
|
||||||
|
max?: number;
|
||||||
|
}) {
|
||||||
|
if (!profiles.length) {
|
||||||
|
return (
|
||||||
|
<Badge color="gray" variant="light" size="sm" radius="md" style={badgeStyle}>
|
||||||
|
No profiles
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shown = profiles.slice(0, max);
|
||||||
|
const extra = profiles.length - shown.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap={6} wrap="wrap">
|
||||||
|
{shown.map((profile) => (
|
||||||
|
<Tooltip
|
||||||
|
key={profile.id}
|
||||||
|
label={`${humanize(profile.type)} · ${humanize(profile.status)}`}
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
color={PROFILE_TYPE_COLOR[profile.type] ?? "gray"}
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
fw={600}
|
||||||
|
style={badgeStyle}
|
||||||
|
>
|
||||||
|
{humanize(profile.type)} · {profile.reference}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
{extra > 0 ? (
|
||||||
|
<Badge color="gray" variant="light" size="sm" radius="md" style={badgeStyle}>
|
||||||
|
+{extra}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileTypeBadge({ type }: { type: ProfileType }) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color={PROFILE_TYPE_COLOR[type] ?? "gray"}
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
fw={600}
|
||||||
|
style={badgeStyle}
|
||||||
|
>
|
||||||
|
{humanize(type)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileStatusBadge({ status }: { status: ProfileStatus }) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color={STATUS_COLOR[status] ?? "gray"}
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
tt="capitalize"
|
||||||
|
fw={600}
|
||||||
|
style={badgeStyle}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOOKING_STATUS_COLOR: Record<CustomerBookingStatus, string> = {
|
||||||
|
DRAFT: "gray",
|
||||||
|
SUBMITTED: "yellow",
|
||||||
|
PENDING_APPROVAL: "yellow",
|
||||||
|
APPROVED: "cyan",
|
||||||
|
PAID: "edr-green",
|
||||||
|
IN_TRANSIT: "blue",
|
||||||
|
COMPLETED: "indigo",
|
||||||
|
REJECTED: "red",
|
||||||
|
CANCELLED: "red",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BookingStatusBadge({ status }: { status: CustomerBookingStatus }) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color={BOOKING_STATUS_COLOR[status] ?? "gray"}
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
tt="uppercase"
|
||||||
|
fw={600}
|
||||||
|
style={badgeStyle}
|
||||||
|
>
|
||||||
|
{humanize(status)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAYMENT_STATUS_COLOR: Record<CustomerPaymentStatus, string> = {
|
||||||
|
"action-required": "orange",
|
||||||
|
processing: "yellow",
|
||||||
|
success: "edr-green",
|
||||||
|
failed: "red",
|
||||||
|
canceled: "gray",
|
||||||
|
refunded: "grape",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color={PAYMENT_STATUS_COLOR[status] ?? "gray"}
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
tt="capitalize"
|
||||||
|
fw={600}
|
||||||
|
style={badgeStyle}
|
||||||
|
>
|
||||||
|
{humanize(status)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/** Shared formatting helpers for the customer-management pages. */
|
||||||
|
|
||||||
|
/** snake_case / SCREAMING_CASE → Title Case. */
|
||||||
|
export function humanize(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[_\s]+/)
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(value: string | null | undefined): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
const d = new Date(value);
|
||||||
|
return Number.isNaN(d.getTime())
|
||||||
|
? "—"
|
||||||
|
: d.toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMoney(amount: number, currency: string): string {
|
||||||
|
return new Intl.NumberFormat(undefined, {
|
||||||
|
style: "currency",
|
||||||
|
currency,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (!bytes) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
const value = bytes / Math.pow(1024, i);
|
||||||
|
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export {
|
||||||
|
BookingStatusBadge,
|
||||||
|
CompanyStatusBadge,
|
||||||
|
CompanyTypeBadge,
|
||||||
|
PaymentStatusBadge,
|
||||||
|
ProfileChips,
|
||||||
|
ProfileStatusBadge,
|
||||||
|
ProfileTypeBadge,
|
||||||
|
} from "./badges";
|
||||||
|
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
||||||
|
export { TableCard, type TableCardProps } from "./TableCard";
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { BookingListFilter } from "@/services/bookings.service";
|
import type { BookingListFilter } from "@/services/bookings.service";
|
||||||
|
import type { CompanyListFilter } from "@/types/customer";
|
||||||
import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
|
import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
|
||||||
import type { TrainScheduleFilters } from "@/types/trainScheduling";
|
import type { TrainScheduleFilters } from "@/types/trainScheduling";
|
||||||
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
||||||
@@ -26,8 +27,12 @@ export const QUERY_KEYS = {
|
|||||||
|
|
||||||
CUSTOMERS: {
|
CUSTOMERS: {
|
||||||
ROOT: ["customers"] as const,
|
ROOT: ["customers"] as const,
|
||||||
list: () => ["customers", "list"] as const,
|
list: (filter?: CompanyListFilter) =>
|
||||||
|
["customers", "list", filter ?? {}] as const,
|
||||||
byId: (id: string) => ["customers", "detail", id] as const,
|
byId: (id: string) => ["customers", "detail", id] as const,
|
||||||
|
bookings: (id: string) => ["customers", "detail", id, "bookings"] as const,
|
||||||
|
documents: (id: string) => ["customers", "detail", id, "documents"] as const,
|
||||||
|
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
BOOKINGS: {
|
BOOKINGS: {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
|
import { customersService } from "@/services/customers.service";
|
||||||
|
import type { CompanyListFilter } from "@/types/customer";
|
||||||
|
|
||||||
|
export function useCustomerList(filter: CompanyListFilter) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: QUERY_KEYS.CUSTOMERS.list(filter),
|
||||||
|
queryFn: () => customersService.list(filter),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCustomerDetail(id: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: QUERY_KEYS.CUSTOMERS.byId(id ?? ""),
|
||||||
|
queryFn: () => customersService.getById(id!),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCustomerBookings(id: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: QUERY_KEYS.CUSTOMERS.bookings(id ?? ""),
|
||||||
|
queryFn: () => customersService.bookingsFor(id!),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCustomerDocuments(id: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: QUERY_KEYS.CUSTOMERS.documents(id ?? ""),
|
||||||
|
queryFn: () => customersService.documentsFor(id!),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCustomerPayments(id: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: QUERY_KEYS.CUSTOMERS.payments(id ?? ""),
|
||||||
|
queryFn: () => customersService.paymentsFor(id!),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,12 +1,555 @@
|
|||||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Center,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Tabs,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Banknote,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
IdCard,
|
||||||
|
LayoutGrid,
|
||||||
|
Package,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
const CustomerDetailPage = () => {
|
import {
|
||||||
|
BookingStatusBadge,
|
||||||
|
CompanyStatusBadge,
|
||||||
|
CompanyTypeBadge,
|
||||||
|
PaymentStatusBadge,
|
||||||
|
ProfileChips,
|
||||||
|
ProfileStatusBadge,
|
||||||
|
ProfileTypeBadge,
|
||||||
|
TableCard,
|
||||||
|
formatBytes,
|
||||||
|
formatDate,
|
||||||
|
formatMoney,
|
||||||
|
humanize,
|
||||||
|
} from "@/components/customers";
|
||||||
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||||
|
import {
|
||||||
|
useCustomerBookings,
|
||||||
|
useCustomerDetail,
|
||||||
|
useCustomerDocuments,
|
||||||
|
useCustomerPayments,
|
||||||
|
} from "@/hooks/customers/useCustomers";
|
||||||
|
import type {
|
||||||
|
CompanyProfile,
|
||||||
|
CustomerBooking,
|
||||||
|
CustomerDocument,
|
||||||
|
CustomerPayment,
|
||||||
|
} from "@/types/customer";
|
||||||
|
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||||
|
|
||||||
|
function InfoField({ label, value }: { label: string; value?: string | null }) {
|
||||||
return (
|
return (
|
||||||
<FeaturePlaceholder
|
<Stack gap={2}>
|
||||||
title="Customer Detail"
|
<Text
|
||||||
description="View customer profile details, active shipments, and internal account notes."
|
size="xs"
|
||||||
/>
|
fw={600}
|
||||||
|
c="edr-muted"
|
||||||
|
tt="uppercase"
|
||||||
|
style={{ letterSpacing: "0.04em" }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="edr-text">
|
||||||
|
{value && value.trim() ? value : "—"}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default CustomerDetailPage;
|
function tableStatus(query: { isLoading: boolean; isError: boolean }) {
|
||||||
|
return query.isLoading ? "loading" : query.isError ? "error" : "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomerDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data: company, isLoading } = useCustomerDetail(id);
|
||||||
|
const bookingsQuery = useCustomerBookings(id);
|
||||||
|
const documentsQuery = useCustomerDocuments(id);
|
||||||
|
const paymentsQuery = useCustomerPayments(id);
|
||||||
|
|
||||||
|
const bookings = bookingsQuery.data ?? [];
|
||||||
|
const documents = documentsQuery.data ?? [];
|
||||||
|
const payments = paymentsQuery.data ?? [];
|
||||||
|
|
||||||
|
const totalPaid = useMemo(
|
||||||
|
() =>
|
||||||
|
payments
|
||||||
|
.filter((p) => p.status === "success")
|
||||||
|
.reduce((sum, p) => sum + p.amount, 0),
|
||||||
|
[payments],
|
||||||
|
);
|
||||||
|
const paidCurrency = payments[0]?.currency ?? "ETB";
|
||||||
|
|
||||||
|
const profileColumns: ColumnDef<CompanyProfile>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "type",
|
||||||
|
header: "Role",
|
||||||
|
cell: ({ row }) => <ProfileTypeBadge type={row.original.type} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "reference",
|
||||||
|
header: "Reference",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
{row.original.reference}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "businessLicense",
|
||||||
|
header: "Business license",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{row.original.businessLicense || "—"}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: ({ row }) => <ProfileStatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "createdAt",
|
||||||
|
header: "Registered",
|
||||||
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{formatDate(row.original.createdAt)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bookingColumns: ColumnDef<CustomerBooking>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "reference",
|
||||||
|
header: "Booking",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
{row.original.reference}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "route",
|
||||||
|
header: "Route",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const b = row.original;
|
||||||
|
return (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Text size="sm" c="edr-text">
|
||||||
|
{b.originLabel}
|
||||||
|
</Text>
|
||||||
|
<ArrowRight size={14} className="shrink-0 text-edr-muted" />
|
||||||
|
<Text size="sm" c="edr-text">
|
||||||
|
{b.destinationLabel}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "type",
|
||||||
|
header: "Type",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{humanize(row.original.tradeDirection)} ·{" "}
|
||||||
|
{humanize(row.original.freightType)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: ({ row }) => <BookingStatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "amount",
|
||||||
|
header: "Amount",
|
||||||
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
{formatMoney(row.original.totalAmount, row.original.currency)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "createdAt",
|
||||||
|
header: "Created",
|
||||||
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{formatDate(row.original.createdAt)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const documentColumns: ColumnDef<CustomerDocument>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
header: "Document",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<FileText size={16} className="shrink-0 text-edr-muted" />
|
||||||
|
<Text size="sm" c="edr-text" truncate>
|
||||||
|
{row.original.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "code",
|
||||||
|
header: "Type",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{humanize(row.original.code)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "size",
|
||||||
|
header: "Size",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{formatBytes(row.original.size)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "uploadedAt",
|
||||||
|
header: "Uploaded",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{formatDate(row.original.uploadedAt)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "",
|
||||||
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<ActionIcon
|
||||||
|
component="a"
|
||||||
|
href={row.original.url ?? "#"}
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label="Download"
|
||||||
|
data-stop-row-click
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "reference",
|
||||||
|
header: "Payment",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
{row.original.reference}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "booking",
|
||||||
|
header: "Booking",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{row.original.bookingReference}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "method",
|
||||||
|
header: "Method",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{humanize(row.original.method)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: ({ row }) => <PaymentStatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "paidAt",
|
||||||
|
header: "Paid",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{formatDate(row.original.paidAt)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "amount",
|
||||||
|
header: "Amount",
|
||||||
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
{formatMoney(row.original.amount, row.original.currency)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Center mih="60vh">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!company) {
|
||||||
|
return (
|
||||||
|
<Container size="sm" py="xl">
|
||||||
|
<Stack align="center" gap="md">
|
||||||
|
<Text fw={700}>Customer not found</Text>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
leftSection={<ArrowLeft size={16} />}
|
||||||
|
onClick={() => navigate("/dashboard/customers")}
|
||||||
|
>
|
||||||
|
Back to customers
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Customers", href: "/dashboard/customers" },
|
||||||
|
{ label: company.name },
|
||||||
|
]}
|
||||||
|
backTo="/dashboard/customers"
|
||||||
|
title={company.name}
|
||||||
|
subtitle={`TIN ${company.tin}${
|
||||||
|
company.country ? ` · ${company.country}` : ""
|
||||||
|
}`}
|
||||||
|
meta={
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<CompanyTypeBadge type={company.type} />
|
||||||
|
<CompanyStatusBadge status={company.status} />
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs defaultValue="overview">
|
||||||
|
<Tabs.List>
|
||||||
|
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={16} />}>
|
||||||
|
Overview
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="bookings" leftSection={<Package size={16} />}>
|
||||||
|
Bookings
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="documents" leftSection={<FileText size={16} />}>
|
||||||
|
Documents
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="payments" leftSection={<Banknote size={16} />}>
|
||||||
|
Payments
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
{/* OVERVIEW */}
|
||||||
|
<Tabs.Panel value="overview" pt="lg">
|
||||||
|
<Stack gap="lg">
|
||||||
|
<KpiStrip
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: "Profiles",
|
||||||
|
value: company.companyProfiles.length,
|
||||||
|
icon: IdCard,
|
||||||
|
color: "edr-green",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Bookings",
|
||||||
|
value: bookings.length,
|
||||||
|
icon: Package,
|
||||||
|
color: "blue",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Total paid",
|
||||||
|
value: formatMoney(totalPaid, paidCurrency),
|
||||||
|
icon: Banknote,
|
||||||
|
color: "edr-green",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Documents",
|
||||||
|
value: documents.length,
|
||||||
|
icon: FileText,
|
||||||
|
color: "grape",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Text fw={600} c="edr-text">
|
||||||
|
Company information
|
||||||
|
</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||||
|
<InfoField label="TIN" value={company.tin} />
|
||||||
|
<InfoField label="VAT number" value={company.vatNumber} />
|
||||||
|
<InfoField label="FAN number" value={company.fanNumber} />
|
||||||
|
<InfoField label="Country" value={company.country} />
|
||||||
|
<InfoField label="Address" value={company.address} />
|
||||||
|
<InfoField label="Website" value={company.website} />
|
||||||
|
<InfoField label="Email" value={company.email} />
|
||||||
|
<InfoField label="Phone" value={company.phone} />
|
||||||
|
<Box />
|
||||||
|
<InfoField
|
||||||
|
label="Contact person"
|
||||||
|
value={company.contactPersonName}
|
||||||
|
/>
|
||||||
|
<InfoField
|
||||||
|
label="Contact phone"
|
||||||
|
value={company.contactPersonPhone}
|
||||||
|
/>
|
||||||
|
<Box />
|
||||||
|
<InfoField
|
||||||
|
label="General manager"
|
||||||
|
value={company.generalManagerName}
|
||||||
|
/>
|
||||||
|
<InfoField
|
||||||
|
label="GM email"
|
||||||
|
value={company.generalManagerEmail}
|
||||||
|
/>
|
||||||
|
<InfoField
|
||||||
|
label="GM phone"
|
||||||
|
value={company.generalManagerPhone}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={600} c="edr-text">
|
||||||
|
Role profiles
|
||||||
|
</Text>
|
||||||
|
<ProfileChips profiles={company.companyProfiles} />
|
||||||
|
</Group>
|
||||||
|
<Box style={{ overflowX: "auto" }} w="100%">
|
||||||
|
<Box miw={720}>
|
||||||
|
<DataTable
|
||||||
|
columns={profileColumns}
|
||||||
|
data={company.companyProfiles}
|
||||||
|
status="success"
|
||||||
|
emptyMessage="No profiles registered."
|
||||||
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
{/* BOOKINGS */}
|
||||||
|
<Tabs.Panel value="bookings" pt="lg">
|
||||||
|
<TableCard minWidth={900}>
|
||||||
|
<DataTable
|
||||||
|
columns={bookingColumns}
|
||||||
|
data={bookings}
|
||||||
|
status={tableStatus(bookingsQuery)}
|
||||||
|
emptyMessage="No bookings for this customer."
|
||||||
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
|
error={
|
||||||
|
bookingsQuery.isError
|
||||||
|
? {
|
||||||
|
message: "Failed to load bookings.",
|
||||||
|
onRetry: () => void bookingsQuery.refetch(),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCard>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
{/* DOCUMENTS */}
|
||||||
|
<Tabs.Panel value="documents" pt="lg">
|
||||||
|
<TableCard minWidth={760}>
|
||||||
|
<DataTable
|
||||||
|
columns={documentColumns}
|
||||||
|
data={documents}
|
||||||
|
status={tableStatus(documentsQuery)}
|
||||||
|
emptyMessage="No documents uploaded."
|
||||||
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
|
error={
|
||||||
|
documentsQuery.isError
|
||||||
|
? {
|
||||||
|
message: "Failed to load documents.",
|
||||||
|
onRetry: () => void documentsQuery.refetch(),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCard>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
{/* PAYMENTS */}
|
||||||
|
<Tabs.Panel value="payments" pt="lg">
|
||||||
|
<TableCard minWidth={880}>
|
||||||
|
<DataTable
|
||||||
|
columns={paymentColumns}
|
||||||
|
data={payments}
|
||||||
|
status={tableStatus(paymentsQuery)}
|
||||||
|
emptyMessage="No payments recorded."
|
||||||
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
|
error={
|
||||||
|
paymentsQuery.isError
|
||||||
|
? {
|
||||||
|
message: "Failed to load payments.",
|
||||||
|
onRetry: () => void paymentsQuery.refetch(),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCard>
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,271 @@
|
|||||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
ShieldOff,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
const CustomersPage = () => {
|
import {
|
||||||
return (
|
CompanyStatusBadge,
|
||||||
<FeaturePlaceholder
|
CompanyTypeBadge,
|
||||||
title="Customers"
|
ProfileChips,
|
||||||
description="Review and maintain customer records, service status, and operational context."
|
formatDate,
|
||||||
/>
|
} from "@/components/customers";
|
||||||
);
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||||
|
import { useCustomerList } from "@/hooks/customers/useCustomers";
|
||||||
|
import { MOCK_COMPANIES } from "@/pages/customers/customers.mock";
|
||||||
|
import type { Company } from "@/types/customer";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableFooter,
|
||||||
|
usePagination,
|
||||||
|
type ColumnDef,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
/** KPI counts are derived from the full fixture set (mock-only). */
|
||||||
|
const KPIS = {
|
||||||
|
total: MOCK_COMPANIES.length,
|
||||||
|
active: MOCK_COMPANIES.filter((c) => c.status === "active").length,
|
||||||
|
pending: MOCK_COMPANIES.filter((c) => c.status === "pending").length,
|
||||||
|
blacklisted: MOCK_COMPANIES.filter((c) => c.status === "blacklisted").length,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CustomersPage;
|
export default function CustomersPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||||
|
|
||||||
|
const filter = useMemo(
|
||||||
|
() => ({
|
||||||
|
page: pagination.pageIndex + 1,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
search: debouncedQuery,
|
||||||
|
}),
|
||||||
|
[pagination.pageIndex, pagination.pageSize, debouncedQuery],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch, isFetching } =
|
||||||
|
useCustomerList(filter);
|
||||||
|
|
||||||
|
const rows = data?.items ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||||
|
|
||||||
|
const columns: ColumnDef<Company>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "company",
|
||||||
|
header: "Company",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const c = row.original;
|
||||||
|
return (
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
className="flex size-9 shrink-0 items-center justify-center rounded-lg"
|
||||||
|
style={{
|
||||||
|
background: "var(--mantine-color-edr-green-1)",
|
||||||
|
color: "var(--mantine-color-edr-green-7)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Building2 size={18} strokeWidth={1.9} />
|
||||||
|
</Box>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Text fw={600} c="edr-text" truncate>
|
||||||
|
{c.name}
|
||||||
|
</Text>
|
||||||
|
<CompanyTypeBadge type={c.type} />
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
TIN {c.tin}
|
||||||
|
{c.country ? ` · ${c.country}` : ""}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "profiles",
|
||||||
|
header: "Profiles",
|
||||||
|
cell: ({ row }) => <ProfileChips profiles={row.original.companyProfiles} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: ({ row }) => <CompanyStatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "contact",
|
||||||
|
header: "Contact",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const c = row.original;
|
||||||
|
return (
|
||||||
|
<Stack gap={2}>
|
||||||
|
{c.contactPersonName ? (
|
||||||
|
<Text size="sm" c="edr-text">
|
||||||
|
{c.contactPersonName}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{c.phone ? (
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
c="dimmed"
|
||||||
|
className="inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Phone size={12} /> {c.phone}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{c.email ? (
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
c="dimmed"
|
||||||
|
className="inline-flex items-center gap-1"
|
||||||
|
truncate
|
||||||
|
>
|
||||||
|
<Mail size={12} /> {c.email}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "created",
|
||||||
|
header: "Registered",
|
||||||
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{formatDate(row.original.createdAt)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title="Customers"
|
||||||
|
subtitle="Companies registered for freight services, with their role profiles."
|
||||||
|
action={
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
aria-label="Refresh"
|
||||||
|
loading={isFetching}
|
||||||
|
onClick={() => void refetch()}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<KpiStrip
|
||||||
|
items={[
|
||||||
|
{ label: "Companies", value: KPIS.total, icon: Users, color: "edr-green" },
|
||||||
|
{ label: "Active", value: KPIS.active, icon: CheckCircle2, color: "edr-green" },
|
||||||
|
{ label: "Pending", value: KPIS.pending, icon: Clock, color: "yellow" },
|
||||||
|
{
|
||||||
|
label: "Blacklisted",
|
||||||
|
value: KPIS.blacklisted,
|
||||||
|
icon: ShieldOff,
|
||||||
|
color: "red",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card p={0}>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Box px="md" pt="md" pb="sm" w="100%">
|
||||||
|
<Group justify="space-between" gap="md" wrap="wrap">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search by company, TIN, email or profile reference…"
|
||||||
|
leftSection={<Search size={18} />}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
rightSection={
|
||||||
|
query ? (
|
||||||
|
<ActionIcon
|
||||||
|
size="sm"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
variant="transparent"
|
||||||
|
onClick={() => setQuery("")}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
style={{ flex: 1, minWidth: "240px" }}
|
||||||
|
radius="lg"
|
||||||
|
/>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{total} record{total !== 1 ? "s" : ""}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box style={{ overflowX: "auto" }} w="100%">
|
||||||
|
<Box miw={980}>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||||
|
onRowClick={(row) => navigate(`/dashboard/customers/${row.id}`)}
|
||||||
|
emptyMessage={
|
||||||
|
debouncedQuery
|
||||||
|
? "No companies match your search."
|
||||||
|
: "No companies yet."
|
||||||
|
}
|
||||||
|
error={
|
||||||
|
isError
|
||||||
|
? {
|
||||||
|
message: "Failed to load customers.",
|
||||||
|
onRetry: () => void refetch(),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.pageIndex,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
pageCount,
|
||||||
|
totalCount: total,
|
||||||
|
}}
|
||||||
|
tableOptions={{
|
||||||
|
state: { pagination },
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount,
|
||||||
|
}}
|
||||||
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
|
footer={DataTableFooter}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,722 @@
|
|||||||
|
/**
|
||||||
|
* Mock fixtures for the Customer Management dashboard.
|
||||||
|
*
|
||||||
|
* Shapes match the backend `Company` / `CompanyProfile` entities and the
|
||||||
|
* lightweight related-data types in `@/types/customer`. Swap the service layer
|
||||||
|
* to live endpoints later — these fixtures (and the helpers below) are the only
|
||||||
|
* thing that has to change.
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
Company,
|
||||||
|
CustomerBooking,
|
||||||
|
CustomerDocument,
|
||||||
|
CustomerPayment,
|
||||||
|
} from "@/types/customer";
|
||||||
|
|
||||||
|
const iso = (date: string) => new Date(date).toISOString();
|
||||||
|
|
||||||
|
export const MOCK_COMPANIES: Company[] = [
|
||||||
|
{
|
||||||
|
id: "c1",
|
||||||
|
name: "Abyssinia Import & Export PLC",
|
||||||
|
type: "customer",
|
||||||
|
status: "active",
|
||||||
|
tin: "0012345678",
|
||||||
|
vatNumber: "VAT-100245",
|
||||||
|
fanNumber: "FAN-0099",
|
||||||
|
country: "Ethiopia",
|
||||||
|
address: "Bole Road, Addis Ababa",
|
||||||
|
phone: "+251 911 234 567",
|
||||||
|
email: "ops@abyssinia-ie.com",
|
||||||
|
contactPersonName: "Selam Bekele",
|
||||||
|
contactPersonPhone: "+251 911 234 567",
|
||||||
|
generalManagerName: "Dawit Tadesse",
|
||||||
|
generalManagerEmail: "dawit@abyssinia-ie.com",
|
||||||
|
generalManagerPhone: "+251 911 000 111",
|
||||||
|
website: "https://abyssinia-ie.com",
|
||||||
|
attributes: null,
|
||||||
|
companyProfiles: [
|
||||||
|
{
|
||||||
|
id: "p1",
|
||||||
|
companyId: "c1",
|
||||||
|
type: "importer",
|
||||||
|
reference: "IMP-00123",
|
||||||
|
status: "active",
|
||||||
|
businessLicense: "BL-IMP-4521",
|
||||||
|
createdAt: iso("2024-02-10"),
|
||||||
|
updatedAt: iso("2024-02-10"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p2",
|
||||||
|
companyId: "c1",
|
||||||
|
type: "exporter",
|
||||||
|
reference: "EXP-00456",
|
||||||
|
status: "active",
|
||||||
|
businessLicense: "BL-EXP-7782",
|
||||||
|
createdAt: iso("2024-03-01"),
|
||||||
|
updatedAt: iso("2024-03-01"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: iso("2024-02-10"),
|
||||||
|
updatedAt: iso("2025-01-12"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c2",
|
||||||
|
name: "Horn Logistics Freight Forwarding",
|
||||||
|
type: "freight_forwarder",
|
||||||
|
status: "active",
|
||||||
|
tin: "0023456789",
|
||||||
|
vatNumber: "VAT-200112",
|
||||||
|
fanNumber: null,
|
||||||
|
country: "Ethiopia",
|
||||||
|
address: "Kality Industrial Zone, Addis Ababa",
|
||||||
|
phone: "+251 911 765 432",
|
||||||
|
email: "dispatch@hornlogistics.et",
|
||||||
|
contactPersonName: "Yonas Girma",
|
||||||
|
contactPersonPhone: "+251 911 765 432",
|
||||||
|
generalManagerName: "Hanna Mekonnen",
|
||||||
|
generalManagerEmail: "hanna@hornlogistics.et",
|
||||||
|
generalManagerPhone: "+251 911 222 333",
|
||||||
|
website: "https://hornlogistics.et",
|
||||||
|
attributes: null,
|
||||||
|
companyProfiles: [
|
||||||
|
{
|
||||||
|
id: "p3",
|
||||||
|
companyId: "c2",
|
||||||
|
type: "freight_forwarder",
|
||||||
|
reference: "FFW-01001",
|
||||||
|
status: "active",
|
||||||
|
businessLicense: "BL-FFW-1190",
|
||||||
|
createdAt: iso("2023-11-05"),
|
||||||
|
updatedAt: iso("2023-11-05"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p4",
|
||||||
|
companyId: "c2",
|
||||||
|
type: "importer",
|
||||||
|
reference: "IMP-00890",
|
||||||
|
status: "pending",
|
||||||
|
businessLicense: null,
|
||||||
|
createdAt: iso("2024-06-18"),
|
||||||
|
updatedAt: iso("2024-06-18"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p5",
|
||||||
|
companyId: "c2",
|
||||||
|
type: "exporter",
|
||||||
|
reference: "EXP-00777",
|
||||||
|
status: "active",
|
||||||
|
businessLicense: "BL-EXP-3310",
|
||||||
|
createdAt: iso("2024-07-01"),
|
||||||
|
updatedAt: iso("2024-07-01"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: iso("2023-11-05"),
|
||||||
|
updatedAt: iso("2025-02-20"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c3",
|
||||||
|
name: "Djibouti Gateway Forwarders",
|
||||||
|
type: "dj_freight_forwarder",
|
||||||
|
status: "active",
|
||||||
|
tin: "0034567890",
|
||||||
|
vatNumber: null,
|
||||||
|
fanNumber: "FAN-0451",
|
||||||
|
country: "Djibouti",
|
||||||
|
address: "Port de Djibouti, Djibouti City",
|
||||||
|
phone: "+253 21 35 00 00",
|
||||||
|
email: "ops@djgateway.dj",
|
||||||
|
contactPersonName: "Amina Idriss",
|
||||||
|
contactPersonPhone: "+253 77 12 34 56",
|
||||||
|
generalManagerName: "Omar Farah",
|
||||||
|
generalManagerEmail: "omar@djgateway.dj",
|
||||||
|
generalManagerPhone: "+253 77 99 88 77",
|
||||||
|
website: null,
|
||||||
|
attributes: null,
|
||||||
|
companyProfiles: [
|
||||||
|
{
|
||||||
|
id: "p6",
|
||||||
|
companyId: "c3",
|
||||||
|
type: "dj_freight_forwarder",
|
||||||
|
reference: "DJF-02050",
|
||||||
|
status: "active",
|
||||||
|
businessLicense: "BL-DJF-0088",
|
||||||
|
createdAt: iso("2023-09-12"),
|
||||||
|
updatedAt: iso("2023-09-12"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: iso("2023-09-12"),
|
||||||
|
updatedAt: iso("2024-12-30"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c4",
|
||||||
|
name: "Rift Valley Transporters",
|
||||||
|
type: "transporter",
|
||||||
|
status: "suspended",
|
||||||
|
tin: "0045678901",
|
||||||
|
vatNumber: "VAT-300988",
|
||||||
|
fanNumber: null,
|
||||||
|
country: "Ethiopia",
|
||||||
|
address: "Adama Ring Road, Adama",
|
||||||
|
phone: "+251 912 345 678",
|
||||||
|
email: "fleet@riftvalley-tr.com",
|
||||||
|
contactPersonName: "Bereket Alemu",
|
||||||
|
contactPersonPhone: "+251 912 345 678",
|
||||||
|
generalManagerName: "Meron Haile",
|
||||||
|
generalManagerEmail: "meron@riftvalley-tr.com",
|
||||||
|
generalManagerPhone: "+251 912 111 222",
|
||||||
|
website: null,
|
||||||
|
attributes: null,
|
||||||
|
companyProfiles: [
|
||||||
|
{
|
||||||
|
id: "p7",
|
||||||
|
companyId: "c4",
|
||||||
|
type: "transporter",
|
||||||
|
reference: "TRP-03012",
|
||||||
|
status: "suspended",
|
||||||
|
businessLicense: "BL-TRP-2204",
|
||||||
|
createdAt: iso("2024-01-22"),
|
||||||
|
updatedAt: iso("2024-10-04"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: iso("2024-01-22"),
|
||||||
|
updatedAt: iso("2024-10-04"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c5",
|
||||||
|
name: "Nile Trading & General Import",
|
||||||
|
type: "customer",
|
||||||
|
status: "pending",
|
||||||
|
tin: "0056789012",
|
||||||
|
vatNumber: null,
|
||||||
|
fanNumber: null,
|
||||||
|
country: "Ethiopia",
|
||||||
|
address: "Merkato, Addis Ababa",
|
||||||
|
phone: "+251 913 456 789",
|
||||||
|
email: "info@niletrading.et",
|
||||||
|
contactPersonName: "Sara Tesfaye",
|
||||||
|
contactPersonPhone: "+251 913 456 789",
|
||||||
|
generalManagerName: "Kebede Worku",
|
||||||
|
generalManagerEmail: "kebede@niletrading.et",
|
||||||
|
generalManagerPhone: "+251 913 000 999",
|
||||||
|
website: null,
|
||||||
|
attributes: null,
|
||||||
|
companyProfiles: [
|
||||||
|
{
|
||||||
|
id: "p8",
|
||||||
|
companyId: "c5",
|
||||||
|
type: "importer",
|
||||||
|
reference: "IMP-01456",
|
||||||
|
status: "pending",
|
||||||
|
businessLicense: null,
|
||||||
|
createdAt: iso("2025-03-14"),
|
||||||
|
updatedAt: iso("2025-03-14"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: iso("2025-03-14"),
|
||||||
|
updatedAt: iso("2025-03-14"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c6",
|
||||||
|
name: "Sheba Steel & Cement Importers",
|
||||||
|
type: "customer",
|
||||||
|
status: "active",
|
||||||
|
tin: "0067890123",
|
||||||
|
vatNumber: "VAT-400777",
|
||||||
|
fanNumber: "FAN-0512",
|
||||||
|
country: "Ethiopia",
|
||||||
|
address: "Lebu, Addis Ababa",
|
||||||
|
phone: "+251 914 567 890",
|
||||||
|
email: "procure@shebasteel.com",
|
||||||
|
contactPersonName: "Tigist Assefa",
|
||||||
|
contactPersonPhone: "+251 914 567 890",
|
||||||
|
generalManagerName: "Robel Negash",
|
||||||
|
generalManagerEmail: "robel@shebasteel.com",
|
||||||
|
generalManagerPhone: "+251 914 222 444",
|
||||||
|
website: "https://shebasteel.com",
|
||||||
|
attributes: null,
|
||||||
|
companyProfiles: [
|
||||||
|
{
|
||||||
|
id: "p9",
|
||||||
|
companyId: "c6",
|
||||||
|
type: "importer",
|
||||||
|
reference: "IMP-01987",
|
||||||
|
status: "active",
|
||||||
|
businessLicense: "BL-IMP-9001",
|
||||||
|
createdAt: iso("2024-05-09"),
|
||||||
|
updatedAt: iso("2024-05-09"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: iso("2024-05-09"),
|
||||||
|
updatedAt: iso("2025-04-02"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c7",
|
||||||
|
name: "Awash Agro Export Union",
|
||||||
|
type: "customer",
|
||||||
|
status: "active",
|
||||||
|
tin: "0078901234",
|
||||||
|
vatNumber: "VAT-500321",
|
||||||
|
fanNumber: null,
|
||||||
|
country: "Ethiopia",
|
||||||
|
address: "Awash, Afar",
|
||||||
|
phone: "+251 915 678 901",
|
||||||
|
email: "export@awashagro.coop",
|
||||||
|
contactPersonName: "Lensa Diriba",
|
||||||
|
contactPersonPhone: "+251 915 678 901",
|
||||||
|
generalManagerName: "Gemechu Bayisa",
|
||||||
|
generalManagerEmail: "gemechu@awashagro.coop",
|
||||||
|
generalManagerPhone: "+251 915 333 555",
|
||||||
|
website: null,
|
||||||
|
attributes: null,
|
||||||
|
companyProfiles: [
|
||||||
|
{
|
||||||
|
id: "p10",
|
||||||
|
companyId: "c7",
|
||||||
|
type: "exporter",
|
||||||
|
reference: "EXP-02233",
|
||||||
|
status: "active",
|
||||||
|
businessLicense: "BL-EXP-6650",
|
||||||
|
createdAt: iso("2024-08-19"),
|
||||||
|
updatedAt: iso("2024-08-19"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p11",
|
||||||
|
companyId: "c7",
|
||||||
|
type: "importer",
|
||||||
|
reference: "IMP-02234",
|
||||||
|
status: "active",
|
||||||
|
businessLicense: "BL-IMP-6651",
|
||||||
|
createdAt: iso("2024-08-19"),
|
||||||
|
updatedAt: iso("2024-08-19"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: iso("2024-08-19"),
|
||||||
|
updatedAt: iso("2025-05-10"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c8",
|
||||||
|
name: "Blacklisted Holdings Trading",
|
||||||
|
type: "customer",
|
||||||
|
status: "blacklisted",
|
||||||
|
tin: "0089012345",
|
||||||
|
vatNumber: null,
|
||||||
|
fanNumber: null,
|
||||||
|
country: "Ethiopia",
|
||||||
|
address: "Unknown",
|
||||||
|
phone: "+251 916 789 012",
|
||||||
|
email: "contact@blacklistedholdings.com",
|
||||||
|
contactPersonName: "N/A",
|
||||||
|
contactPersonPhone: "+251 916 789 012",
|
||||||
|
generalManagerName: "N/A",
|
||||||
|
generalManagerEmail: null,
|
||||||
|
generalManagerPhone: null,
|
||||||
|
website: null,
|
||||||
|
attributes: null,
|
||||||
|
companyProfiles: [
|
||||||
|
{
|
||||||
|
id: "p12",
|
||||||
|
companyId: "c8",
|
||||||
|
type: "importer",
|
||||||
|
reference: "IMP-02999",
|
||||||
|
status: "blacklisted",
|
||||||
|
businessLicense: null,
|
||||||
|
createdAt: iso("2023-12-01"),
|
||||||
|
updatedAt: iso("2024-02-15"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: iso("2023-12-01"),
|
||||||
|
updatedAt: iso("2024-02-15"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c9",
|
||||||
|
name: "Lalibela Coffee Exporters",
|
||||||
|
type: "customer",
|
||||||
|
status: "active",
|
||||||
|
tin: "0090123456",
|
||||||
|
vatNumber: "VAT-600145",
|
||||||
|
fanNumber: "FAN-0623",
|
||||||
|
country: "Ethiopia",
|
||||||
|
address: "Sidama, Hawassa",
|
||||||
|
phone: "+251 917 890 123",
|
||||||
|
email: "trade@lalibelacoffee.com",
|
||||||
|
contactPersonName: "Eyob Tariku",
|
||||||
|
contactPersonPhone: "+251 917 890 123",
|
||||||
|
generalManagerName: "Frehiwot Solomon",
|
||||||
|
generalManagerEmail: "frehiwot@lalibelacoffee.com",
|
||||||
|
generalManagerPhone: "+251 917 444 666",
|
||||||
|
website: "https://lalibelacoffee.com",
|
||||||
|
attributes: null,
|
||||||
|
companyProfiles: [
|
||||||
|
{
|
||||||
|
id: "p13",
|
||||||
|
companyId: "c9",
|
||||||
|
type: "exporter",
|
||||||
|
reference: "EXP-03456",
|
||||||
|
status: "active",
|
||||||
|
businessLicense: "BL-EXP-8842",
|
||||||
|
createdAt: iso("2024-04-03"),
|
||||||
|
updatedAt: iso("2024-04-03"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: iso("2024-04-03"),
|
||||||
|
updatedAt: iso("2025-03-22"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c10",
|
||||||
|
name: "Unity Multimodal Forwarders",
|
||||||
|
type: "freight_forwarder",
|
||||||
|
status: "active",
|
||||||
|
tin: "0101234567",
|
||||||
|
vatNumber: "VAT-700998",
|
||||||
|
fanNumber: null,
|
||||||
|
country: "Ethiopia",
|
||||||
|
address: "Sululta, Oromia",
|
||||||
|
phone: "+251 918 901 234",
|
||||||
|
email: "ops@unitymultimodal.com",
|
||||||
|
contactPersonName: "Helen Tsegaye",
|
||||||
|
contactPersonPhone: "+251 918 901 234",
|
||||||
|
generalManagerName: "Nahom Berhanu",
|
||||||
|
generalManagerEmail: "nahom@unitymultimodal.com",
|
||||||
|
generalManagerPhone: "+251 918 555 777",
|
||||||
|
website: null,
|
||||||
|
attributes: null,
|
||||||
|
companyProfiles: [
|
||||||
|
{
|
||||||
|
id: "p14",
|
||||||
|
companyId: "c10",
|
||||||
|
type: "freight_forwarder",
|
||||||
|
reference: "FFW-04088",
|
||||||
|
status: "active",
|
||||||
|
businessLicense: "BL-FFW-5521",
|
||||||
|
createdAt: iso("2024-09-28"),
|
||||||
|
updatedAt: iso("2024-09-28"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p15",
|
||||||
|
companyId: "c10",
|
||||||
|
type: "transporter",
|
||||||
|
reference: "TRP-04089",
|
||||||
|
status: "active",
|
||||||
|
businessLicense: "BL-TRP-5522",
|
||||||
|
createdAt: iso("2024-09-28"),
|
||||||
|
updatedAt: iso("2024-09-28"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p16",
|
||||||
|
companyId: "c10",
|
||||||
|
type: "exporter",
|
||||||
|
reference: "EXP-04090",
|
||||||
|
status: "pending",
|
||||||
|
businessLicense: null,
|
||||||
|
createdAt: iso("2025-01-15"),
|
||||||
|
updatedAt: iso("2025-01-15"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: iso("2024-09-28"),
|
||||||
|
updatedAt: iso("2025-01-15"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Bookings keyed by companyId. */
|
||||||
|
const BOOKINGS_BY_COMPANY: Record<string, CustomerBooking[]> = {
|
||||||
|
c1: [
|
||||||
|
{
|
||||||
|
id: "b1",
|
||||||
|
reference: "BK-2025-0481",
|
||||||
|
status: "PAID",
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
freightType: "CONTAINER",
|
||||||
|
originLabel: "Djibouti Port",
|
||||||
|
destinationLabel: "Mojo Dry Port",
|
||||||
|
totalAmount: 482000,
|
||||||
|
currency: "ETB",
|
||||||
|
scheduledDate: iso("2025-05-20"),
|
||||||
|
createdAt: iso("2025-05-02"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "b2",
|
||||||
|
reference: "BK-2025-0512",
|
||||||
|
status: "IN_TRANSIT",
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
freightType: "CONTAINER",
|
||||||
|
originLabel: "Djibouti Port",
|
||||||
|
destinationLabel: "Indode Terminal",
|
||||||
|
totalAmount: 356500,
|
||||||
|
currency: "ETB",
|
||||||
|
scheduledDate: iso("2025-06-01"),
|
||||||
|
createdAt: iso("2025-05-18"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "b3",
|
||||||
|
reference: "BK-2025-0298",
|
||||||
|
status: "COMPLETED",
|
||||||
|
tradeDirection: "EXPORT",
|
||||||
|
freightType: "BULK",
|
||||||
|
originLabel: "Mojo Dry Port",
|
||||||
|
destinationLabel: "Djibouti Port",
|
||||||
|
totalAmount: 198000,
|
||||||
|
currency: "ETB",
|
||||||
|
scheduledDate: iso("2025-03-30"),
|
||||||
|
createdAt: iso("2025-03-12"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
c2: [
|
||||||
|
{
|
||||||
|
id: "b4",
|
||||||
|
reference: "BK-2025-0633",
|
||||||
|
status: "PENDING_APPROVAL",
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
freightType: "CONTAINER",
|
||||||
|
originLabel: "Djibouti Port",
|
||||||
|
destinationLabel: "Mojo Dry Port",
|
||||||
|
totalAmount: 720000,
|
||||||
|
currency: "ETB",
|
||||||
|
scheduledDate: null,
|
||||||
|
createdAt: iso("2025-06-10"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "b5",
|
||||||
|
reference: "BK-2025-0588",
|
||||||
|
status: "PAID",
|
||||||
|
tradeDirection: "EXPORT",
|
||||||
|
freightType: "CONTAINER",
|
||||||
|
originLabel: "Indode Terminal",
|
||||||
|
destinationLabel: "Djibouti Port",
|
||||||
|
totalAmount: 540000,
|
||||||
|
currency: "ETB",
|
||||||
|
scheduledDate: iso("2025-06-15"),
|
||||||
|
createdAt: iso("2025-05-29"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
c6: [
|
||||||
|
{
|
||||||
|
id: "b6",
|
||||||
|
reference: "BK-2025-0701",
|
||||||
|
status: "SUBMITTED",
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
freightType: "BULK",
|
||||||
|
originLabel: "Djibouti Port",
|
||||||
|
destinationLabel: "Mojo Dry Port",
|
||||||
|
totalAmount: 1250000,
|
||||||
|
currency: "ETB",
|
||||||
|
scheduledDate: null,
|
||||||
|
createdAt: iso("2025-06-18"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
c7: [
|
||||||
|
{
|
||||||
|
id: "b7",
|
||||||
|
reference: "BK-2025-0344",
|
||||||
|
status: "COMPLETED",
|
||||||
|
tradeDirection: "EXPORT",
|
||||||
|
freightType: "BULK",
|
||||||
|
originLabel: "Mojo Dry Port",
|
||||||
|
destinationLabel: "Djibouti Port",
|
||||||
|
totalAmount: 2100,
|
||||||
|
currency: "USD",
|
||||||
|
scheduledDate: iso("2025-04-05"),
|
||||||
|
createdAt: iso("2025-03-20"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "b8",
|
||||||
|
reference: "BK-2025-0410",
|
||||||
|
status: "CANCELLED",
|
||||||
|
tradeDirection: "EXPORT",
|
||||||
|
freightType: "CONTAINER",
|
||||||
|
originLabel: "Indode Terminal",
|
||||||
|
destinationLabel: "Djibouti Port",
|
||||||
|
totalAmount: 3400,
|
||||||
|
currency: "USD",
|
||||||
|
scheduledDate: null,
|
||||||
|
createdAt: iso("2025-04-22"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Documents keyed by companyId. */
|
||||||
|
const DOCUMENTS_BY_COMPANY: Record<string, CustomerDocument[]> = {
|
||||||
|
c1: [
|
||||||
|
{
|
||||||
|
id: "d1",
|
||||||
|
name: "Business License 2025.pdf",
|
||||||
|
code: "business_license",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
size: 482_113,
|
||||||
|
uploadedAt: iso("2025-01-12"),
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "d2",
|
||||||
|
name: "VAT Registration.pdf",
|
||||||
|
code: "vat_certificate",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
size: 211_544,
|
||||||
|
uploadedAt: iso("2024-02-11"),
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "d3",
|
||||||
|
name: "Trade Agreement - Signed.pdf",
|
||||||
|
code: "contract",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
size: 1_204_882,
|
||||||
|
uploadedAt: iso("2025-05-03"),
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
c2: [
|
||||||
|
{
|
||||||
|
id: "d4",
|
||||||
|
name: "Forwarding License.pdf",
|
||||||
|
code: "business_license",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
size: 365_002,
|
||||||
|
uploadedAt: iso("2023-11-06"),
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "d5",
|
||||||
|
name: "Company Profile.docx",
|
||||||
|
code: "company_profile",
|
||||||
|
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
size: 92_440,
|
||||||
|
uploadedAt: iso("2024-07-02"),
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
c6: [
|
||||||
|
{
|
||||||
|
id: "d6",
|
||||||
|
name: "Import License.pdf",
|
||||||
|
code: "business_license",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
size: 410_223,
|
||||||
|
uploadedAt: iso("2024-05-10"),
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
c7: [
|
||||||
|
{
|
||||||
|
id: "d7",
|
||||||
|
name: "Export Permit.pdf",
|
||||||
|
code: "export_permit",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
size: 298_770,
|
||||||
|
uploadedAt: iso("2024-08-20"),
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "d8",
|
||||||
|
name: "Quality Certificate.jpg",
|
||||||
|
code: "quality_certificate",
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
size: 1_882_001,
|
||||||
|
uploadedAt: iso("2025-03-21"),
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Payments keyed by companyId. */
|
||||||
|
const PAYMENTS_BY_COMPANY: Record<string, CustomerPayment[]> = {
|
||||||
|
c1: [
|
||||||
|
{
|
||||||
|
id: "pay1",
|
||||||
|
reference: "PMT-77120",
|
||||||
|
bookingReference: "BK-2025-0481",
|
||||||
|
amount: 482000,
|
||||||
|
currency: "ETB",
|
||||||
|
method: "telebirr",
|
||||||
|
status: "success",
|
||||||
|
paidAt: iso("2025-05-04"),
|
||||||
|
createdAt: iso("2025-05-03"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pay2",
|
||||||
|
reference: "PMT-77450",
|
||||||
|
bookingReference: "BK-2025-0512",
|
||||||
|
amount: 356500,
|
||||||
|
currency: "ETB",
|
||||||
|
method: "cbe-birr",
|
||||||
|
status: "processing",
|
||||||
|
paidAt: null,
|
||||||
|
createdAt: iso("2025-05-19"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pay3",
|
||||||
|
reference: "PMT-71002",
|
||||||
|
bookingReference: "BK-2025-0298",
|
||||||
|
amount: 198000,
|
||||||
|
currency: "ETB",
|
||||||
|
method: "telebirr",
|
||||||
|
status: "success",
|
||||||
|
paidAt: iso("2025-03-14"),
|
||||||
|
createdAt: iso("2025-03-13"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
c2: [
|
||||||
|
{
|
||||||
|
id: "pay4",
|
||||||
|
reference: "PMT-78900",
|
||||||
|
bookingReference: "BK-2025-0588",
|
||||||
|
amount: 540000,
|
||||||
|
currency: "ETB",
|
||||||
|
method: "cbe-birr",
|
||||||
|
status: "success",
|
||||||
|
paidAt: iso("2025-05-30"),
|
||||||
|
createdAt: iso("2025-05-29"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pay5",
|
||||||
|
reference: "PMT-79120",
|
||||||
|
bookingReference: "BK-2025-0633",
|
||||||
|
amount: 720000,
|
||||||
|
currency: "ETB",
|
||||||
|
method: "card",
|
||||||
|
status: "action-required",
|
||||||
|
paidAt: null,
|
||||||
|
createdAt: iso("2025-06-10"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
c7: [
|
||||||
|
{
|
||||||
|
id: "pay6",
|
||||||
|
reference: "PMT-70044",
|
||||||
|
bookingReference: "BK-2025-0344",
|
||||||
|
amount: 2100,
|
||||||
|
currency: "USD",
|
||||||
|
method: "card",
|
||||||
|
status: "success",
|
||||||
|
paidAt: iso("2025-03-22"),
|
||||||
|
createdAt: iso("2025-03-21"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pay7",
|
||||||
|
reference: "PMT-70410",
|
||||||
|
bookingReference: "BK-2025-0410",
|
||||||
|
amount: 3400,
|
||||||
|
currency: "USD",
|
||||||
|
method: "card",
|
||||||
|
status: "refunded",
|
||||||
|
paidAt: iso("2025-04-23"),
|
||||||
|
createdAt: iso("2025-04-22"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getCompanyById(id: string): Company | undefined {
|
||||||
|
return MOCK_COMPANIES.find((c) => c.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBookingsFor(companyId: string): CustomerBooking[] {
|
||||||
|
return BOOKINGS_BY_COMPANY[companyId] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDocumentsFor(companyId: string): CustomerDocument[] {
|
||||||
|
return DOCUMENTS_BY_COMPANY[companyId] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPaymentsFor(companyId: string): CustomerPayment[] {
|
||||||
|
return PAYMENTS_BY_COMPANY[companyId] ?? [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Customers service.
|
||||||
|
*
|
||||||
|
* Currently backed by in-memory mock fixtures (`customers.mock.ts`); the public
|
||||||
|
* surface mirrors the other services (e.g. `bookings.service.ts`) — async
|
||||||
|
* methods returning `{ items, total }` / detail objects — so it can be pointed
|
||||||
|
* at the live `/companies` API later without touching the hooks or pages.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
getBookingsFor,
|
||||||
|
getCompanyById,
|
||||||
|
getDocumentsFor,
|
||||||
|
getPaymentsFor,
|
||||||
|
MOCK_COMPANIES,
|
||||||
|
} from "@/pages/customers/customers.mock";
|
||||||
|
import type {
|
||||||
|
Company,
|
||||||
|
CompanyListFilter,
|
||||||
|
CustomerBooking,
|
||||||
|
CustomerDocument,
|
||||||
|
CustomerPayment,
|
||||||
|
PaginatedCompanies,
|
||||||
|
} from "@/types/customer";
|
||||||
|
|
||||||
|
/** Simulate network latency so loading states are visible during UI work. */
|
||||||
|
const delay = <T>(value: T, ms = 350): Promise<T> =>
|
||||||
|
new Promise((resolve) => setTimeout(() => resolve(value), ms));
|
||||||
|
|
||||||
|
function matchesSearch(company: Company, search: string): boolean {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return true;
|
||||||
|
return (
|
||||||
|
company.name.toLowerCase().includes(q) ||
|
||||||
|
company.tin.toLowerCase().includes(q) ||
|
||||||
|
company.email?.toLowerCase().includes(q) === true ||
|
||||||
|
company.companyProfiles.some((p) => p.reference.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const customersService = {
|
||||||
|
list(filter: CompanyListFilter): Promise<PaginatedCompanies> {
|
||||||
|
const { page, pageSize, search = "", type, status } = filter;
|
||||||
|
|
||||||
|
const filtered = MOCK_COMPANIES.filter(
|
||||||
|
(c) =>
|
||||||
|
matchesSearch(c, search) &&
|
||||||
|
(!type || c.type === type) &&
|
||||||
|
(!status || c.status === status),
|
||||||
|
);
|
||||||
|
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const items = filtered.slice(start, start + pageSize);
|
||||||
|
|
||||||
|
return delay({ items, total: filtered.length });
|
||||||
|
},
|
||||||
|
|
||||||
|
getById(id: string): Promise<Company | undefined> {
|
||||||
|
return delay(getCompanyById(id));
|
||||||
|
},
|
||||||
|
|
||||||
|
bookingsFor(companyId: string): Promise<CustomerBooking[]> {
|
||||||
|
return delay(getBookingsFor(companyId));
|
||||||
|
},
|
||||||
|
|
||||||
|
documentsFor(companyId: string): Promise<CustomerDocument[]> {
|
||||||
|
return delay(getDocumentsFor(companyId));
|
||||||
|
},
|
||||||
|
|
||||||
|
paymentsFor(companyId: string): Promise<CustomerPayment[]> {
|
||||||
|
return delay(getPaymentsFor(companyId));
|
||||||
|
},
|
||||||
|
};
|
||||||
154
apps/edr-freight-web/backoffice/src/types/customer.ts
Normal file
154
apps/edr-freight-web/backoffice/src/types/customer.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* Customer-management types for the freight backoffice.
|
||||||
|
*
|
||||||
|
* These mirror the backend `Company` / `CompanyProfile` entities
|
||||||
|
* (apps/edr-freight-api/src/modules/companies/entities) plus a few lightweight
|
||||||
|
* shapes for the related data shown on the detail page (bookings / documents /
|
||||||
|
* payments). The UI is currently driven by mock data, but the shapes match the
|
||||||
|
* API so the data layer can be swapped to live endpoints with no UI changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Mirrors backend `CompanyType`. */
|
||||||
|
export type CompanyType =
|
||||||
|
| "customer"
|
||||||
|
| "freight_forwarder"
|
||||||
|
| "dj_freight_forwarder"
|
||||||
|
| "transporter";
|
||||||
|
|
||||||
|
/** Mirrors backend `CompanyStatus`. */
|
||||||
|
export type CompanyStatus = "active" | "pending" | "suspended" | "blacklisted";
|
||||||
|
|
||||||
|
/** Mirrors backend `ProfileType` (the role a company plays). */
|
||||||
|
export type ProfileType =
|
||||||
|
| "importer"
|
||||||
|
| "exporter"
|
||||||
|
| "freight_forwarder"
|
||||||
|
| "dj_freight_forwarder"
|
||||||
|
| "transporter";
|
||||||
|
|
||||||
|
/** Mirrors backend `ProfileStatus`. */
|
||||||
|
export type ProfileStatus = "active" | "pending" | "suspended" | "blacklisted";
|
||||||
|
|
||||||
|
/** A single role a company is registered for, with its reference code. */
|
||||||
|
export interface CompanyProfile {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
type: ProfileType;
|
||||||
|
reference: string;
|
||||||
|
status: ProfileStatus;
|
||||||
|
businessLicense?: string | null;
|
||||||
|
attributes?: Record<string, unknown> | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors backend `Company` (+ its `companyProfiles`). */
|
||||||
|
export interface Company {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: CompanyType;
|
||||||
|
status: CompanyStatus;
|
||||||
|
tin: string;
|
||||||
|
vatNumber?: string | null;
|
||||||
|
fanNumber?: string | null;
|
||||||
|
country: string;
|
||||||
|
address?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
contactPersonName?: string | null;
|
||||||
|
contactPersonPhone?: string | null;
|
||||||
|
generalManagerName?: string | null;
|
||||||
|
generalManagerEmail?: string | null;
|
||||||
|
generalManagerPhone?: string | null;
|
||||||
|
website?: string | null;
|
||||||
|
attributes?: Record<string, unknown> | null;
|
||||||
|
companyProfiles: CompanyProfile[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query parameters for the company list. */
|
||||||
|
export interface CompanyListFilter {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
search?: string;
|
||||||
|
type?: CompanyType;
|
||||||
|
status?: CompanyStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Standard paginated list envelope (matches the bookings service shape). */
|
||||||
|
export interface PaginatedCompanies {
|
||||||
|
items: Company[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Related data shown on the customer detail page (mocked for now). *
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export type CustomerBookingStatus =
|
||||||
|
| "DRAFT"
|
||||||
|
| "SUBMITTED"
|
||||||
|
| "PENDING_APPROVAL"
|
||||||
|
| "APPROVED"
|
||||||
|
| "PAID"
|
||||||
|
| "IN_TRANSIT"
|
||||||
|
| "COMPLETED"
|
||||||
|
| "REJECTED"
|
||||||
|
| "CANCELLED";
|
||||||
|
|
||||||
|
export interface CustomerBooking {
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
status: CustomerBookingStatus;
|
||||||
|
tradeDirection: "IMPORT" | "EXPORT";
|
||||||
|
freightType: "CONTAINER" | "BULK";
|
||||||
|
originLabel: string;
|
||||||
|
destinationLabel: string;
|
||||||
|
totalAmount: number;
|
||||||
|
currency: "ETB" | "USD";
|
||||||
|
scheduledDate?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerDocument {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** File-upload setting code, e.g. "business_license", "contract". */
|
||||||
|
code: string;
|
||||||
|
mimeType: string;
|
||||||
|
/** Size in bytes. */
|
||||||
|
size: number;
|
||||||
|
uploadedAt: string;
|
||||||
|
url?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CustomerPaymentStatus =
|
||||||
|
| "action-required"
|
||||||
|
| "processing"
|
||||||
|
| "success"
|
||||||
|
| "failed"
|
||||||
|
| "canceled"
|
||||||
|
| "refunded";
|
||||||
|
|
||||||
|
export type CustomerPaymentMethod =
|
||||||
|
| "telebirr"
|
||||||
|
| "cbe-birr"
|
||||||
|
| "ebirr"
|
||||||
|
| "waafi"
|
||||||
|
| "card"
|
||||||
|
| "dmoney"
|
||||||
|
| "cac-bank";
|
||||||
|
|
||||||
|
export interface CustomerPayment {
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
/** Booking reference the payment settles. */
|
||||||
|
bookingReference: string;
|
||||||
|
amount: number;
|
||||||
|
currency: "ETB" | "USD";
|
||||||
|
method: CustomerPaymentMethod;
|
||||||
|
status: CustomerPaymentStatus;
|
||||||
|
paidAt?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user