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:
Nathnael
2026-06-22 12:32:52 +00:00
parent d66ccc5363
commit c6bc636495
12 changed files with 2124 additions and 25 deletions

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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]}`;
}

View File

@@ -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";