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:
parent
40052b7002
commit
7ad3fccd6e
@ -318,6 +318,23 @@ export const useLedgerSellers = (params: AdminListParams) =>
|
||||
export const useLedgerJournal = (params: AdminListParams) =>
|
||||
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) {
|
||||
const token = useAuthToken();
|
||||
return useQuery({
|
||||
|
||||
166
app/routes/admin.audit.tsx
Normal file
166
app/routes/admin.audit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,7 @@ import {
|
||||
Users,
|
||||
Package,
|
||||
Landmark,
|
||||
ScrollText,
|
||||
ArrowRight,
|
||||
ShieldCheck,
|
||||
Loader2,
|
||||
@ -28,6 +29,7 @@ const NAV: NavItem[] = [
|
||||
{ label: "کاربران", to: "/admin/users", icon: Users },
|
||||
{ label: "محصولات", to: "/admin/products", icon: Package },
|
||||
{ label: "دفتر مالی", to: "/admin/ledger", icon: Landmark },
|
||||
{ label: "گزارش فعالیت", to: "/admin/audit", icon: ScrollText },
|
||||
];
|
||||
|
||||
export default function AdminLayout() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user