Add payment module, BFF proxy, hub/dashboard/ops/PSP/merchant screens wired to real APIs, and mark payment-frontend complete in project status. Co-authored-by: Cursor <cursoragent@cursor.com>
249 lines
8.4 KiB
TypeScript
249 lines
8.4 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useState } from "react";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { Download, RefreshCw, Search } from "lucide-react";
|
||
import { Button, Input, Select } from "@/components/ds";
|
||
import { useTenantId } from "@/hooks/useTenantId";
|
||
import { PaymentStatusChip } from "@/modules/payment/components/PaymentStatusChip";
|
||
import {
|
||
PaymentEmptyState,
|
||
PaymentPageError,
|
||
PaymentPageLoader,
|
||
exportToCsv,
|
||
} from "@/modules/payment/pages/shared";
|
||
import type { PaymentRecord } from "@/modules/payment/types";
|
||
|
||
type Column = {
|
||
key: string;
|
||
header: string;
|
||
type?: "status" | "datetime" | "text";
|
||
render?: (row: PaymentRecord) => React.ReactNode;
|
||
};
|
||
|
||
type Props = {
|
||
title: string;
|
||
description?: string;
|
||
resourceKey: string;
|
||
columns: Column[];
|
||
fetcher: (tenantId: string) => Promise<PaymentRecord[]>;
|
||
filterKey?: string;
|
||
detailHref?: (row: PaymentRecord) => string | null;
|
||
rowActions?: React.ReactNode | ((row: PaymentRecord, refresh: () => void) => React.ReactNode);
|
||
headerActions?: React.ReactNode;
|
||
};
|
||
|
||
const PAGE_SIZE = 10;
|
||
|
||
export function PaymentListPage({
|
||
title,
|
||
description,
|
||
resourceKey,
|
||
columns,
|
||
fetcher,
|
||
filterKey = "status",
|
||
detailHref,
|
||
rowActions,
|
||
headerActions,
|
||
}: Props) {
|
||
const { tenantId } = useTenantId();
|
||
const [search, setSearch] = useState("");
|
||
const [filter, setFilter] = useState("");
|
||
const [sortKey, setSortKey] = useState(columns[0]?.key || "created_at");
|
||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||
const [page, setPage] = useState(1);
|
||
|
||
const q = useQuery({
|
||
queryKey: ["payment", tenantId, resourceKey],
|
||
queryFn: () => fetcher(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
|
||
const rows = useMemo(() => {
|
||
let data = [...(q.data ?? [])];
|
||
if (filter) data = data.filter((r) => String(r[filterKey] ?? "") === filter);
|
||
if (search.trim()) {
|
||
const s = search.trim().toLowerCase();
|
||
data = data.filter((r) => JSON.stringify(r).toLowerCase().includes(s));
|
||
}
|
||
data.sort((a, b) => {
|
||
const av = String(a[sortKey] ?? "");
|
||
const bv = String(b[sortKey] ?? "");
|
||
return sortDir === "asc" ? av.localeCompare(bv) : bv.localeCompare(av);
|
||
});
|
||
return data;
|
||
}, [q.data, filter, filterKey, search, sortKey, sortDir]);
|
||
|
||
const filterOptions = useMemo(() => {
|
||
const set = new Set((q.data ?? []).map((r) => String(r[filterKey] ?? "")).filter(Boolean));
|
||
return Array.from(set);
|
||
}, [q.data, filterKey]);
|
||
|
||
const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
|
||
const pageRows = rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||
|
||
if (!tenantId || q.isLoading) return <PaymentPageLoader />;
|
||
if (q.isError) return <PaymentPageError error={q.error} onRetry={() => q.refetch()} />;
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||
<div>
|
||
<h1 className="text-xl font-semibold text-secondary">{title}</h1>
|
||
{description ? <p className="mt-1 text-sm text-[var(--muted)]">{description}</p> : null}
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{headerActions}
|
||
<Button variant="outline" size="sm" onClick={() => q.refetch()} aria-label="بازنشانی">
|
||
<RefreshCw className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() =>
|
||
exportToCsv(
|
||
resourceKey,
|
||
rows,
|
||
columns.map((c) => c.key)
|
||
)
|
||
}
|
||
>
|
||
<Download className="ml-1 h-4 w-4" />
|
||
خروجی
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2 sm:flex-row">
|
||
<div className="relative flex-1">
|
||
<Search className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--muted)]" />
|
||
<Input
|
||
className="pr-9"
|
||
placeholder="جستجو…"
|
||
value={search}
|
||
onChange={(e) => {
|
||
setSearch(e.target.value);
|
||
setPage(1);
|
||
}}
|
||
aria-label="جستجو"
|
||
/>
|
||
</div>
|
||
<Select
|
||
value={filter}
|
||
onChange={(e) => {
|
||
setFilter(e.target.value);
|
||
setPage(1);
|
||
}}
|
||
aria-label="فیلتر وضعیت"
|
||
>
|
||
<option value="">همه وضعیتها</option>
|
||
{filterOptions.map((o) => (
|
||
<option key={o} value={o}>
|
||
{o}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
<Select
|
||
value={`${sortKey}:${sortDir}`}
|
||
onChange={(e) => {
|
||
const [k, d] = e.target.value.split(":");
|
||
setSortKey(k);
|
||
setSortDir(d as "asc" | "desc");
|
||
}}
|
||
aria-label="مرتبسازی"
|
||
>
|
||
{columns.map((c) => (
|
||
<option key={`${c.key}-desc`} value={`${c.key}:desc`}>
|
||
{c.header} ↓
|
||
</option>
|
||
))}
|
||
{columns.map((c) => (
|
||
<option key={`${c.key}-asc`} value={`${c.key}:asc`}>
|
||
{c.header} ↑
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</div>
|
||
|
||
{rows.length === 0 ? (
|
||
<PaymentEmptyState />
|
||
) : (
|
||
<div className="overflow-x-auto rounded-xl border border-[var(--border)] bg-[var(--surface)]">
|
||
<table className="min-w-full text-sm">
|
||
<thead className="bg-[var(--surface-muted)] text-[var(--muted)]">
|
||
<tr>
|
||
{columns.map((c) => (
|
||
<th key={c.key} className="px-3 py-2 text-right font-medium">
|
||
{c.header}
|
||
</th>
|
||
))}
|
||
{(detailHref || rowActions) && (
|
||
<th className="px-3 py-2 text-right font-medium">عملیات</th>
|
||
)}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{pageRows.map((row, idx) => (
|
||
<tr key={String(row.id ?? idx)} className="border-t border-[var(--border)]">
|
||
{columns.map((c) => (
|
||
<td key={c.key} className="px-3 py-2 text-secondary">
|
||
{c.render
|
||
? c.render(row)
|
||
: c.type === "status"
|
||
? <PaymentStatusChip status={String(row[c.key] ?? "")} />
|
||
: c.type === "datetime" && row[c.key]
|
||
? new Date(String(row[c.key])).toLocaleString("fa-IR")
|
||
: String(row[c.key] ?? "—")}
|
||
</td>
|
||
))}
|
||
{(detailHref || rowActions) && (
|
||
<td className="px-3 py-2">
|
||
<div className="flex flex-wrap gap-2">
|
||
{detailHref?.(row) ? (
|
||
<a
|
||
href={detailHref(row)!}
|
||
className="text-xs font-medium text-[var(--payment-accent,#0d9488)] hover:underline"
|
||
>
|
||
جزئیات
|
||
</a>
|
||
) : null}
|
||
{typeof rowActions === "function"
|
||
? rowActions(row, () => q.refetch())
|
||
: rowActions}
|
||
</div>
|
||
</td>
|
||
)}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center justify-between text-xs text-[var(--muted)]">
|
||
<span>
|
||
{rows.length} مورد — صفحه {page} از {totalPages}
|
||
</span>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={page <= 1}
|
||
onClick={() => setPage((p) => p - 1)}
|
||
>
|
||
قبلی
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={page >= totalPages}
|
||
onClick={() => setPage((p) => p + 1)}
|
||
>
|
||
بعدی
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|