feat: implement audit logs module with routing, sidebar integration, and API services

This commit is contained in:
azeeee05
2026-08-07 16:27:28 +05:30
parent 35a6f81a90
commit 180567f11f
6 changed files with 559 additions and 2 deletions
+2 -1
View File
@@ -8,7 +8,7 @@ import RecoveryIncidentsList from './app/recoveryIncidents/components/RecoveryIn
import RecoveryIncidentTabs from './app/recoveryIncidents/tabs/index'
import ActionBuilderPage from './app/actionBuilder'
import MasterDataManagement from './app/masterData'
import AuditLogsList from './app/auditLogs/components/AuditLogsList'
function AppRoutes() {
return (
<Layout>
@@ -22,6 +22,7 @@ function AppRoutes() {
<Route path="/config" element={<MasterDataManagement />} />
<Route path="/recovery" element={<RecoveryIncidentsList />} />
<Route path="/recovery/:id" element={<RecoveryIncidentTabs />} />
<Route path="/audit-logs" element={<AuditLogsList />} />
</Routes>
</Layout>
)
+19
View File
@@ -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}`);
}
+46
View File
@@ -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>
);
}
+1 -1
View File
@@ -23,7 +23,7 @@ const NAV_ITEMS = [
{ label: "Policy Engine", path: "/policy-engine", icon: ShieldCheckIcon },
{ label: "Action Builder", path: "/action-builder", icon: LightningIcon },
{ label: "Configuration", path: "/config", icon: GearIcon },
{ label: "Audit Logs", path: "/audit", icon: ClockCounterClockwiseIcon },
{ label: "Audit Logs", path: "/audit-logs", icon: ClockCounterClockwiseIcon },
];
interface AppSidebarProps {