Vitron-Front/app/routes/admin.audit.tsx
Arda Samadi 7ad3fccd6e admin: Slice 3a audit-log viewer
- 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>
2026-07-25 13:33:59 +03:30

167 lines
5.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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