From 44f0a080ffeb5368d2c1c354e470fd76a76c5fc7 Mon Sep 17 00:00:00 2001 From: xtcnet Date: Wed, 18 Mar 2026 22:43:01 +0700 Subject: [PATCH] feat(audit-log): add pagination, global search filter and CSV export --- frontend/src/pages/AuditLog/Table.tsx | 190 ++++++++++++++++--- frontend/src/pages/AuditLog/TableWrapper.tsx | 50 ++++- 2 files changed, 209 insertions(+), 31 deletions(-) diff --git a/frontend/src/pages/AuditLog/Table.tsx b/frontend/src/pages/AuditLog/Table.tsx index f61809a..cf64329 100644 --- a/frontend/src/pages/AuditLog/Table.tsx +++ b/frontend/src/pages/AuditLog/Table.tsx @@ -1,4 +1,10 @@ -import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import { + createColumnHelper, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + useReactTable, +} from "@tanstack/react-table"; import { useMemo } from "react"; import type { AuditLog } from "src/api/backend"; import { EventFormatter, GravatarFormatter } from "src/components"; @@ -9,8 +15,41 @@ interface Props { data: AuditLog[]; isFetching?: boolean; onSelectItem?: (id: number) => void; + globalFilter?: string; } -export default function Table({ data, isFetching, onSelectItem }: Props) { + +/** + * Export filtered rows to CSV and trigger browser download. + */ +function exportCSV(rows: AuditLog[]) { + const headers = ["ID", "Date", "User", "Object Type", "Action", "Object ID"]; + const escape = (v: string) => `"${String(v ?? "").replace(/"/g, '""')}"`; + const lines = [ + headers.join(","), + ...rows.map((r) => + [ + r.id, + r.createdOn, + r.user?.name ?? r.userId, + r.objectType, + r.action, + r.objectId, + ] + .map(String) + .map(escape) + .join(","), + ), + ]; + const blob = new Blob([lines.join("\r\n")], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); +} + +export default function Table({ data, isFetching, onSelectItem, globalFilter }: Props) { const columnHelper = createColumnHelper(); const columns = useMemo( () => [ @@ -20,36 +59,41 @@ export default function Table({ data, isFetching, onSelectItem }: Props) { const value = info.getValue(); return ; }, - meta: { - className: "w-1", - }, + meta: { className: "w-1" }, }), columnHelper.accessor((row: AuditLog) => row, { id: "objectType", header: intl.formatMessage({ id: "column.event" }), - cell: (info: any) => { - return ; + cell: (info: any) => , + filterFn: (row, _columnId, filterValue: string) => { + const r = row.original; + const haystack = [ + r.user?.name ?? "", + r.objectType, + r.action, + r.createdOn, + String(r.objectId), + ] + .join(" ") + .toLowerCase(); + return haystack.includes(filterValue.toLowerCase()); }, }), columnHelper.display({ id: "id", - cell: (info: any) => { - return ( - - ); - }, - meta: { - className: "text-end w-1", - }, + cell: (info: any) => ( + + ), + meta: { className: "text-end w-1" }, }), ], [columnHelper, onSelectItem], @@ -58,13 +102,101 @@ export default function Table({ data, isFetching, onSelectItem }: Props) { const tableInstance = useReactTable({ columns, data, - getCoreRowModel: getCoreRowModel(), - rowCount: data.length, - meta: { - isFetching, + state: { + globalFilter, }, + globalFilterFn: (row, _columnId, filterValue: string) => { + if (!filterValue) return true; + const r = row.original; + const haystack = [ + r.user?.name ?? "", + r.objectType, + r.action, + r.createdOn, + String(r.objectId), + ] + .join(" ") + .toLowerCase(); + return haystack.includes(filterValue.toLowerCase()); + }, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + initialState: { + pagination: { pageSize: 10 }, + }, + rowCount: data.length, + meta: { isFetching }, enableSortingRemoval: false, }); - return ; + const { pageIndex, pageSize } = tableInstance.getState().pagination; + const filteredRows = tableInstance.getFilteredRowModel().rows.map((r) => r.original); + const totalRows = filteredRows.length; + const pageCount = tableInstance.getPageCount(); + const from = totalRows === 0 ? 0 : pageIndex * pageSize + 1; + const to = Math.min((pageIndex + 1) * pageSize, totalRows); + + return ( + <> + + {(totalRows > pageSize || pageCount > 1) && ( +
+ {/* Record count */} + + {from}–{to} / {totalRows} + + + {/* Prev / Page / Next */} +
+ + + {pageIndex + 1} / {pageCount} + + +
+ + {/* Page size */} +
+ Mỗi trang: + +
+ + {/* Export */} + +
+ )} + + ); } diff --git a/frontend/src/pages/AuditLog/TableWrapper.tsx b/frontend/src/pages/AuditLog/TableWrapper.tsx index a7d3967..9477c93 100644 --- a/frontend/src/pages/AuditLog/TableWrapper.tsx +++ b/frontend/src/pages/AuditLog/TableWrapper.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import Alert from "react-bootstrap/Alert"; import { LoadingPage } from "src/components"; import { useAuditLogs } from "src/hooks"; @@ -7,6 +8,7 @@ import Table from "./Table"; export default function TableWrapper() { const { isFetching, isLoading, isError, error, data } = useAuditLogs(["user"]); + const [search, setSearch] = useState(""); if (isLoading) { return ; @@ -21,15 +23,59 @@ export default function TableWrapper() {
-
+

+ {/* Search box */} +
+
+ + + + + + + setSearch(e.target.value)} + style={{ minWidth: "180px" }} + /> + {search && ( + + )} +
+
- +
);