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) =>
|
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
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,
|
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() {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user