feat: implement audit logs module with routing, sidebar integration, and API services
This commit is contained in:
+2
-1
@@ -8,7 +8,7 @@ import RecoveryIncidentsList from './app/recoveryIncidents/components/RecoveryIn
|
|||||||
import RecoveryIncidentTabs from './app/recoveryIncidents/tabs/index'
|
import RecoveryIncidentTabs from './app/recoveryIncidents/tabs/index'
|
||||||
import ActionBuilderPage from './app/actionBuilder'
|
import ActionBuilderPage from './app/actionBuilder'
|
||||||
import MasterDataManagement from './app/masterData'
|
import MasterDataManagement from './app/masterData'
|
||||||
|
import AuditLogsList from './app/auditLogs/components/AuditLogsList'
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
@@ -22,6 +22,7 @@ function AppRoutes() {
|
|||||||
<Route path="/config" element={<MasterDataManagement />} />
|
<Route path="/config" element={<MasterDataManagement />} />
|
||||||
<Route path="/recovery" element={<RecoveryIncidentsList />} />
|
<Route path="/recovery" element={<RecoveryIncidentsList />} />
|
||||||
<Route path="/recovery/:id" element={<RecoveryIncidentTabs />} />
|
<Route path="/recovery/:id" element={<RecoveryIncidentTabs />} />
|
||||||
|
<Route path="/audit-logs" element={<AuditLogsList />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ApiClient } from '../api/ApiClient';
|
||||||
|
import type { AuditLog, AuditLogListResponse, AuditLogFilters } from './AuditLogsTypes';
|
||||||
|
|
||||||
|
export function getAuditLogs(filters: AuditLogFilters = {}): Promise<AuditLogListResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.page) params.set('page', String(filters.page));
|
||||||
|
if (filters.limit) params.set('limit', String(filters.limit));
|
||||||
|
if (filters.module) params.set('module', filters.module);
|
||||||
|
if (filters.action) params.set('action', filters.action);
|
||||||
|
if (filters.entityId) params.set('entityId', filters.entityId);
|
||||||
|
if (filters.dateFrom) params.set('dateFrom', filters.dateFrom);
|
||||||
|
if (filters.dateTo) params.set('dateTo', filters.dateTo);
|
||||||
|
|
||||||
|
return ApiClient.get<any, AuditLogListResponse>(`/audit-logs?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuditLog(id: string): Promise<AuditLog> {
|
||||||
|
return ApiClient.get<any, AuditLog>(`/audit-logs/${id}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
export interface AuditLog {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
module: string; // 'policy-engine' | 'recovery-incident' | 'cohort' | 'master-data'
|
||||||
|
action: string; // 'CREATE' | 'UPDATE' | 'DELETE' | 'STATUS_CHANGE'
|
||||||
|
entityId?: string;
|
||||||
|
entityLabel?: string;
|
||||||
|
before?: Record<string, any>;
|
||||||
|
after?: Record<string, any>;
|
||||||
|
performedBy?: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogListResponse {
|
||||||
|
data: AuditLog[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogFilters {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
module?: string;
|
||||||
|
action?: string;
|
||||||
|
entityId?: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUDIT_MODULE_LABELS: Record<string, string> = {
|
||||||
|
'policy-engine': 'Policy Engine',
|
||||||
|
'recovery-incident': 'Recovery Incident',
|
||||||
|
'cohort': 'Cohort',
|
||||||
|
'master-data': 'Master Data',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AUDIT_ACTION_VARIANTS: Record<string, 'success' | 'error' | 'warning' | 'info' | 'neutral'> = {
|
||||||
|
CREATE: 'success',
|
||||||
|
UPDATE: 'info',
|
||||||
|
DELETE: 'error',
|
||||||
|
STATUS_CHANGE: 'warning',
|
||||||
|
};
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { X, ClockCounterClockwise } from "@phosphor-icons/react";
|
||||||
|
import type { AuditLog } from "../AuditLogsTypes";
|
||||||
|
import { AUDIT_MODULE_LABELS, AUDIT_ACTION_VARIANTS } from "../AuditLogsTypes";
|
||||||
|
import { CustomStatus } from "../../../components/custom";
|
||||||
|
|
||||||
|
interface AuditLogDetailProps {
|
||||||
|
log: AuditLog | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function JsonDiffRow({
|
||||||
|
label,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
before: any;
|
||||||
|
after: any;
|
||||||
|
}) {
|
||||||
|
const changed =
|
||||||
|
JSON.stringify(before) !== JSON.stringify(after);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className={changed ? "bg-amber-50" : ""}>
|
||||||
|
<td className="py-2 px-3 text-[12px] font-semibold text-gray-500 w-[140px] align-top border-b border-gray-100">
|
||||||
|
{label}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-[12px] text-red-500 align-top border-b border-gray-100 w-1/2 font-mono break-all">
|
||||||
|
{before !== undefined ? JSON.stringify(before) : <span className="text-gray-300 italic">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-[12px] text-[#1B9869] align-top border-b border-gray-100 w-1/2 font-mono break-all">
|
||||||
|
{after !== undefined ? JSON.stringify(after) : <span className="text-gray-300 italic">—</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffTable({ before, after }: { before?: Record<string, any>; after?: Record<string, any> }) {
|
||||||
|
const allKeys = Array.from(
|
||||||
|
new Set([
|
||||||
|
...Object.keys(before ?? {}),
|
||||||
|
...Object.keys(after ?? {}),
|
||||||
|
])
|
||||||
|
).filter((k) => !["tenantId", "createdAt", "updatedAt"].includes(k));
|
||||||
|
|
||||||
|
if (allKeys.length === 0) {
|
||||||
|
return <p className="text-[13px] text-gray-400 italic mt-2">No data to display.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="w-full border-collapse mt-3">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50">
|
||||||
|
<th className="py-2 px-3 text-left text-[11px] font-bold text-gray-400 uppercase tracking-wider w-[140px]">Field</th>
|
||||||
|
<th className="py-2 px-3 text-left text-[11px] font-bold text-red-400 uppercase tracking-wider w-1/2">Before</th>
|
||||||
|
<th className="py-2 px-3 text-left text-[11px] font-bold text-[#1B9869] uppercase tracking-wider w-1/2">After</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{allKeys.map((key) => (
|
||||||
|
<JsonDiffRow
|
||||||
|
key={key}
|
||||||
|
label={key}
|
||||||
|
before={(before ?? {})[key]}
|
||||||
|
after={(after ?? {})[key]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditLogDetail({ log, onClose }: AuditLogDetailProps) {
|
||||||
|
if (!log) return null;
|
||||||
|
|
||||||
|
const actionVariant = AUDIT_ACTION_VARIANTS[log.action] ?? "neutral";
|
||||||
|
const moduleLabel = AUDIT_MODULE_LABELS[log.module] ?? log.module;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex justify-end">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<div className="relative w-[640px] max-w-full h-full bg-white shadow-2xl flex flex-col overflow-hidden animate-[slideInRight_0.2s_ease-out]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-[8px] bg-[#F0FDF4] flex items-center justify-center">
|
||||||
|
<ClockCounterClockwise size={16} className="text-[#1B9869]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[15px] font-bold text-gray-900">Audit Entry</h2>
|
||||||
|
<p className="text-[12px] text-gray-400">{log.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 transition-colors text-gray-400 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable body */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||||
|
|
||||||
|
{/* Meta grid */}
|
||||||
|
<div className="bg-[#F9FAFB] rounded-[12px] p-4 grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-1">Module</p>
|
||||||
|
<p className="text-[13px] font-semibold text-gray-800">{moduleLabel}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-1">Action</p>
|
||||||
|
<CustomStatus status={log.action} variant={actionVariant} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-1">Entity</p>
|
||||||
|
<p className="text-[13px] font-semibold text-gray-800">
|
||||||
|
{log.entityLabel ?? "—"}
|
||||||
|
</p>
|
||||||
|
{log.entityId && (
|
||||||
|
<p className="text-[11px] text-gray-400 font-mono mt-0.5">{log.entityId}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-1">Timestamp</p>
|
||||||
|
<p className="text-[13px] font-semibold text-gray-800">
|
||||||
|
{new Date(log.createdAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-1">Performed By</p>
|
||||||
|
<p className="text-[13px] font-medium text-gray-700">{log.performedBy ?? "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-1">IP Address</p>
|
||||||
|
<p className="text-[13px] font-medium text-gray-700 font-mono">{log.ipAddress ?? "—"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Before / After diff */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[13px] font-bold text-gray-700 mb-1 flex items-center gap-2">
|
||||||
|
Change Diff
|
||||||
|
{log.action === "CREATE" && (
|
||||||
|
<span className="text-[11px] font-medium text-gray-400">(no before — record was created)</span>
|
||||||
|
)}
|
||||||
|
{log.action === "DELETE" && (
|
||||||
|
<span className="text-[11px] font-medium text-gray-400">(no after — record was deleted)</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div className="rounded-[12px] border border-gray-100 overflow-hidden">
|
||||||
|
<DiffTable before={log.before} after={log.after} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User agent */}
|
||||||
|
{log.userAgent && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-1">User Agent</p>
|
||||||
|
<p className="text-[11px] text-gray-500 font-mono break-all">{log.userAgent}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
MagnifyingGlassIcon
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import {
|
||||||
|
CustomTable,
|
||||||
|
CustomInput,
|
||||||
|
CustomDropdown,
|
||||||
|
CustomStatus,
|
||||||
|
Skeleton,
|
||||||
|
} from "../../../components/custom";
|
||||||
|
import type { Column } from "../../../components/custom/CustomTable";
|
||||||
|
import type { AuditLog, AuditLogFilters } from "../AuditLogsTypes";
|
||||||
|
import {
|
||||||
|
AUDIT_MODULE_LABELS,
|
||||||
|
AUDIT_ACTION_VARIANTS,
|
||||||
|
} from "../AuditLogsTypes";
|
||||||
|
import { getAuditLogs } from "../AuditLogsApi";
|
||||||
|
import AuditLogDetail from "./AuditLogDetail";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
function HeaderLabel({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<span className="text-[13px] font-semibold text-[#6C766D] tracking-[0px]">
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrimaryText({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="text-[13px] font-semibold text-[#0F172B] leading-[18px]">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecondaryText({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="text-[12px] font-medium text-[#6C766D] leading-[16px] mt-0.5">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODULE_OPTIONS = [
|
||||||
|
{ label: "All Modules", value: "" },
|
||||||
|
{ label: "Policy Engine", value: "policy-engine" },
|
||||||
|
{ label: "Recovery Incident", value: "recovery-incident" },
|
||||||
|
{ label: "Cohort", value: "cohort" },
|
||||||
|
{ label: "Master Data", value: "master-data" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ACTION_OPTIONS = [
|
||||||
|
{ label: "All Actions", value: "" },
|
||||||
|
{ label: "Create", value: "CREATE" },
|
||||||
|
{ label: "Update", value: "UPDATE" },
|
||||||
|
{ label: "Delete", value: "DELETE" },
|
||||||
|
{ label: "Status Change", value: "STATUS_CHANGE" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AuditLogsList() {
|
||||||
|
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [moduleFilter, setModuleFilter] = useState("");
|
||||||
|
const [actionFilter, setActionFilter] = useState("");
|
||||||
|
|
||||||
|
// ─── Fetch ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(
|
||||||
|
async (page: number) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const filters: AuditLogFilters = {
|
||||||
|
page,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
module: moduleFilter || undefined,
|
||||||
|
action: actionFilter || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await getAuditLogs(filters);
|
||||||
|
|
||||||
|
// Client-side search on entityLabel / entityId
|
||||||
|
const filtered = search
|
||||||
|
? result.data.filter(
|
||||||
|
(l) =>
|
||||||
|
l.entityLabel?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
l.entityId?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
l.module?.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
: result.data;
|
||||||
|
|
||||||
|
setLogs(filtered);
|
||||||
|
setTotalItems(result.total);
|
||||||
|
setTotalPages(result.totalPages);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch audit logs", err);
|
||||||
|
setLogs([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[moduleFilter, actionFilter, search]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs(currentPage);
|
||||||
|
}, [currentPage, moduleFilter, actionFilter, fetchLogs]);
|
||||||
|
|
||||||
|
// Reset to page 1 when filters change
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [moduleFilter, actionFilter, search]);
|
||||||
|
|
||||||
|
// ─── Columns ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const columns: Column<AuditLog>[] = [
|
||||||
|
{
|
||||||
|
header: <HeaderLabel text="Timestamp" />,
|
||||||
|
accessor: (row) => (
|
||||||
|
<div>
|
||||||
|
<PrimaryText
|
||||||
|
text={new Date(row.createdAt).toLocaleDateString(undefined, {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<SecondaryText
|
||||||
|
text={new Date(row.createdAt).toLocaleTimeString(undefined, {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: <HeaderLabel text="Module" />,
|
||||||
|
accessor: (row) => (
|
||||||
|
<PrimaryText text={AUDIT_MODULE_LABELS[row.module] ?? row.module} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: <HeaderLabel text="Action" />,
|
||||||
|
accessor: (row) => (
|
||||||
|
<CustomStatus
|
||||||
|
status={row.action}
|
||||||
|
variant={AUDIT_ACTION_VARIANTS[row.action] ?? "neutral"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: <HeaderLabel text="Entity" />,
|
||||||
|
accessor: (row) => (
|
||||||
|
<div>
|
||||||
|
<PrimaryText text={row.entityLabel ?? "—"} />
|
||||||
|
{row.entityId && (
|
||||||
|
<SecondaryText text={row.entityId.slice(0, 8) + "…"} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: <HeaderLabel text="Performed By" />,
|
||||||
|
accessor: (row) => (
|
||||||
|
<PrimaryText text={row.performedBy ?? "—"} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: <HeaderLabel text="IP" />,
|
||||||
|
accessor: (row) => (
|
||||||
|
<span className="text-[12px] font-mono text-gray-500">
|
||||||
|
{row.ipAddress ?? "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: <HeaderLabel text="Changes" />,
|
||||||
|
accessor: (row) => {
|
||||||
|
if (!row.before && !row.after) return null;
|
||||||
|
const changedFields = Object.keys({
|
||||||
|
...row.before,
|
||||||
|
...row.after,
|
||||||
|
}).filter(
|
||||||
|
(k) =>
|
||||||
|
JSON.stringify((row.before ?? {})[k]) !==
|
||||||
|
JSON.stringify((row.after ?? {})[k])
|
||||||
|
).length;
|
||||||
|
return (
|
||||||
|
<span className="text-[12px] font-semibold text-gray-500">
|
||||||
|
{row.action === "CREATE"
|
||||||
|
? "New record"
|
||||||
|
: row.action === "DELETE"
|
||||||
|
? "Deleted"
|
||||||
|
: `${changedFields} field${changedFields !== 1 ? "s" : ""}`}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Loading skeleton ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col bg-white rounded-[20px] shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||||
|
<Skeleton width={320} height={36} />
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton width={160} height={36} />
|
||||||
|
<Skeleton width={160} height={36} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 flex flex-col gap-4">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} height={52} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Render ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const startIndex = totalItems > 0 ? (currentPage - 1) * PAGE_SIZE + 1 : 0;
|
||||||
|
const endIndex = Math.min(currentPage * PAGE_SIZE, totalItems);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col gap-6">
|
||||||
|
{/* Summary strip */}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{[
|
||||||
|
{ label: "Total Events", value: totalItems.toLocaleString(), color: "text-gray-900" },
|
||||||
|
{ label: "Creates", value: logs.filter((l) => l.action === "CREATE").length, color: "text-[#1B9869]" },
|
||||||
|
{ label: "Updates", value: logs.filter((l) => l.action === "UPDATE").length, color: "text-blue-500" },
|
||||||
|
{ label: "Deletes", value: logs.filter((l) => l.action === "DELETE").length, color: "text-red-500" },
|
||||||
|
].map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.label}
|
||||||
|
className="bg-[#F8F9FA] rounded-[16px] p-5 flex-1 flex flex-col gap-1"
|
||||||
|
>
|
||||||
|
<span className="text-[13px] font-semibold text-gray-500">{s.label}</span>
|
||||||
|
<span className={`text-[28px] font-bold leading-none ${s.color}`}>
|
||||||
|
{s.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<CustomTable<AuditLog>
|
||||||
|
columns={columns}
|
||||||
|
data={logs}
|
||||||
|
leftHeaderActions={
|
||||||
|
<div className="w-[300px]">
|
||||||
|
<CustomInput
|
||||||
|
placeholder="Search entity, module…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
leftIcon={<MagnifyingGlassIcon size={16} />}
|
||||||
|
className="!bg-[#F3F6F5] !rounded-[10px] !h-[40px] !border !border-[#E5E7EB]"
|
||||||
|
containerClassName="!gap-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
rightHeaderActions={
|
||||||
|
<>
|
||||||
|
<div className="w-[180px]">
|
||||||
|
<CustomDropdown
|
||||||
|
placeholder="All Modules"
|
||||||
|
value={moduleFilter}
|
||||||
|
onChange={(v) => setModuleFilter(v as string)}
|
||||||
|
options={MODULE_OPTIONS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-[160px]">
|
||||||
|
<CustomDropdown
|
||||||
|
placeholder="All Actions"
|
||||||
|
value={actionFilter}
|
||||||
|
onChange={(v) => setActionFilter(v as string)}
|
||||||
|
options={ACTION_OPTIONS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalItems={totalItems}
|
||||||
|
startIndex={startIndex}
|
||||||
|
endIndex={endIndex}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
itemName="Events"
|
||||||
|
onRowClick={(row) => setSelectedLog(row)}
|
||||||
|
rowClassName={() =>
|
||||||
|
"bg-[#F9FAFB] border-transparent cursor-pointer hover:bg-[#F3F4F6]"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Detail side panel */}
|
||||||
|
<AuditLogDetail
|
||||||
|
log={selectedLog}
|
||||||
|
onClose={() => setSelectedLog(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ const NAV_ITEMS = [
|
|||||||
{ label: "Policy Engine", path: "/policy-engine", icon: ShieldCheckIcon },
|
{ label: "Policy Engine", path: "/policy-engine", icon: ShieldCheckIcon },
|
||||||
{ label: "Action Builder", path: "/action-builder", icon: LightningIcon },
|
{ label: "Action Builder", path: "/action-builder", icon: LightningIcon },
|
||||||
{ label: "Configuration", path: "/config", icon: GearIcon },
|
{ label: "Configuration", path: "/config", icon: GearIcon },
|
||||||
{ label: "Audit Logs", path: "/audit", icon: ClockCounterClockwiseIcon },
|
{ label: "Audit Logs", path: "/audit-logs", icon: ClockCounterClockwiseIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface AppSidebarProps {
|
interface AppSidebarProps {
|
||||||
|
|||||||
Reference in New Issue
Block a user