feat: finish company profile in the backoffice

This commit is contained in:
Nathnael
2026-06-23 06:45:29 +00:00
parent f7cbb6af6f
commit a6f3fd5643
24 changed files with 690 additions and 938 deletions

View File

@@ -1,4 +1,6 @@
import { Badge, Group, Tooltip } from "@mantine/core";
import { Badge, Button, Group, Tooltip } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { api } from "@/services/api";
import type {
CompanyProfile,
@@ -207,3 +209,108 @@ export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }
</Badge>
);
}
/**
* Inline approval action buttons for a profile row.
* Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate
*/
export function ProfileApprovalActions({
profileId,
status,
}: {
profileId: string;
status: ProfileStatus;
}) {
const { mutate, isPending } = useMutation(
api.customers.setProfileStatus.mutationOptions(),
);
const act = (next: ProfileStatus) =>
mutate({ profileId, status: next });
if (status === "pending") {
return (
<Group gap={6} wrap="nowrap">
<Button
size="xs"
variant="light"
color="edr-green"
radius="md"
loading={isPending}
onClick={() => act("active")}
>
Approve
</Button>
<Button
size="xs"
variant="light"
color="red"
radius="md"
loading={isPending}
onClick={() => act("blacklisted")}
>
Reject
</Button>
</Group>
);
}
if (status === "active") {
return (
<Button
size="xs"
variant="light"
color="orange"
radius="md"
loading={isPending}
onClick={() => act("suspended")}
>
Suspend
</Button>
);
}
if (status === "suspended") {
return (
<Group gap={6} wrap="nowrap">
<Button
size="xs"
variant="light"
color="edr-green"
radius="md"
loading={isPending}
onClick={() => act("active")}
>
Reactivate
</Button>
<Button
size="xs"
variant="light"
color="red"
radius="md"
loading={isPending}
onClick={() => act("blacklisted")}
>
Blacklist
</Button>
</Group>
);
}
if (status === "blacklisted") {
return (
<Button
size="xs"
variant="light"
color="gray"
radius="md"
loading={isPending}
onClick={() => act("pending")}
>
Reinstate
</Button>
);
}
return null;
}

View File

@@ -3,6 +3,7 @@ export {
CompanyStatusBadge,
CompanyTypeBadge,
PaymentStatusBadge,
ProfileApprovalActions,
ProfileChips,
ProfileStatusBadge,
ProfileTypeBadge,

View File

@@ -27,6 +27,7 @@ export const QUERY_KEYS = {
CUSTOMERS: {
ROOT: ["customers"] as const,
stats: ["customers", "stats"] as const,
list: (filter?: CompanyListFilter) =>
["customers", "list", filter ?? {}] as const,
byId: (id: string) => ["customers", "detail", id] as const,

View File

@@ -71,7 +71,12 @@ export const URL_CONSTANTS = {
COMPANIES: {
BASE: "/companies",
STATS: "/companies/stats",
BY_ID: (id: string | number) => `/companies/${id}`,
DOCUMENTS: (id: string) => `/companies/${id}/documents`,
PROFILE_STATUS: (profileId: string) => `/companies/company-profiles/${profileId}/status`,
BOOKINGS_CUSTOMER_VIEW: (id: string) => `/bookings/by-company/${id}/customer-view`,
PAYMENTS_CUSTOMER_VIEW: (id: string) => `/payments/by-company/${id}/customer-view`,
},
CUSTOMERS_API: {

View File

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

View File

@@ -22,6 +22,7 @@ import {
LayoutGrid,
Package,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
@@ -30,6 +31,7 @@ import {
CompanyStatusBadge,
CompanyTypeBadge,
PaymentStatusBadge,
ProfileApprovalActions,
ProfileChips,
ProfileStatusBadge,
ProfileTypeBadge,
@@ -40,12 +42,7 @@ import {
humanize,
} from "@/components/customers";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import {
useCustomerBookings,
useCustomerDetail,
useCustomerDocuments,
useCustomerPayments,
} from "@/hooks/customers/useCustomers";
import { api } from "@/services/api";
import type {
CompanyProfile,
CustomerBooking,
@@ -81,10 +78,30 @@ 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 { data: company, isLoading } = useQuery(
api.customers.getById.queryOptions({
input: { id: id ?? "" },
enabled: Boolean(id),
}),
);
const bookingsQuery = useQuery(
api.customers.bookings.queryOptions({
input: { id: id ?? "" },
enabled: Boolean(id),
}),
);
const documentsQuery = useQuery(
api.customers.documents.queryOptions({
input: { id: id ?? "" },
enabled: Boolean(id),
}),
);
const paymentsQuery = useQuery(
api.customers.payments.queryOptions({
input: { id: id ?? "" },
enabled: Boolean(id),
}),
);
const bookings = bookingsQuery.data ?? [];
const documents = documentsQuery.data ?? [];
@@ -132,13 +149,23 @@ export default function CustomerDetailPage() {
{
id: "createdAt",
header: "Registered",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatDate(row.original.createdAt)}
</Text>
),
},
{
id: "actions",
header: "",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<ProfileApprovalActions
profileId={row.original.id}
status={row.original.status}
/>
),
},
],
[],
);
@@ -402,6 +429,14 @@ export default function CustomerDetailPage() {
icon: IdCard,
color: "edr-green",
},
{
label: "Pending approval",
value: company.companyProfiles.filter(
(p) => p.status === "pending",
).length,
icon: IdCard,
color: "yellow",
},
{
label: "Bookings",
value: bookings.length,
@@ -414,12 +449,6 @@ export default function CustomerDetailPage() {
icon: Banknote,
color: "edr-green",
},
{
label: "Documents",
value: documents.length,
icon: FileText,
color: "grape",
},
]}
/>
@@ -472,7 +501,7 @@ export default function CustomerDetailPage() {
<ProfileChips profiles={company.companyProfiles} />
</Group>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={720}>
<Box miw={860}>
<DataTable
columns={profileColumns}
data={company.companyProfiles}

View File

@@ -8,6 +8,7 @@ import {
TextInput,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import {
Building2,
CheckCircle2,
@@ -30,8 +31,7 @@ import {
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 { api } from "@/services/api";
import type { Company } from "@/types/customer";
import {
DataTable,
@@ -40,14 +40,6 @@ import {
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 function CustomersPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
@@ -63,8 +55,11 @@ export default function CustomersPage() {
[pagination.pageIndex, pagination.pageSize, debouncedQuery],
);
const { data, isLoading, isError, refetch, isFetching } =
useCustomerList(filter);
const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} }));
const { data, isLoading, isError, refetch, isFetching } = useQuery(
api.customers.list.queryOptions({ input: { filter } }),
);
const rows = data?.items ?? [];
const total = data?.total ?? 0;
@@ -184,12 +179,12 @@ export default function CustomersPage() {
<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: "Companies", value: stats?.total ?? "—", icon: Users, color: "edr-green" },
{ label: "Active", value: stats?.active ?? "—", icon: CheckCircle2, color: "edr-green" },
{ label: "Pending", value: stats?.pending ?? "—", icon: Clock, color: "yellow" },
{
label: "Blacklisted",
value: KPIS.blacklisted,
value: stats?.blacklisted ?? "—",
icon: ShieldOff,
color: "red",
},

View File

@@ -1,722 +0,0 @@
/**
* 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] ?? [];
}

View File

@@ -1,13 +1,17 @@
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { endpoint } from "@/utils/endpoint";
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
import type { BookingDetail } from "@/types/booking";
import type {
CreateFileUploadFieldDto,
CreateFileUploadSettingDto,
FileUploadField,
FileUploadSetting,
UpdateFileUploadFieldDto,
UpdateFileUploadSettingDto,
} from "@/types/fileUploadSettings";
Company,
CompanyListFilter,
CompanyProfile,
CompanyStats,
CustomerBooking,
CustomerDocument,
CustomerPayment,
PaginatedCompanies,
ProfileStatus,
} from "@/types/customer";
import {
CreateDropdownOptionDto,
CreateDropdownSettingDto,
@@ -16,72 +20,20 @@ import {
UpdateDropdownOptionDto,
UpdateDropdownSettingDto,
} from "@/types/dropdownSettings";
import type {
CreateFileUploadFieldDto,
CreateFileUploadSettingDto,
FileUploadField,
FileUploadSetting,
UpdateFileUploadFieldDto,
UpdateFileUploadSettingDto,
} from "@/types/fileUploadSettings";
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
import {
RuleEngineListResult,
RuleEngineRecord,
RuleEngineResourceSlug,
} from "@/types/rule-engine";
import { fileUploadSettingsService } from "./fileUploadSettings.service";
import { dropdownSettingsService } from "./dropdownSettings.service";
import {
ruleEngineService,
RuleEngineListParams,
} from "./ruleEngine/ruleEngine.service";
import {
bookingsService,
BookingListFilter,
type ApproveStepPayload,
type PaginatedBookings,
type RejectStepPayload,
} from "./bookings.service";
import type { BookingDetail } from "@/types/booking";
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
import { overviewService } from "./overview.service";
import {
cargoService,
type Cargo,
type DeliverCargoPayload,
} from "./cargoService";
import { containerService, type Container } from "./containerService";
import { containerTypesService } from "./container-types.service";
import {
wagonService,
type Wagon,
type WagonListFilters,
} from "./wagon.service";
import { wagonTypesService, type WagonType } from "./wagon-types.service";
import { trainService, type Train } from "./trains.service";
import {
locomotivesService,
type Locomotive,
type SaveLocomotivePayload,
} from "./locomotives.service";
import { cargoTypesService } from "./cargo-types.service";
import {
fleetService,
type FleetListFilters,
type FleetRecord,
} from "./fleet/fleet.service";
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
import {
paymentsService,
type PaginatedPayments,
type PaymentListFilter,
type PaymentSummary,
} from "./payments.service";
import {
signaturesService,
type SavedSignature,
type SaveSignaturePayload,
} from "./signatures.service";
import { warehouseService } from "./warehouse.service";
import { trainSchedulingService } from "./trainScheduling.service";
import {
routesService,
type RouteRecord,
type SaveRoutePayload,
type YardRef,
} from "./routes.service";
import type {
AssignBookingsPayload,
BatchBoardSchedule,
@@ -157,6 +109,66 @@ import type {
WarehouseYard,
WarehouseZone,
} from "@/types/warehouse";
import { endpoint } from "@/utils/endpoint";
import {
BookingListFilter,
bookingsService,
type ApproveStepPayload,
type PaginatedBookings,
type RejectStepPayload,
} from "./bookings.service";
import { cargoTypesService } from "./cargo-types.service";
import {
cargoService,
type Cargo,
type DeliverCargoPayload,
} from "./cargoService";
import { containerTypesService } from "./container-types.service";
import { containerService, type Container } from "./containerService";
import { customersService } from "./customers.service";
import { dropdownSettingsService } from "./dropdownSettings.service";
import { fileUploadSettingsService } from "./fileUploadSettings.service";
import {
fleetService,
type FleetListFilters,
type FleetRecord,
} from "./fleet/fleet.service";
import {
locomotivesService,
type Locomotive,
type SaveLocomotivePayload,
} from "./locomotives.service";
import { overviewService } from "./overview.service";
import {
paymentsService,
type PaginatedPayments,
type PaymentListFilter,
type PaymentSummary,
} from "./payments.service";
import {
routesService,
type RouteRecord,
type SaveRoutePayload,
type YardRef,
} from "./routes.service";
import {
RuleEngineListParams,
ruleEngineService,
} from "./ruleEngine/ruleEngine.service";
import {
signaturesService,
type SavedSignature,
type SaveSignaturePayload,
} from "./signatures.service";
import { trainService, type Train } from "./trains.service";
import { trainSchedulingService } from "./trainScheduling.service";
import { wagonTypesService, type WagonType } from "./wagon-types.service";
import {
wagonService,
type Wagon,
type WagonListFilters,
} from "./wagon.service";
import { warehouseService } from "./warehouse.service";
/** Query keys for inventory-lifecycle mutations that ripple across views. */
const INVENTORY_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
@@ -1879,6 +1891,64 @@ export const api = {
),
},
customers: {
stats: endpoint<Record<string, never>, CompanyStats>(
"customers",
"stats",
() => customersService.stats(),
() => QUERY_KEYS.CUSTOMERS.stats,
),
list: endpoint<{ filter: CompanyListFilter }, PaginatedCompanies>(
"customers",
"list",
({ filter }) => customersService.list(filter),
({ filter }) => QUERY_KEYS.CUSTOMERS.list(filter),
),
getById: endpoint<{ id: string }, Company | undefined>(
"customers",
"getById",
({ id }) => customersService.getById(id),
({ id }) => QUERY_KEYS.CUSTOMERS.byId(id),
),
bookings: endpoint<{ id: string }, CustomerBooking[]>(
"customers",
"bookings",
({ id }) => customersService.bookingsFor(id),
({ id }) => QUERY_KEYS.CUSTOMERS.bookings(id),
),
documents: endpoint<{ id: string }, CustomerDocument[]>(
"customers",
"documents",
({ id }) => customersService.documentsFor(id),
({ id }) => QUERY_KEYS.CUSTOMERS.documents(id),
),
payments: endpoint<{ id: string }, CustomerPayment[]>(
"customers",
"payments",
({ id }) => customersService.paymentsFor(id),
({ id }) => QUERY_KEYS.CUSTOMERS.payments(id),
),
setProfileStatus: endpoint<
{ profileId: string; status: ProfileStatus },
CompanyProfile
>(
"customers",
"setProfileStatus",
({ profileId, status }) => customersService.setProfileStatus(profileId, status),
undefined,
(_input, data) => [
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
QUERY_KEYS.CUSTOMERS.ROOT,
],
),
},
overview: {
get: endpoint<{ range?: OverviewRange }, IOverviewDashboard>(
"overview",
@@ -1886,4 +1956,4 @@ export const api = {
({ range }) => overviewService.getDashboard(range),
),
},
};
};

View File

@@ -1,72 +1,90 @@
/**
* 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 { api as apiClient } from "@/auth/http";
import { URL_CONSTANTS } from "@/constants/URLS";
import type {
Company,
CompanyListFilter,
CompanyProfile,
CompanyStats,
CustomerBooking,
CustomerDocument,
CustomerPayment,
PaginatedCompanies,
ProfileStatus,
} 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))
const cleanParams = (params: object) =>
Object.fromEntries(
Object.entries(params).filter(
([, value]) => value !== undefined && value !== "" && value !== null,
),
);
/** Lift attributes JSONB into the flat contact/manager fields the UI reads. */
function mapCompany(dto: Record<string, unknown>): Company {
const attrs = (dto.attributes as Record<string, unknown> | null) ?? {};
return {
...(dto as unknown as Company),
contactPersonName: (attrs.contactPersonName as string | null) ?? null,
contactPersonPhone: (attrs.contactPersonPhone as string | null) ?? null,
generalManagerName: (attrs.generalManagerName as string | null) ?? null,
generalManagerEmail: (attrs.generalManagerEmail as string | null) ?? null,
generalManagerPhone: (attrs.generalManagerPhone as string | null) ?? null,
};
}
export const customersService = {
stats(): Promise<CompanyStats> {
return apiClient
.get<CompanyStats>(URL_CONSTANTS.COMPANIES.STATS)
.then((r) => r.data);
},
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 });
return apiClient
.get<{ items: Record<string, unknown>[]; total: number }>(
URL_CONSTANTS.COMPANIES.BASE,
{ params: cleanParams(filter) },
)
.then((r) => ({
items: r.data.items.map(mapCompany),
total: r.data.total,
}));
},
getById(id: string): Promise<Company | undefined> {
return delay(getCompanyById(id));
return apiClient
.get<Record<string, unknown>>(URL_CONSTANTS.COMPANIES.BY_ID(id))
.then((r) => mapCompany(r.data));
},
bookingsFor(companyId: string): Promise<CustomerBooking[]> {
return delay(getBookingsFor(companyId));
return apiClient
.get<CustomerBooking[]>(
URL_CONSTANTS.COMPANIES.BOOKINGS_CUSTOMER_VIEW(companyId),
)
.then((r) => r.data);
},
documentsFor(companyId: string): Promise<CustomerDocument[]> {
return delay(getDocumentsFor(companyId));
return apiClient
.get<CustomerDocument[]>(URL_CONSTANTS.COMPANIES.DOCUMENTS(companyId))
.then((r) => r.data);
},
paymentsFor(companyId: string): Promise<CustomerPayment[]> {
return delay(getPaymentsFor(companyId));
return apiClient
.get<CustomerPayment[]>(
URL_CONSTANTS.COMPANIES.PAYMENTS_CUSTOMER_VIEW(companyId),
)
.then((r) => r.data);
},
setProfileStatus(profileId: string, status: ProfileStatus): Promise<CompanyProfile> {
return apiClient
.patch<CompanyProfile>(
URL_CONSTANTS.COMPANIES.PROFILE_STATUS(profileId),
{ status },
)
.then((r) => r.data);
},
};

View File

@@ -82,6 +82,15 @@ export interface PaginatedCompanies {
total: number;
}
/** KPI counts returned by GET /companies/stats. */
export interface CompanyStats {
total: number;
active: number;
pending: number;
suspended: number;
blacklisted: number;
}
/* ------------------------------------------------------------------ *
* Related data shown on the customer detail page (mocked for now). *
* ------------------------------------------------------------------ */