mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
368 lines
11 KiB
TypeScript
368 lines
11 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import {
|
|
ActionIcon,
|
|
Box,
|
|
Card,
|
|
Container,
|
|
Group,
|
|
Paper,
|
|
Select,
|
|
Stack,
|
|
Tabs,
|
|
Text,
|
|
TextInput,
|
|
} from "@mantine/core";
|
|
import {
|
|
CheckCircle2,
|
|
CircleDollarSign,
|
|
Loader2,
|
|
RotateCcw,
|
|
Search,
|
|
X,
|
|
XCircle,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
|
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
|
import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments";
|
|
import type {
|
|
PaymentMethod,
|
|
PaymentRow,
|
|
} from "@/services/payments.service";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
Badge,
|
|
DataTable,
|
|
DataTableFooter,
|
|
type ColumnDef,
|
|
usePagination,
|
|
} from "@edr/ui-common";
|
|
|
|
const STATUS_TABS = [
|
|
{ key: "all", label: "All", statuses: undefined as string | undefined },
|
|
{ key: "success", label: "Success", statuses: "success" },
|
|
{ key: "processing", label: "Processing", statuses: "processing,action-required" },
|
|
{ key: "failed", label: "Failed", statuses: "failed,canceled" },
|
|
{ key: "refunded", label: "Refunded", statuses: "refunded" },
|
|
] as const;
|
|
|
|
type StatusTabKey = (typeof STATUS_TABS)[number]["key"];
|
|
|
|
const METHOD_OPTIONS: { value: PaymentMethod; label: string }[] = [
|
|
{ value: "telebirr", label: "Telebirr" },
|
|
{ value: "waafi", label: "Waafi" },
|
|
{ value: "cbe-birr", label: "CBE Birr" },
|
|
{ value: "ebirr", label: "E-Birr" },
|
|
{ value: "card", label: "Card" },
|
|
{ value: "dmoney", label: "D-Money" },
|
|
{ value: "cac-bank", label: "CAC Bank" },
|
|
];
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
success: "green",
|
|
processing: "yellow",
|
|
"action-required": "yellow",
|
|
failed: "red",
|
|
canceled: "gray",
|
|
refunded: "indigo",
|
|
};
|
|
|
|
function StatCard({
|
|
icon: Icon,
|
|
label,
|
|
value,
|
|
accent,
|
|
}: {
|
|
icon: LucideIcon;
|
|
label: string;
|
|
value: string | number;
|
|
accent: string;
|
|
}) {
|
|
return (
|
|
<Paper
|
|
p="md"
|
|
radius="lg"
|
|
style={{
|
|
flex: "1 1 180px",
|
|
minWidth: 160,
|
|
background: "var(--mantine-color-gray-0)",
|
|
border: "1px solid var(--mantine-color-gray-2)",
|
|
}}
|
|
>
|
|
<Group gap="sm" wrap="nowrap" align="center">
|
|
<Box
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 11,
|
|
background: `var(--mantine-color-${accent}-1)`,
|
|
color: `var(--mantine-color-${accent}-7)`,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<Icon size={20} strokeWidth={2} />
|
|
</Box>
|
|
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
|
|
<Text fw={800} size="24px" lh={1.05} style={{ color: "#0f172a" }} truncate>
|
|
{value}
|
|
</Text>
|
|
<Text size="xs" fw={600} c="dimmed" truncate>
|
|
{label}
|
|
</Text>
|
|
</Stack>
|
|
</Group>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
function formatAmount(amount: number, currency: string): string {
|
|
return `${currency} ${Number(amount).toLocaleString(undefined, {
|
|
minimumFractionDigits: 2,
|
|
})}`;
|
|
}
|
|
|
|
function formatDate(iso: string | null): string {
|
|
if (!iso) return "—";
|
|
const d = new Date(iso);
|
|
return Number.isNaN(d.getTime())
|
|
? "—"
|
|
: d.toLocaleDateString(undefined, {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
const tableHeader = "text-xs font-semibold uppercase tracking-wide text-muted-foreground";
|
|
|
|
export default function PaymentsPage() {
|
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
const [query, setQuery] = useState("");
|
|
const [statusTab, setStatusTab] = useState<StatusTabKey>("all");
|
|
const [method, setMethod] = useState<string | null>(null);
|
|
|
|
const statuses = STATUS_TABS.find((t) => t.key === statusTab)?.statuses;
|
|
|
|
const filter = useMemo(
|
|
() => ({
|
|
search: query.trim() || undefined,
|
|
status: statuses,
|
|
method: method ?? undefined,
|
|
page: pagination.pageIndex + 1,
|
|
pageSize: pagination.pageSize,
|
|
}),
|
|
[query, statuses, method, pagination.pageIndex, pagination.pageSize],
|
|
);
|
|
|
|
const { data, isLoading, isError } = usePaymentList(filter);
|
|
const { data: summary, isLoading: summaryLoading } = usePaymentSummary();
|
|
|
|
const rows = data?.items ?? [];
|
|
const total = data?.total ?? 0;
|
|
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
|
|
|
const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0));
|
|
|
|
const columns: ColumnDef<PaymentRow>[] = [
|
|
{
|
|
id: "order",
|
|
header: () => <span className={tableHeader}>Order</span>,
|
|
cell: ({ row }) => (
|
|
<div className="min-w-0 py-1">
|
|
<p className="truncate font-medium text-foreground">
|
|
{row.original.merchantOrderId ?? row.original.id.slice(0, 8)}
|
|
</p>
|
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
|
Booking {row.original.bookingId?.slice(0, 8) ?? "—"}
|
|
</p>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
id: "amount",
|
|
header: () => <span className={tableHeader}>Amount</span>,
|
|
cell: ({ row }) => (
|
|
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
|
|
{formatAmount(row.original.amount, row.original.currency)}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
id: "method",
|
|
header: () => <span className={tableHeader}>Method</span>,
|
|
cell: ({ row }) => (
|
|
<Badge variant="light" radius="sm">
|
|
{METHOD_OPTIONS.find((m) => m.value === row.original.method)?.label ??
|
|
row.original.method}
|
|
</Badge>
|
|
),
|
|
},
|
|
{
|
|
id: "status",
|
|
header: () => <span className={tableHeader}>Status</span>,
|
|
cell: ({ row }) => (
|
|
<Badge
|
|
color={STATUS_COLORS[row.original.status] ?? "gray"}
|
|
variant="light"
|
|
radius="sm"
|
|
tt="capitalize"
|
|
>
|
|
{row.original.status.replace(/-/g, " ")}
|
|
</Badge>
|
|
),
|
|
},
|
|
{
|
|
id: "date",
|
|
header: () => <span className={tableHeader}>Date</span>,
|
|
cell: ({ row }) => (
|
|
<span className="text-sm text-muted-foreground">
|
|
{formatDate(row.original.paidAt ?? row.original.createdAt)}
|
|
</span>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div style={{ background: "var(--mantine-color-gray-0)", minHeight: "100vh" }}>
|
|
<Container size="xxl" py="xl">
|
|
<Breadcrumbs items={[{ label: "Operations" }, { label: "Payments" }]} />
|
|
|
|
<Stack gap="lg" mt="md">
|
|
<Group grow gap="md" align="stretch" wrap="wrap">
|
|
<StatCard
|
|
icon={CircleDollarSign}
|
|
label="Total collected"
|
|
value={
|
|
summaryLoading
|
|
? "—"
|
|
: `ETB ${Number(summary?.paidAmount ?? 0).toLocaleString()}`
|
|
}
|
|
accent="teal"
|
|
/>
|
|
<StatCard
|
|
icon={CheckCircle2}
|
|
label="Successful"
|
|
value={val(summary?.success)}
|
|
accent="green"
|
|
/>
|
|
<StatCard
|
|
icon={Loader2}
|
|
label="Processing"
|
|
value={val(summary?.processing)}
|
|
accent="yellow"
|
|
/>
|
|
<StatCard
|
|
icon={XCircle}
|
|
label="Failed"
|
|
value={val(summary?.failed)}
|
|
accent="red"
|
|
/>
|
|
<StatCard
|
|
icon={RotateCcw}
|
|
label="Refunded"
|
|
value={val(summary?.refunded)}
|
|
accent="indigo"
|
|
/>
|
|
</Group>
|
|
|
|
<Tabs
|
|
value={statusTab}
|
|
onChange={(value) => {
|
|
setStatusTab((value as StatusTabKey) ?? "all");
|
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
|
}}
|
|
>
|
|
<Tabs.List>
|
|
{STATUS_TABS.map((t) => (
|
|
<Tabs.Tab key={t.key} value={t.key}>
|
|
{t.label}
|
|
</Tabs.Tab>
|
|
))}
|
|
</Tabs.List>
|
|
</Tabs>
|
|
|
|
<Card
|
|
p="md"
|
|
radius="lg"
|
|
withBorder
|
|
style={{ background: "white", border: "1px solid var(--mantine-color-gray-2)" }}
|
|
>
|
|
<Stack gap="md">
|
|
<Group justify="space-between" gap="md" wrap="wrap">
|
|
<TextInput
|
|
placeholder="Search order, booking, or transaction…"
|
|
leftSection={<Search size={18} />}
|
|
value={query}
|
|
onChange={(e) => {
|
|
setQuery(e.target.value);
|
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
|
}}
|
|
rightSection={
|
|
query && (
|
|
<ActionIcon
|
|
size="sm"
|
|
color="gray"
|
|
radius="md"
|
|
variant="transparent"
|
|
onClick={() => setQuery("")}
|
|
>
|
|
<X size={16} />
|
|
</ActionIcon>
|
|
)
|
|
}
|
|
style={{ flex: 1, minWidth: "200px" }}
|
|
radius="lg"
|
|
/>
|
|
<Select
|
|
placeholder="All methods"
|
|
clearable
|
|
data={METHOD_OPTIONS}
|
|
value={method}
|
|
onChange={(value) => {
|
|
setMethod(value);
|
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
|
}}
|
|
radius="lg"
|
|
style={{ minWidth: 180 }}
|
|
/>
|
|
<Text size="sm" c="dimmed">
|
|
{total} record{total !== 1 ? "s" : ""}
|
|
</Text>
|
|
</Group>
|
|
|
|
<div style={{ overflowX: "auto" }}>
|
|
<DataTable
|
|
columns={columns}
|
|
data={rows}
|
|
status={isLoading ? "loading" : isError ? "error" : "success"}
|
|
pagination={{
|
|
pageIndex: pagination.pageIndex,
|
|
pageSize: pagination.pageSize,
|
|
pageCount,
|
|
totalCount: total,
|
|
}}
|
|
tableOptions={{
|
|
state: { pagination },
|
|
onPaginationChange: setPagination,
|
|
manualPagination: true,
|
|
pageCount,
|
|
}}
|
|
containerClassName={cn(
|
|
"border-0 shadow-none",
|
|
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
|
|
"[&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
|
|
)}
|
|
footer={DataTableFooter}
|
|
/>
|
|
</div>
|
|
</Stack>
|
|
</Card>
|
|
</Stack>
|
|
</Container>
|
|
</div>
|
|
);
|
|
}
|