Files
saas_frontend/src/application/logs/LogsPage.tsx
T
2026-04-25 08:23:16 +03:00

313 lines
10 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { CustomColumnFilter, CustomButton } from "../../components/custom";
import { DataTable } from "../../components/custom/CustomTable";
import type { ColumnDef } from "../../components/custom/CustomTable";
import {
buildColumnFilterOptions,
resolveColumnSortState,
} from "../../components/custom/CustomColumnFilter.utils";
import type { ColumnSortDirection } from "../../components/custom/CustomColumnFilter";
import { useDebounce } from "../../components/hooks/useDebounce";
import { apiClient } from "../../lib/apiClient";
import { buildQueryString } from "../../lib/queryParams";
import {
DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
resolveStoredTablePageSize,
} from "../../lib/tablePageSize";
import { RefreshCcw } from "lucide-react";
interface AuditLog extends Record<string, unknown> {
id: string;
module_name: string;
action_type: string;
entity_name: string;
performed_by_email: string;
ip_address: string;
description: string;
created_at: string;
}
interface AuditLogListResponse {
items: AuditLog[];
total: number;
limit: number;
offset: number;
}
const LogsPage = () => {
const { t, i18n } = useTranslation(["logs", "common"]);
const [logs, setLogs] = useState<AuditLog[]>([]);
const [allLogsForCounts, setAllLogsForCounts] = useState<AuditLog[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(() =>
resolveStoredTablePageSize({
storageKey: SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
pageSizeOptions: DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
defaultPageSize: 10,
})
);
const [search, setSearch] = useState("");
const [moduleFilter, setModuleFilter] = useState<string[]>([]);
const [actionFilter, setActionFilter] = useState<string[]>([]);
const [emailFilter, setEmailFilter] = useState<string[]>([]);
const [totalRows, setTotalRows] = useState(0);
const [activeSort, setActiveSort] = useState<{
column: "module_name" | "action_type" | "performed_by_email" | "created_at" | null;
direction: ColumnSortDirection;
}>({ column: "created_at", direction: "desc" });
const debouncedSearch = useDebounce(search, 500);
const searchInputRef = useRef<HTMLInputElement>(null);
const prevLoadingRef = useRef(isLoading);
const latestLogsRequestRef = useRef(0);
const fetchLogs = useCallback(async () => {
const requestId = ++latestLogsRequestRef.current;
try {
setIsLoading(true);
const queryString = buildQueryString({
limit: pageSize,
offset: (page - 1) * pageSize,
search: debouncedSearch || undefined,
module_names: moduleFilter,
action_types: actionFilter,
performed_by_emails: emailFilter,
sort_by: activeSort.column ?? undefined,
sort_order: activeSort.direction ?? undefined,
});
const response = await apiClient.get<AuditLogListResponse>(
`/api/admin/audit-logs/${queryString}`,
{ silent: true }
);
if (requestId === latestLogsRequestRef.current) {
setLogs(response.items);
setTotalRows(response.total);
}
} catch (error) {
console.error(t("messages.loadError"), error);
} finally {
if (requestId === latestLogsRequestRef.current) {
setIsLoading(false);
}
}
}, [actionFilter, activeSort, debouncedSearch, emailFilter, page, pageSize, moduleFilter, t]);
const fetchFilterOptions = useCallback(async () => {
try {
const response = await apiClient.get<AuditLogListResponse>(
"/api/admin/audit-logs/?limit=500&offset=0",
{ silent: true }
);
setAllLogsForCounts(response.items);
} catch (error) {
console.error(t("messages.loadError"), error);
}
}, [t]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
useEffect(() => {
fetchFilterOptions();
}, [fetchFilterOptions]);
useEffect(() => {
setPage(1);
}, [debouncedSearch, moduleFilter, actionFilter, emailFilter, activeSort]);
useEffect(() => {
if (prevLoadingRef.current && !isLoading && search.trim()) {
searchInputRef.current?.focus({ preventScroll: true });
}
prevLoadingRef.current = isLoading;
}, [isLoading, search]);
const moduleCounts = useMemo(() => {
const counts = new Map<string, number>();
allLogsForCounts.forEach((log) => {
counts.set(log.module_name, (counts.get(log.module_name) ?? 0) + 1);
});
return counts;
}, [allLogsForCounts]);
const actionCounts = useMemo(() => {
const counts = new Map<string, number>();
allLogsForCounts.forEach((log) => {
counts.set(log.action_type, (counts.get(log.action_type) ?? 0) + 1);
});
return counts;
}, [allLogsForCounts]);
const emailCounts = useMemo(() => {
const counts = new Map<string, number>();
allLogsForCounts.forEach((log) => {
counts.set(log.performed_by_email, (counts.get(log.performed_by_email) ?? 0) + 1);
});
return counts;
}, [allLogsForCounts]);
const columns: ColumnDef<AuditLog>[] = useMemo(
() => [
{
key: "module_name",
visibilityLabel: t("columns.module"),
header: (
<div className="flex items-center">
{t("columns.module")}
<CustomColumnFilter
title={t("columns.module")}
options={buildColumnFilterOptions(moduleCounts.entries())}
selectedValues={moduleFilter}
sortDirection={activeSort.column === "module_name" ? activeSort.direction : null}
onApply={(values, direction) => {
setPage(1);
setModuleFilter(values);
setActiveSort(resolveColumnSortState(activeSort, "module_name", direction));
}}
/>
</div>
),
searchable: false,
},
{
key: "action_type",
visibilityLabel: t("columns.action"),
header: (
<div className="flex items-center">
{t("columns.action")}
<CustomColumnFilter
title={t("columns.action")}
options={buildColumnFilterOptions(actionCounts.entries())}
selectedValues={actionFilter}
sortDirection={activeSort.column === "action_type" ? activeSort.direction : null}
enableSearch={false}
onApply={(values, direction) => {
setPage(1);
setActionFilter(values);
setActiveSort(resolveColumnSortState(activeSort, "action_type", direction));
}}
/>
</div>
),
searchable: false,
render: (row) => (
<span
className={`rounded px-2 py-1 text-xs font-bold ${
row.action_type === "CREATE"
? "bg-green-100 text-green-700"
: row.action_type === "UPDATE"
? "bg-blue-100 text-blue-700"
: row.action_type === "DELETE"
? "bg-red-100 text-red-700"
: "bg-gray-100"
}`}
>
{row.action_type}
</span>
),
},
{ key: "description", header: t("columns.description") },
{
key: "performed_by_email",
visibilityLabel: t("columns.performedBy"),
header: (
<div className="flex items-center">
{t("columns.performedBy")}
<CustomColumnFilter
title={t("columns.performedBy")}
options={buildColumnFilterOptions(emailCounts.entries())}
selectedValues={emailFilter}
sortDirection={activeSort.column === "performed_by_email" ? activeSort.direction : null}
onApply={(values, direction) => {
setPage(1);
setEmailFilter(values);
setActiveSort(resolveColumnSortState(activeSort, "performed_by_email", direction));
}}
/>
</div>
),
searchable: false,
},
{ key: "ip_address", header: t("columns.ipAddress") },
{
key: "created_at",
visibilityLabel: t("columns.timestamp"),
header: (
<div className="flex items-center">
{t("columns.timestamp")}
<CustomColumnFilter
title={t("columns.timestamp")}
options={[]}
selectedValues={[]}
sortDirection={activeSort.column === "created_at" ? activeSort.direction : null}
enableSearch={false}
enableSelectAll={false}
onApply={(_, direction) => {
setPage(1);
setActiveSort(resolveColumnSortState(activeSort, "created_at", direction));
}}
/>
</div>
),
searchable: false,
render: (row) =>
new Date(row.created_at).toLocaleString(
i18n.language === "ar" ? "ar-EG" : "en-GB"
),
},
],
[actionCounts, actionFilter, activeSort, emailCounts, emailFilter, i18n.language, moduleCounts, moduleFilter, t]
);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-(--text-primary)">
{t("title")}
</h1>
<p className="text-sm text-[var(--text-secondary)]">Track and audit all system activities and user actions.</p>
</div>
<CustomButton
variant="outlined"
onClick={fetchLogs}
loading={isLoading}
leftIcon={<RefreshCcw size={16} />}
>
{t("common:actions.refresh")}
</CustomButton>
</div>
<DataTable<AuditLog>
data={logs}
columns={columns}
columnVisibilityEnabled
columnVisibilityStorageKey="logs-table-columns"
pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY}
isLoading={isLoading}
exportEnabled={false}
exportFileName={t("exportFileName")}
manualPagination
manualFiltering
totalRows={totalRows}
page={page}
pageSize={pageSize}
search={search}
onPageChange={setPage}
onPageSizeChange={setPageSize}
onSearchChange={setSearch}
searchInputRef={searchInputRef}
/>
</div>
);
};
export default LogsPage;