feat(audit-log): add pagination, global search filter and CSV export
Some checks failed
Docker Cloud Build / Build & Publish Image (push) Failing after 10m11s

This commit is contained in:
xtcnet 2026-03-18 22:43:01 +07:00
parent 9929d77326
commit 44f0a080ff
2 changed files with 209 additions and 31 deletions

View file

@ -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<AuditLog>();
const columns = useMemo(
() => [
@ -20,36 +59,41 @@ export default function Table({ data, isFetching, onSelectItem }: Props) {
const value = info.getValue();
return <GravatarFormatter url={value ? value.avatar : ""} name={value ? value.name : ""} />;
},
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 <EventFormatter row={info.getValue()} />;
cell: (info: any) => <EventFormatter row={info.getValue()} />,
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 (
<button
type="button"
className="btn btn-action btn-sm px-1"
onClick={(e) => {
e.preventDefault();
onSelectItem?.(info.row.original.id);
}}
>
<T id="action.view-details" />
</button>
);
},
meta: {
className: "text-end w-1",
},
cell: (info: any) => (
<button
type="button"
className="btn btn-action btn-sm px-1"
onClick={(e) => {
e.preventDefault();
onSelectItem?.(info.row.original.id);
}}
>
<T id="action.view-details" />
</button>
),
meta: { className: "text-end w-1" },
}),
],
[columnHelper, onSelectItem],
@ -58,13 +102,101 @@ export default function Table({ data, isFetching, onSelectItem }: Props) {
const tableInstance = useReactTable<AuditLog>({
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 <TableLayout tableInstance={tableInstance} />;
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 (
<>
<TableLayout tableInstance={tableInstance} />
{(totalRows > pageSize || pageCount > 1) && (
<div className="card-footer d-flex align-items-center justify-content-between py-2 px-3 flex-wrap gap-2">
{/* Record count */}
<small className="text-muted">
{from}{to} / {totalRows}
</small>
{/* Prev / Page / Next */}
<div className="d-flex gap-1">
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => tableInstance.previousPage()}
disabled={!tableInstance.getCanPreviousPage()}
>
<T id="action.previous" defaultMessage="Trước" />
</button>
<span className="btn btn-sm disabled text-muted px-2">
{pageIndex + 1} / {pageCount}
</span>
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => tableInstance.nextPage()}
disabled={!tableInstance.getCanNextPage()}
>
<T id="action.next" defaultMessage="Tiếp" />
</button>
</div>
{/* Page size */}
<div className="d-flex align-items-center gap-2">
<small className="text-muted text-nowrap">Mỗi trang:</small>
<select
className="form-select form-select-sm w-auto"
value={pageSize}
onChange={(e) => tableInstance.setPageSize(Number(e.target.value))}
>
{[10, 25, 50, 100].map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</div>
{/* Export */}
<button
type="button"
className="btn btn-sm btn-outline-success"
onClick={() => exportCSV(filteredRows)}
title="Export CSV"
>
CSV
</button>
</div>
)}
</>
);
}

View file

@ -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 <LoadingPage />;
@ -21,15 +23,59 @@ export default function TableWrapper() {
<div className="card-status-top bg-purple" />
<div className="card-table">
<div className="card-header">
<div className="row w-full">
<div className="row w-full align-items-center">
<div className="col">
<h2 className="mt-1 mb-0">
<T id="auditlogs" />
</h2>
</div>
{/* Search box */}
<div className="col-auto">
<div className="input-group input-group-sm">
<span className="input-group-text">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</span>
<input
type="text"
className="form-control"
placeholder="Tìm kiếm..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ minWidth: "180px" }}
/>
{search && (
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={() => setSearch("")}
title="Xoá tìm kiếm"
>
</button>
)}
</div>
</div>
</div>
</div>
<Table data={data ?? []} isFetching={isFetching} onSelectItem={showEventDetailsModal} />
<Table
data={data ?? []}
isFetching={isFetching}
onSelectItem={showEventDetailsModal}
globalFilter={search}
/>
</div>
</div>
);