- useAuditLog hook + AuditLogRow type. - new /admin/audit route: action/target filters, search, and a compact before→after diff of the touched fields per entry. - sidebar "گزارش فعالیت" nav link. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
167 lines
5.2 KiB
TypeScript
167 lines
5.2 KiB
TypeScript
import { useState } from "react";
|
||
import { useAuditLog, type AuditLogRow } from "~/requestHandler/use-admin-hooks";
|
||
import {
|
||
AdminTable,
|
||
AdminPagination,
|
||
AdminToolbar,
|
||
AdminSearch,
|
||
AdminSelect,
|
||
AdminPageTitle,
|
||
StatusBadge,
|
||
faDate,
|
||
useDebouncedValue,
|
||
type Column,
|
||
} from "~/components/admin/DataTable";
|
||
|
||
const PAGE_SIZE = 25;
|
||
|
||
const ACTION: Record<string, { label: string; tone: "green" | "blue" | "red" | "gray" }> = {
|
||
"seller.update": { label: "ویرایش فروشنده", tone: "blue" },
|
||
"user.update": { label: "ویرایش کاربر", tone: "blue" },
|
||
"product.update": { label: "ویرایش محصول", tone: "blue" },
|
||
"payout.complete": { label: "تکمیل تسویه", tone: "green" },
|
||
"payout.reject": { label: "رد تسویه", tone: "red" },
|
||
"ledger.backfill": { label: "بازسازی دفتر", tone: "gray" },
|
||
};
|
||
|
||
const TARGET_LABEL: Record<string, string> = {
|
||
seller: "فروشنده",
|
||
user: "کاربر",
|
||
product: "محصول",
|
||
payout: "تسویه",
|
||
ledger: "دفتر مالی",
|
||
};
|
||
|
||
/** Compact before→after diff of the touched fields. */
|
||
function Changes({ before, after }: Pick<AuditLogRow, "before" | "after">) {
|
||
const keys = Array.from(
|
||
new Set([...Object.keys(before || {}), ...Object.keys(after || {})])
|
||
);
|
||
const changed = keys.filter(
|
||
(k) => JSON.stringify(before?.[k]) !== JSON.stringify(after?.[k])
|
||
);
|
||
if (changed.length === 0)
|
||
return <span className="text-GRAY">—</span>;
|
||
return (
|
||
<div className="flex flex-col gap-0.5" dir="ltr">
|
||
{changed.map((k) => (
|
||
<span key={k} className="text-[11px] tabular-nums whitespace-nowrap">
|
||
<span className="text-GRAY">{k}: </span>
|
||
<span className="text-RED">{fmt(before?.[k])}</span>
|
||
<span className="text-GRAY"> → </span>
|
||
<span className="text-GREEN">{fmt(after?.[k])}</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function fmt(v: unknown) {
|
||
if (v === null || v === undefined) return "∅";
|
||
if (typeof v === "boolean") return v ? "true" : "false";
|
||
return String(v);
|
||
}
|
||
|
||
export default function AdminAudit() {
|
||
const [page, setPage] = useState(1);
|
||
const [search, setSearch] = useState("");
|
||
const [action, setAction] = useState("");
|
||
const [targetType, setTargetType] = useState("");
|
||
const debouncedSearch = useDebouncedValue(search);
|
||
|
||
const { data, isLoading } = useAuditLog({
|
||
page,
|
||
page_size: PAGE_SIZE,
|
||
search: debouncedSearch,
|
||
action,
|
||
target_type: targetType,
|
||
});
|
||
|
||
const reset = (fn: () => void) => {
|
||
fn();
|
||
setPage(1);
|
||
};
|
||
|
||
const columns: Column<AuditLogRow>[] = [
|
||
{
|
||
header: "عملیات",
|
||
render: (r) => {
|
||
const a = ACTION[r.action] || { label: r.action, tone: "gray" as const };
|
||
return <StatusBadge label={a.label} tone={a.tone} />;
|
||
},
|
||
},
|
||
{
|
||
header: "هدف",
|
||
render: (r) => (
|
||
<span className="text-[12px] whitespace-nowrap">
|
||
{TARGET_LABEL[r.targetType] || r.targetType}
|
||
{r.targetId && (
|
||
<span className="text-GRAY tabular-nums"> · {r.targetId.slice(0, 8)}</span>
|
||
)}
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
header: "تغییرات",
|
||
render: (r) => <Changes before={r.before} after={r.after} />,
|
||
},
|
||
{
|
||
header: "مدیر",
|
||
render: (r) => (
|
||
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
|
||
{r.actorPhone || "سیستم"}
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
header: "تاریخ",
|
||
render: (r) => <span className="text-GRAY whitespace-nowrap">{faDate(r.createdAt)}</span>,
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<AdminPageTitle>گزارش فعالیت مدیران</AdminPageTitle>
|
||
<AdminToolbar>
|
||
<AdminSearch
|
||
value={search}
|
||
onChange={(v) => reset(() => setSearch(v))}
|
||
placeholder="جستجوی تلفن مدیر / شناسه هدف…"
|
||
/>
|
||
<AdminSelect
|
||
value={action}
|
||
onChange={(v) => reset(() => setAction(v))}
|
||
options={[
|
||
{ value: "", label: "همه عملیاتها" },
|
||
{ value: "seller.update", label: "ویرایش فروشنده" },
|
||
{ value: "user.update", label: "ویرایش کاربر" },
|
||
{ value: "product.update", label: "ویرایش محصول" },
|
||
{ value: "payout.complete", label: "تکمیل تسویه" },
|
||
{ value: "payout.reject", label: "رد تسویه" },
|
||
{ value: "ledger.backfill", label: "بازسازی دفتر" },
|
||
]}
|
||
/>
|
||
<AdminSelect
|
||
value={targetType}
|
||
onChange={(v) => reset(() => setTargetType(v))}
|
||
options={[
|
||
{ value: "", label: "همه هدفها" },
|
||
{ value: "seller", label: "فروشنده" },
|
||
{ value: "user", label: "کاربر" },
|
||
{ value: "product", label: "محصول" },
|
||
{ value: "payout", label: "تسویه" },
|
||
]}
|
||
/>
|
||
</AdminToolbar>
|
||
<AdminTable
|
||
columns={columns}
|
||
rows={data?.results || []}
|
||
isLoading={isLoading}
|
||
getRowKey={(r) => r.id}
|
||
emptyText="فعالیتی ثبت نشده است"
|
||
/>
|
||
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
|
||
</div>
|
||
);
|
||
}
|