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>
This commit is contained in:
Arda Samadi 2026-07-25 13:33:59 +03:30
parent 40052b7002
commit 7ad3fccd6e
3 changed files with 185 additions and 0 deletions

View File

@ -318,6 +318,23 @@ export const useLedgerSellers = (params: AdminListParams) =>
export const useLedgerJournal = (params: AdminListParams) => export const useLedgerJournal = (params: AdminListParams) =>
useAdminList<LedgerEntryRow>("ledger/journal", params); useAdminList<LedgerEntryRow>("ledger/journal", params);
/* ------------------------------ audit log ------------------------------ */
export interface AuditLogRow {
id: string;
createdAt: string;
actorPhone: string | null;
action: string;
targetType: string;
targetId: string | null;
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
note: string;
}
export const useAuditLog = (params: AdminListParams) =>
useAdminList<AuditLogRow>("audit", params);
export function useLedgerSellerDetail(id: string | undefined) { export function useLedgerSellerDetail(id: string | undefined) {
const token = useAuthToken(); const token = useAuthToken();
return useQuery({ return useQuery({

166
app/routes/admin.audit.tsx Normal file
View File

@ -0,0 +1,166 @@
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>
);
}

View File

@ -6,6 +6,7 @@ import {
Users, Users,
Package, Package,
Landmark, Landmark,
ScrollText,
ArrowRight, ArrowRight,
ShieldCheck, ShieldCheck,
Loader2, Loader2,
@ -28,6 +29,7 @@ const NAV: NavItem[] = [
{ label: "کاربران", to: "/admin/users", icon: Users }, { label: "کاربران", to: "/admin/users", icon: Users },
{ label: "محصولات", to: "/admin/products", icon: Package }, { label: "محصولات", to: "/admin/products", icon: Package },
{ label: "دفتر مالی", to: "/admin/ledger", icon: Landmark }, { label: "دفتر مالی", to: "/admin/ledger", icon: Landmark },
{ label: "گزارش فعالیت", to: "/admin/audit", icon: ScrollText },
]; ];
export default function AdminLayout() { export default function AdminLayout() {